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:
2026-03-03 09:48:31 +01:00
parent 1266f81d35
commit dc51d65440
178 changed files with 14308 additions and 665 deletions

View 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 ''
}
}