more fixes

This commit is contained in:
2026-03-12 07:22:38 +01:00
parent 547215cbe8
commit 4f576ceb04
226 changed files with 14380 additions and 4453 deletions

View File

@@ -18,6 +18,7 @@ export default function AuthorBadge({ user, size = 'md' }) {
const role = (user?.role ?? 'member').toLowerCase()
const cls = ROLE_STYLES[role] ?? ROLE_STYLES.member
const label = ROLE_LABELS[role] ?? 'Member'
const rank = user?.rank ?? null
const imgSize = size === 'sm' ? 'h-8 w-8' : 'h-10 w-10'
@@ -32,9 +33,16 @@ export default function AuthorBadge({ user, size = 'md' }) {
/>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-100">{name}</div>
<span className={`inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
{label}
</span>
<div className="mt-1 flex flex-wrap gap-1.5">
<span className={`inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
{label}
</span>
{rank && (
<span className="inline-flex rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-300">
{rank}
</span>
)}
</div>
</div>
</div>
)

View File

@@ -3,64 +3,151 @@ import React from 'react'
export default function CategoryCard({ category }) {
const name = category?.name ?? 'Untitled'
const slug = category?.slug
const categoryHref = slug ? `/forum/category/${slug}` : null
const threads = category?.thread_count ?? 0
const posts = category?.post_count ?? 0
const lastActivity = category?.last_activity_at
const preview = category?.preview_image ?? '/images/forum-default.jpg'
const href = slug ? `/forum/${slug}` : '#'
const boards = category?.boards ?? []
const boardCount = boards.length
const activeBoards = boards.filter((board) => Number(board?.topics_count ?? 0) > 0).length
const latestBoard = boards
.filter((board) => board?.latest_topic?.last_post_at)
.sort((a, b) => new Date(b.latest_topic.last_post_at) - new Date(a.latest_topic.last_post_at))[0]
const timeAgo = lastActivity ? formatTimeAgo(lastActivity) : null
return (
<a
href={href}
className="group relative block overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 shadow-xl backdrop-blur transition-all duration-300 hover:border-cyan-400/20 hover:shadow-cyan-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400"
>
<div className="group relative block overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 shadow-xl backdrop-blur transition-all duration-300 hover:border-cyan-400/20 hover:shadow-cyan-500/10 focus-within:ring-2 focus-within:ring-cyan-400">
{/* Image */}
<div className="relative aspect-[16/9]">
<img
src={preview}
alt={`${name} preview`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{categoryHref ? (
<a href={categoryHref} className="block h-full">
<img
src={preview}
alt={`${name} preview`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{/* Overlay content */}
<div className="absolute inset-x-0 bottom-0 p-5">
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
<div className="absolute inset-x-0 bottom-0 p-5">
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div>
<h3 className="text-lg font-bold leading-snug text-white transition group-hover:text-cyan-200">
{name}
</h3>
{category?.description && (
<p className="mt-1 line-clamp-2 text-xs text-white/60">{category.description}</p>
)}
{timeAgo && (
<p className="mt-1 text-xs text-white/50">
Last activity: <span className="text-white/70">{timeAgo}</span>
</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-cyan-300">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
{number(posts)} posts
</span>
<span className="flex items-center gap-1.5 text-cyan-300/70">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{number(threads)} topics
</span>
</div>
<div className="mt-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-cyan-200 transition group-hover:text-cyan-100">
View section
</span>
</div>
</div>
</a>
) : (
<>
<img
src={preview}
alt={`${name} preview`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-5">
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div>
<h3 className="text-lg font-bold leading-snug text-white">{name}</h3>
{category?.description && (
<p className="mt-1 line-clamp-2 text-xs text-white/60">{category.description}</p>
)}
{timeAgo && (
<p className="mt-1 text-xs text-white/50">
Last activity: <span className="text-white/70">{timeAgo}</span>
</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-cyan-300">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
{number(posts)} posts
</span>
<span className="flex items-center gap-1.5 text-cyan-300/70">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{number(threads)} topics
</span>
</div>
</div>
</>
)}
</div>
<div className="border-t border-white/8 p-4">
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Boards</div>
<div className="mt-1 text-sm font-semibold text-white">{number(boardCount)}</div>
</div>
<h3 className="text-lg font-bold text-white leading-snug">{name}</h3>
{timeAgo && (
<p className="mt-1 text-xs text-white/50">
Last activity: <span className="text-white/70">{timeAgo}</span>
</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-cyan-300">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
{number(posts)} posts
</span>
<span className="flex items-center gap-1.5 text-cyan-300/70">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{number(threads)} topics
</span>
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Topics</div>
<div className="mt-1 text-sm font-semibold text-white">{number(threads)}</div>
</div>
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Posts</div>
<div className="mt-1 text-sm font-semibold text-white">{number(posts)}</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-white/50">
<span>{number(activeBoards)} active boards</span>
{latestBoard?.title ? <span>Latest: {latestBoard.title}</span> : <span>No recent board activity</span>}
</div>
</div>
</a>
</div>
)
}

View File

@@ -1,9 +1,17 @@
import React, { useState } from 'react'
import AuthorBadge from './AuthorBadge'
const REACTIONS = [
{ key: 'like', label: 'Like', emoji: '👍' },
{ key: 'love', label: 'Love', emoji: '❤️' },
{ key: 'fire', label: 'Amazing', emoji: '🔥' },
{ key: 'laugh', label: 'Funny', emoji: '😂' },
{ key: 'disagree', label: 'Disagree', emoji: '👎' },
]
export default function PostCard({ post, thread, isOp = false, isAuthenticated = false, canModerate = false }) {
const [reported, setReported] = useState(false)
const [reporting, setReporting] = useState(false)
const [reactionState, setReactionState] = useState(post?.reactions ?? { summary: {}, active: null })
const [reacting, setReacting] = useState(false)
const author = post?.user
const content = post?.rendered_content ?? post?.content ?? ''
@@ -11,14 +19,13 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
const editedAt = post?.edited_at
const isEdited = post?.is_edited
const postId = post?.id
const threadId = thread?.id
const threadSlug = thread?.slug
const handleReport = async () => {
if (reporting || reported) return
setReporting(true)
const handleReaction = async (reaction) => {
if (reacting || !isAuthenticated) return
setReacting(true)
try {
const res = await fetch(`/forum/post/${postId}/report`, {
const res = await fetch(`/forum/post/${postId}/react`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -26,10 +33,14 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
'Accept': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({ reaction }),
})
if (res.ok) setReported(true)
if (res.ok) {
const json = await res.json()
setReactionState(json)
}
} catch { /* silent */ }
setReporting(false)
setReacting(false)
}
return (
@@ -82,32 +93,31 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
{/* Footer */}
<footer className="flex flex-wrap items-center gap-3 border-t border-white/[0.06] px-5 py-3 text-xs">
{/* Quote */}
{threadId && (
<a
href={`/forum/thread/${threadId}-${threadSlug ?? ''}?quote=${postId}#reply-content`}
className="rounded-lg border border-white/10 px-2.5 py-1 text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
>
Quote
</a>
)}
<div className="flex flex-wrap items-center gap-2">
{REACTIONS.map((reaction) => {
const count = reactionState?.summary?.[reaction.key] ?? 0
const isActive = reactionState?.active === reaction.key
{/* Report */}
{isAuthenticated && (post?.user_id !== post?.current_user_id) && (
<button
type="button"
onClick={handleReport}
disabled={reported || reporting}
className={[
'rounded-lg border border-white/10 px-2.5 py-1 transition-colors',
reported
? 'text-emerald-400 border-emerald-500/20 cursor-default'
: 'text-zinc-400 hover:border-white/20 hover:text-zinc-200',
].join(' ')}
>
{reported ? 'Reported ✓' : reporting ? 'Reporting…' : 'Report'}
</button>
)}
return (
<button
key={reaction.key}
type="button"
disabled={!isAuthenticated || reacting}
onClick={() => handleReaction(reaction.key)}
className={[
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 transition-colors',
isActive
? 'border-cyan-400/30 bg-cyan-400/10 text-cyan-200'
: 'border-white/10 text-zinc-400 hover:border-white/20 hover:text-zinc-200',
].join(' ')}
title={reaction.label}
>
<span>{reaction.emoji}</span>
<span>{count}</span>
</button>
)
})}
</div>
{/* Edit */}
{(post?.can_edit) && (

View File

@@ -2,7 +2,7 @@ import React, { useState, useRef, useCallback } from 'react'
import Button from '../ui/Button'
import RichTextEditor from './RichTextEditor'
export default function ReplyForm({ threadId, prefill = '', quotedAuthor = null, csrfToken }) {
export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null, csrfToken }) {
const [content, setContent] = useState(prefill)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
@@ -16,7 +16,7 @@ export default function ReplyForm({ threadId, prefill = '', quotedAuthor = null,
setError(null)
try {
const res = await fetch(`/forum/thread/${threadId}/reply`, {
const res = await fetch(`/forum/topic/${topicKey}/reply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -42,7 +42,7 @@ export default function ReplyForm({ threadId, prefill = '', quotedAuthor = null,
}
setSubmitting(false)
}, [content, threadId, csrfToken, submitting])
}, [content, topicKey, csrfToken, submitting])
return (
<form

View File

@@ -10,7 +10,7 @@ export default function ThreadRow({ thread, isFirst = false }) {
const lastUpdate = thread?.last_update ?? thread?.post_date
const isPinned = thread?.is_pinned ?? false
const href = `/forum/thread/${id}-${slug}`
const href = `/forum/topic/${slug}`
return (
<a
@@ -36,7 +36,7 @@ export default function ThreadRow({ thread, isFirst = false }) {
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-white group-hover:text-sky-300 transition-colors">
<h3 className="m-0 truncate text-sm font-semibold leading-tight text-white transition-colors group-hover:text-sky-300">
{title}
</h3>
{isPinned && (