Files
SkinbaseNova/resources/js/components/forum/PostCard.jsx
2026-03-12 07:22:38 +01:00

203 lines
6.8 KiB
JavaScript

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 [reactionState, setReactionState] = useState(post?.reactions ?? { summary: {}, active: null })
const [reacting, setReacting] = useState(false)
const author = post?.user
const content = post?.rendered_content ?? post?.content ?? ''
const postedAt = post?.created_at
const editedAt = post?.edited_at
const isEdited = post?.is_edited
const postId = post?.id
const threadSlug = thread?.slug
const handleReaction = async (reaction) => {
if (reacting || !isAuthenticated) return
setReacting(true)
try {
const res = await fetch(`/forum/post/${postId}/react`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrf(),
'Accept': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({ reaction }),
})
if (res.ok) {
const json = await res.json()
setReactionState(json)
}
} catch { /* silent */ }
setReacting(false)
}
return (
<article
id={`post-${postId}`}
className="overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 backdrop-blur transition-all hover:border-white/10"
>
{/* Header */}
<header className="flex flex-col gap-3 border-b border-white/[0.06] px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<AuthorBadge user={author} />
<div className="flex items-center gap-2 text-xs text-zinc-500">
{postedAt && (
<time dateTime={postedAt}>
{formatDate(postedAt)}
</time>
)}
{isOp && (
<span className="rounded-full bg-cyan-500/15 px-2.5 py-0.5 text-[11px] font-medium text-cyan-300">
OP
</span>
)}
</div>
</header>
{/* Body */}
<div className="px-5 py-5">
<div
className="prose prose-invert max-w-none text-sm leading-relaxed prose-pre:overflow-x-auto prose-a:text-sky-300 prose-a:hover:text-sky-200"
dangerouslySetInnerHTML={{ __html: content }}
/>
{isEdited && editedAt && (
<p className="mt-3 text-xs text-zinc-600">
Edited {formatTimeAgo(editedAt)}
</p>
)}
{/* Attachments */}
{post?.attachments?.length > 0 && (
<div className="mt-5 space-y-3 border-t border-white/[0.06] pt-4">
<h4 className="text-xs font-semibold uppercase tracking-widest text-white/30">Attachments</h4>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{post.attachments.map((att) => (
<AttachmentItem key={att.id} attachment={att} />
))}
</div>
</div>
)}
</div>
{/* Footer */}
<footer className="flex flex-wrap items-center gap-3 border-t border-white/[0.06] px-5 py-3 text-xs">
<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
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) && (
<a
href={`/forum/post/${postId}/edit`}
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"
>
Edit
</a>
)}
{canModerate && (
<span className="ml-auto text-[11px] text-amber-400/60">Mod</span>
)}
</footer>
</article>
)
}
function AttachmentItem({ attachment }) {
const mime = attachment?.mime_type ?? ''
const isImage = mime.startsWith('image/')
const url = attachment?.url ?? '#'
return (
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-slate-900/60">
{isImage ? (
<a href={url} target="_blank" rel="noopener noreferrer" className="block">
<img
src={url}
alt="Attachment"
loading="lazy"
decoding="async"
className="h-40 w-full object-cover transition-transform hover:scale-[1.02]"
/>
</a>
) : (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 text-sm text-sky-300 hover:text-sky-200"
>
<svg width="16" height="16" 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>
Download attachment
</a>
)}
</div>
)
}
function getCsrf() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? ''
}
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 ''
}
}
function formatTimeAgo(dateStr) {
try {
const now = new Date()
const date = new Date(dateStr)
const diff = Math.floor((now - date) / 1000)
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
return formatDate(dateStr)
} catch {
return ''
}
}