203 lines
7.3 KiB
JavaScript
203 lines
7.3 KiB
JavaScript
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,
|
|
}) {
|
|
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>
|
|
<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}
|
|
/>
|
|
)
|
|
) : (
|
|
<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 ''
|
|
}
|
|
}
|