feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
This commit is contained in:
192
resources/js/components/forum/PostCard.jsx
Normal file
192
resources/js/components/forum/PostCard.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react'
|
||||
import AuthorBadge from './AuthorBadge'
|
||||
|
||||
export default function PostCard({ post, thread, isOp = false, isAuthenticated = false, canModerate = false }) {
|
||||
const [reported, setReported] = useState(false)
|
||||
const [reporting, setReporting] = 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 threadId = thread?.id
|
||||
const threadSlug = thread?.slug
|
||||
|
||||
const handleReport = async () => {
|
||||
if (reporting || reported) return
|
||||
setReporting(true)
|
||||
try {
|
||||
const res = await fetch(`/forum/post/${postId}/report`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrf(),
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (res.ok) setReported(true)
|
||||
} catch { /* silent */ }
|
||||
setReporting(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">
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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 ''
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user