Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,83 @@
import React from 'react'
import Breadcrumbs from '../../components/forum/Breadcrumbs'
import ThreadRow from '../../components/forum/ThreadRow'
import Pagination from '../../components/forum/Pagination'
import Button from '../../components/ui/Button'
export default function ForumCategory({ category, parentCategory = null, threads = [], pagination = {}, isAuthenticated = false }) {
const name = category?.name ?? 'Category'
const slug = category?.slug
const breadcrumbs = [
{ label: 'Home', href: '/' },
{ label: 'Forum', href: '/forum' },
...(parentCategory ? [{ label: parentCategory.name, href: `/forum/category/${parentCategory.slug}` }] : []),
{ label: name },
]
return (
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
{/* Breadcrumbs */}
<Breadcrumbs items={breadcrumbs} />
{/* Header */}
<div className="mt-5 mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Forum</p>
<h1 className="text-3xl font-bold text-white leading-tight">{name}</h1>
{category?.description && <p className="mt-2 text-sm text-white/50">{category.description}</p>}
</div>
{isAuthenticated && slug && (
<a href={`/forum/${slug}/new`}>
<Button variant="primary" size="sm"
leftIcon={
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
}
>
New topic
</Button>
</a>
)}
</div>
{/* Thread list */}
<section className="overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 backdrop-blur">
{/* Column header */}
<div className="flex items-center gap-4 border-b border-white/[0.06] px-5 py-3">
<span className="flex-1 text-xs font-semibold uppercase tracking-widest text-white/30">Topics</span>
<span className="w-16 text-center text-xs font-semibold uppercase tracking-widest text-white/30">Replies</span>
</div>
{threads.length === 0 ? (
<div className="px-5 py-12 text-center">
<svg className="mx-auto mb-4 text-zinc-600" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
<p className="text-sm text-zinc-500">No topics in this board yet.</p>
{isAuthenticated && slug && (
<a href={`/forum/${slug}/new`} className="mt-3 inline-block text-sm text-sky-300 hover:text-sky-200">
Be the first to start a discussion
</a>
)}
</div>
) : (
<div>
{threads.map((thread, i) => (
<ThreadRow key={thread.topic_id ?? thread.id ?? i} thread={thread} isFirst={i === 0} />
))}
</div>
)}
</section>
{/* Pagination */}
{pagination?.last_page > 1 && (
<div className="mt-6">
<Pagination meta={pagination} />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,105 @@
import React, { useState, useCallback } from 'react'
import Breadcrumbs from '../../components/forum/Breadcrumbs'
import Button from '../../components/ui/Button'
import RichTextEditor from '../../components/forum/RichTextEditor'
import TurnstileField from '../../components/security/TurnstileField'
import { populateBotFingerprint } from '../../lib/security/botFingerprint'
export default function ForumEditPost({ post, thread, csrfToken, errors = {}, captcha = {} }) {
const [content, setContent] = useState(post?.content ?? '')
const [captchaToken, setCaptchaToken] = useState('')
const [submitting, setSubmitting] = useState(false)
const breadcrumbs = [
{ label: 'Home', href: '/' },
{ label: 'Forum', href: '/forum' },
{ label: thread?.title ?? 'Topic', href: thread?.slug ? `/forum/topic/${thread.slug}` : '/forum' },
{ label: 'Edit post' },
]
const handleSubmit = useCallback((e) => {
if (submitting) return
setSubmitting(true)
// Let the form submit normally for PRG
populateBotFingerprint(e.currentTarget).finally(() => {
e.currentTarget.submit()
})
e.preventDefault()
}, [submitting])
return (
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
<Breadcrumbs items={breadcrumbs} />
{/* Header */}
<div className="mt-5 mb-6">
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Edit</p>
<h1 className="text-2xl font-bold text-white leading-tight">Edit post</h1>
</div>
{/* Form */}
<form
method="POST"
action={`/forum/post/${post?.id}`}
onSubmit={handleSubmit}
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
>
<input type="hidden" name="_token" value={csrfToken} />
<input type="hidden" name="_method" value="PUT" />
<input type="text" name="homepage_url" defaultValue="" autoComplete="off" className="hidden" aria-hidden="true" tabIndex={-1} />
<input type="hidden" name="_bot_fingerprint" value="" />
<input type="hidden" name={captcha.inputName || 'cf-turnstile-response'} value={captchaToken} />
{errors.bot ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{Array.isArray(errors.bot) ? errors.bot[0] : errors.bot}
</div>
) : null}
{errors.captcha ? (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
{Array.isArray(errors.captcha) ? errors.captcha[0] : errors.captcha}
</div>
) : null}
{/* Rich text editor */}
<div>
<label className="mb-1.5 block text-sm font-medium text-white/85">
Content
</label>
<RichTextEditor
content={content}
onChange={setContent}
placeholder="Edit your post…"
error={errors.content}
minHeight={14}
autofocus={false}
/>
<input type="hidden" name="content" value={content} />
</div>
{captcha.siteKey ? (
<TurnstileField
provider={captcha.provider}
siteKey={captcha.siteKey}
scriptUrl={captcha.scriptUrl}
onToken={setCaptchaToken}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
) : null}
{/* Actions */}
<div className="flex items-center justify-between pt-2">
<a
href={thread?.slug ? `/forum/topic/${thread.slug}` : '/forum'}
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
>
Cancel
</a>
<Button type="submit" variant="primary" size="md" loading={submitting}>
Save changes
</Button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,149 @@
import React from 'react'
import CategoryCard from '../../components/forum/CategoryCard'
export default function ForumIndex({ categories = [], trendingTopics = [], latestTopics = [] }) {
const totalThreads = categories.reduce((sum, cat) => sum + (Number(cat?.thread_count) || 0), 0)
const totalPosts = categories.reduce((sum, cat) => sum + (Number(cat?.post_count) || 0), 0)
const sortedByActivity = [...categories].sort((a, b) => {
const aTime = a?.last_activity_at ? new Date(a.last_activity_at).getTime() : 0
const bTime = b?.last_activity_at ? new Date(b.last_activity_at).getTime() : 0
return bTime - aTime
})
const latestActive = sortedByActivity[0] ?? null
return (
<div className="pb-20">
<section className="relative overflow-hidden border-b border-white/10 bg-[radial-gradient(circle_at_15%_20%,rgba(34,211,238,0.24),transparent_40%),radial-gradient(circle_at_80%_0%,rgba(56,189,248,0.16),transparent_42%),linear-gradient(180deg,rgba(10,14,26,0.96),rgba(8,12,22,0.92))]">
<div className="pointer-events-none absolute inset-0 opacity-40 [background-image:linear-gradient(rgba(255,255,255,0.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.06)_1px,transparent_1px)] [background-size:40px_40px]" />
<div className="relative mx-auto w-full max-w-[1400px] px-4 py-10 sm:px-6 lg:px-10 lg:py-14">
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr] lg:items-end">
<div>
<p className="mb-2 inline-flex items-center gap-2 rounded-full border border-cyan-300/30 bg-cyan-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-100">
Community Hub
</p>
<h1 className="text-4xl font-black leading-[0.95] tracking-[-0.02em] text-white sm:text-5xl lg:text-6xl">
Skinbase Forum
</h1>
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-slate-200/80 sm:text-base">
Ask questions, share progress, and join focused conversations across every part of Skinbase.
This page is your launch point to active topics and community knowledge.
</p>
<div className="mt-6 flex flex-wrap items-center gap-3">
<a href="/forum" className="inline-flex items-center gap-2 rounded-xl bg-cyan-400 px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-cyan-300">
Explore Categories
<span aria-hidden="true">&rarr;</span>
</a>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
<StatCard label="Sections" value={number(categories.length)} />
<StatCard label="Topics" value={number(totalThreads)} />
<StatCard label="Posts" value={number(totalPosts)} />
</div>
</div>
{latestActive && (
<div className="mt-7 rounded-2xl border border-white/15 bg-white/[0.04] p-4 backdrop-blur">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/50">Latest Activity</p>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
<a
href={`/forum/${latestActive.board_slug ?? latestActive.slug}`}
className="text-base font-semibold text-cyan-200 transition hover:text-cyan-100"
>
{latestActive.name}
</a>
<span className="text-xs text-white/45">{formatLastActivity(latestActive.last_activity_at)}</span>
</div>
</div>
)}
</div>
</section>
<section className="mx-auto w-full max-w-[1400px] px-4 pt-8 sm:px-6 lg:px-10">
<div className="mb-5 flex items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-white/40">Browse</p>
<h2 className="mt-1 text-2xl font-bold text-white sm:text-3xl">Forum Sections</h2>
</div>
<p className="text-xs text-white/50 sm:text-sm">Choose a section to view threads or start a discussion.</p>
</div>
{/* Category grid */}
{categories.length === 0 ? (
<div className="rounded-2xl border border-white/[0.08] bg-nova-800/50 p-12 text-center">
<svg className="mx-auto mb-4 text-zinc-600" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
<p className="text-sm text-zinc-400">No forum categories available yet.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{categories.map((cat) => (
<CategoryCard key={cat.id ?? cat.slug} category={cat} />
))}
</div>
)}
</section>
<section className="mx-auto grid w-full max-w-[1400px] gap-5 px-4 pt-8 sm:px-6 lg:grid-cols-2 lg:px-10">
<Panel title="Trending Topics" items={trendingTopics} emptyLabel="Trending topics will appear once boards become active." />
<Panel title="Latest Topics" items={latestTopics} emptyLabel="Latest topics will appear here." />
</section>
</div>
)
}
function Panel({ title, items, emptyLabel }) {
return (
<div className="rounded-2xl border border-white/[0.08] bg-nova-800/50 p-5 backdrop-blur">
<h2 className="text-lg font-semibold text-white">{title}</h2>
{items.length === 0 ? (
<p className="mt-3 text-sm text-white/45">{emptyLabel}</p>
) : (
<div className="mt-4 space-y-3">
{items.map((item) => (
<a key={item.slug} href={`/forum/topic/${item.slug}`} className="block rounded-xl border border-white/6 px-4 py-3 transition hover:border-cyan-400/20 hover:bg-white/[0.03]">
<div className="text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 flex flex-wrap gap-3 text-xs text-white/45">
{item.board && <span>{item.board}</span>}
{item.author && <span>by {item.author}</span>}
{typeof item.replies_count === 'number' && <span>{item.replies_count} replies</span>}
{item.score !== undefined && <span>score {item.score}</span>}
{item.last_post_at && <span>{formatLastActivity(item.last_post_at)}</span>}
</div>
</a>
))}
</div>
)}
</div>
)
}
function StatCard({ label, value }) {
return (
<div className="rounded-xl border border-white/15 bg-white/[0.04] px-4 py-3 backdrop-blur">
<p className="text-[11px] uppercase tracking-[0.14em] text-white/50">{label}</p>
<p className="mt-1 text-2xl font-bold text-white">{value}</p>
</div>
)
}
function number(n) {
return (n ?? 0).toLocaleString()
}
function formatLastActivity(value) {
if (!value) {
return 'No recent activity'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return 'No recent activity'
}
return `Updated ${date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}`
}

View File

@@ -0,0 +1,119 @@
import React, { useState, useCallback } from 'react'
import Breadcrumbs from '../../components/forum/Breadcrumbs'
import Button from '../../components/ui/Button'
import TextInput from '../../components/ui/TextInput'
import RichTextEditor from '../../components/forum/RichTextEditor'
import TurnstileField from '../../components/security/TurnstileField'
import { populateBotFingerprint } from '../../lib/security/botFingerprint'
export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {}, captcha = {} }) {
const [title, setTitle] = useState(oldValues.title ?? '')
const [content, setContent] = useState(oldValues.content ?? '')
const [captchaToken, setCaptchaToken] = useState('')
const [submitting, setSubmitting] = useState(false)
const slug = category?.slug
const categoryName = category?.name ?? 'Category'
const breadcrumbs = [
{ label: 'Home', href: '/' },
{ label: 'Forum', href: '/forum' },
{ label: categoryName, href: slug ? `/forum/${slug}` : '/forum' },
{ label: 'New topic' },
]
const handleSubmit = useCallback(async (e) => {
e.preventDefault()
if (submitting) return
setSubmitting(true)
// Standard form submission to keep server-side validation + redirect
await populateBotFingerprint(e.currentTarget)
e.target.submit()
}, [submitting])
return (
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
<Breadcrumbs items={breadcrumbs} />
{/* Header */}
<div className="mt-5 mb-6">
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">New topic</p>
<h1 className="text-2xl font-bold text-white leading-tight">
Create topic in {categoryName}
</h1>
</div>
{/* Form */}
<form
method="POST"
action={`/forum/${slug}/new`}
onSubmit={handleSubmit}
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
>
<input type="hidden" name="_token" value={csrfToken} />
<input type="text" name="homepage_url" defaultValue="" autoComplete="off" className="hidden" aria-hidden="true" tabIndex={-1} />
<input type="hidden" name="_bot_fingerprint" value="" />
<input type="hidden" name={captcha.inputName || 'cf-turnstile-response'} value={captchaToken} />
{errors.bot ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{Array.isArray(errors.bot) ? errors.bot[0] : errors.bot}
</div>
) : null}
{errors.captcha ? (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
{Array.isArray(errors.captcha) ? errors.captcha[0] : errors.captcha}
</div>
) : null}
<TextInput
label="Title"
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
maxLength={255}
placeholder="Thread title…"
error={errors.title}
/>
{/* Rich text editor */}
<div>
<label className="mb-1.5 block text-sm font-medium text-white/85">
Content <span className="text-red-400 ml-1">*</span>
</label>
<RichTextEditor
content={content}
onChange={setContent}
placeholder="Write your post…"
error={errors.content}
minHeight={14}
autofocus={false}
/>
<input type="hidden" name="content" value={content} />
</div>
{captcha.siteKey ? (
<TurnstileField
provider={captcha.provider}
siteKey={captcha.siteKey}
scriptUrl={captcha.scriptUrl}
onToken={setCaptchaToken}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
) : null}
{/* Submit */}
<div className="flex items-center justify-between pt-2">
<a href={`/forum/${slug}`} className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
Cancel
</a>
<Button type="submit" variant="primary" size="md" loading={submitting}>
Publish topic
</Button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import React from 'react'
import Breadcrumbs from '../../components/forum/Breadcrumbs'
export default function ForumSection({ category, boards = [] }) {
const name = category?.name ?? 'Forum Section'
const description = category?.description
const preview = category?.preview_image ?? '/images/forum/default.jpg'
const breadcrumbs = [
{ label: 'Home', href: '/' },
{ label: 'Forum', href: '/forum' },
{ label: name },
]
return (
<div className="mx-auto max-w-6xl px-4 pb-20 pt-10 sm:px-6 lg:px-8">
<Breadcrumbs items={breadcrumbs} />
<section className="mt-5 overflow-hidden rounded-3xl border border-white/10 bg-nova-800/55 shadow-xl backdrop-blur">
<div className="relative h-56 overflow-hidden sm:h-64">
<img src={preview} alt={`${name} preview`} className="h-full w-full object-cover object-center" />
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-6 sm:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-200/85">Forum Section</p>
<h1 className="mt-2 text-3xl font-black text-white sm:text-4xl">{name}</h1>
{description && <p className="mt-2 max-w-3xl text-sm text-white/70 sm:text-base">{description}</p>}
</div>
</div>
</section>
<section className="mt-8 rounded-2xl border border-white/8 bg-nova-800/45 p-5 backdrop-blur sm:p-6">
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-white/40">Subcategories</p>
<h2 className="mt-1 text-2xl font-bold text-white">Browse boards</h2>
</div>
<p className="text-xs text-white/45 sm:text-sm">Select a board to open its thread list.</p>
</div>
{boards.length === 0 ? (
<div className="py-12 text-center text-sm text-white/45">No boards are available in this section yet.</div>
) : (
<div className="mt-5 grid gap-4 md:grid-cols-2">
{boards.map((board) => (
<a key={board.id ?? board.slug} href={`/forum/${board.slug}`} className="rounded-2xl border border-white/8 bg-white/[0.02] p-5 transition hover:border-cyan-400/25 hover:bg-white/[0.04]">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-white">{board.title}</h3>
{board.description && <p className="mt-2 text-sm text-white/55">{board.description}</p>}
</div>
<span className="rounded-full border border-cyan-300/20 bg-cyan-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-cyan-200">
Open
</span>
</div>
<div className="mt-4 flex flex-wrap gap-4 text-xs text-white/50">
<span>{board.topics_count ?? 0} topics</span>
<span>{board.posts_count ?? 0} posts</span>
{board.latest_topic?.title && <span>Latest: {board.latest_topic.title}</span>}
</div>
</a>
))}
</div>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,209 @@
import React, { useState, useCallback } from 'react'
import Breadcrumbs from '../../components/forum/Breadcrumbs'
import PostCard from '../../components/forum/PostCard'
import ReplyForm from '../../components/forum/ReplyForm'
import Pagination from '../../components/forum/Pagination'
export default function ForumThread({
thread,
category,
forumCategory,
author,
opPost,
posts = [],
pagination = {},
replyCount = 0,
sort = 'asc',
quotedPost = null,
replyPrefill = '',
isAuthenticated = false,
canModerate = false,
csrfToken = '',
status = null,
captcha = {},
}) {
const [currentSort, setCurrentSort] = useState(sort)
const breadcrumbs = [
{ label: 'Home', href: '/' },
{ label: 'Forum', href: '/forum' },
...(forumCategory?.name ? [{ label: forumCategory.name }] : []),
{ label: category?.name ?? 'Board', href: category?.slug ? `/forum/${category.slug}` : '/forum' },
{ label: thread?.title ?? 'Thread' },
]
const handleSortToggle = useCallback(() => {
const newSort = currentSort === 'asc' ? 'desc' : 'asc'
setCurrentSort(newSort)
const url = new URL(window.location.href)
url.searchParams.set('sort', newSort)
window.location.href = url.toString()
}, [currentSort])
return (
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto space-y-5">
<Breadcrumbs items={breadcrumbs} />
{/* Status flash */}
{status && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
{status}
</div>
)}
{/* Thread header card */}
<section className="rounded-2xl border border-white/[0.06] bg-nova-800/50 p-5 backdrop-blur">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-2xl font-bold text-white leading-snug">{thread?.title}</h1>
{thread?.description ? (
<p className="mt-3 max-w-3xl text-sm leading-6 text-zinc-300 sm:text-[15px]">
{thread.description}
</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<span>By {author?.name ?? 'Unknown'}</span>
<span className="text-zinc-700"></span>
{thread?.created_at && (
<time dateTime={thread.created_at}>{formatDate(thread.created_at)}</time>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full bg-sky-500/15 px-2.5 py-1 text-sky-300">
{number(thread?.views ?? 0)} views
</span>
<span className="rounded-full bg-cyan-500/15 px-2.5 py-1 text-cyan-300">
{number(replyCount)} replies
</span>
{thread?.is_pinned && (
<span className="rounded-full bg-amber-500/15 px-2.5 py-1 text-amber-300">Pinned</span>
)}
{thread?.is_locked && (
<span className="rounded-full bg-red-500/15 px-2.5 py-1 text-red-300">Locked</span>
)}
</div>
</div>
{/* Moderation tools */}
{canModerate && (
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-white/[0.06] pt-3">
{thread?.is_locked ? (
<ModForm action={`/forum/topic/${thread.slug}/unlock`} csrf={csrfToken} label="Unlock" variant="danger" />
) : (
<ModForm action={`/forum/topic/${thread.slug}/lock`} csrf={csrfToken} label="Lock" variant="danger" />
)}
{thread?.is_pinned ? (
<ModForm action={`/forum/topic/${thread.slug}/unpin`} csrf={csrfToken} label="Unpin" variant="warning" />
) : (
<ModForm action={`/forum/topic/${thread.slug}/pin`} csrf={csrfToken} label="Pin" variant="warning" />
)}
</div>
)}
</section>
{/* Sort toggle + reply count */}
<div className="flex items-center justify-between">
<p className="text-xs text-zinc-500">{number(replyCount)} {replyCount === 1 ? 'reply' : 'replies'}</p>
<button
onClick={handleSortToggle}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points={currentSort === 'asc' ? '18 15 12 21 6 15' : '18 9 12 3 6 9'} />
<line x1="12" y1="3" x2="12" y2="21" />
</svg>
{currentSort === 'asc' ? 'Oldest first' : 'Newest first'}
</button>
</div>
{/* OP Post */}
{opPost && (
<PostCard
post={opPost}
thread={thread}
isOp
isAuthenticated={isAuthenticated}
canModerate={canModerate}
/>
)}
{/* Reply list */}
<section className="space-y-4" aria-label="Replies">
{posts.length === 0 ? (
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/40 px-5 py-8 text-center text-zinc-500 text-sm">
No replies yet. Be the first to respond!
</div>
) : (
posts.map((post) => (
<PostCard
key={post.id}
post={post}
thread={thread}
isAuthenticated={isAuthenticated}
canModerate={canModerate}
/>
))
)}
</section>
{/* Pagination */}
{pagination?.last_page > 1 && (
<div className="sticky bottom-3 z-10 rounded-xl border border-white/[0.06] bg-nova-800/80 p-2 backdrop-blur">
<Pagination meta={pagination} />
</div>
)}
{/* Reply form or locked / auth prompt */}
{isAuthenticated ? (
thread?.is_locked ? (
<div className="rounded-2xl border border-red-500/20 bg-red-500/5 px-5 py-4 text-sm text-red-300">
This thread is locked. Replies are disabled.
</div>
) : (
<ReplyForm
topicKey={thread?.slug ?? thread?.id}
prefill={replyPrefill}
quotedAuthor={quotedPost?.user?.name}
csrfToken={csrfToken}
captcha={captcha}
/>
)
) : (
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/40 px-5 py-5 text-sm text-zinc-400">
<a href="/login" className="text-sky-300 hover:text-sky-200 font-medium">Sign in</a> to post a reply.
</div>
)}
</div>
)
}
function ModForm({ action, csrf, label, variant }) {
const colors = variant === 'danger'
? 'bg-red-500/15 text-red-300 hover:bg-red-500/25 border-red-500/20'
: 'bg-amber-500/15 text-amber-300 hover:bg-amber-500/25 border-amber-500/20'
return (
<form method="POST" action={action}>
<input type="hidden" name="_token" value={csrf} />
<button type="submit" className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${colors}`}>
{label}
</button>
</form>
)
}
function number(n) {
return (n ?? 0).toLocaleString()
}
function formatDate(dateStr) {
try {
const d = new Date(dateStr)
return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
} catch {
return ''
}
}