203 lines
6.8 KiB
JavaScript
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 ''
|
|
}
|
|
}
|