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,96 @@
import React from 'react'
/**
* LinkPreviewCard
* Renders an OG/OpenGraph link preview card.
*
* Props:
* preview { url, title, description, image, site_name }
* onDismiss function|null — if provided, shows a dismiss ✕ button
* loading boolean — shows skeleton while fetching
*/
export default function LinkPreviewCard({ preview, onDismiss, loading = false }) {
if (loading) {
return (
<div className="rounded-xl border border-white/[0.08] bg-white/[0.03] overflow-hidden flex gap-3 p-3 animate-pulse">
<div className="w-20 h-20 rounded-lg bg-white/10 shrink-0" />
<div className="flex-1 min-w-0 flex flex-col gap-2 justify-center">
<div className="h-3 bg-white/10 rounded w-2/3" />
<div className="h-2.5 bg-white/10 rounded w-full" />
<div className="h-2.5 bg-white/10 rounded w-4/5" />
<div className="h-2 bg-white/[0.06] rounded w-1/3 mt-1" />
</div>
</div>
)
}
if (!preview?.url) return null
const domain = (() => {
try { return new URL(preview.url).hostname.replace(/^www\./, '') }
catch { return preview.site_name ?? '' }
})()
return (
<div className="relative rounded-xl border border-white/[0.08] bg-white/[0.03] overflow-hidden hover:border-white/[0.14] transition-colors group">
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer nofollow"
className="flex gap-0 items-stretch"
onClick={(e) => e.stopPropagation()}
>
{/* Image */}
{preview.image ? (
<div className="w-24 shrink-0 bg-white/5">
<img
src={preview.image}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => { e.currentTarget.parentElement.style.display = 'none' }}
/>
</div>
) : (
<div className="w-24 shrink-0 bg-white/[0.04] flex items-center justify-center text-slate-600">
<i className="fa-solid fa-link text-xl" />
</div>
)}
{/* Text */}
<div className="flex-1 min-w-0 px-3 py-2.5 flex flex-col justify-center gap-0.5">
{preview.site_name && (
<p className="text-[10px] uppercase tracking-wide text-sky-500/80 font-medium truncate">
{preview.site_name}
</p>
)}
{preview.title && (
<p className="text-sm font-semibold text-white/90 line-clamp-2 leading-snug">
{preview.title}
</p>
)}
{preview.description && (
<p className="text-xs text-slate-500 line-clamp-2 leading-relaxed mt-0.5">
{preview.description}
</p>
)}
<p className="text-[10px] text-slate-600 mt-1 truncate">
{domain}
</p>
</div>
</a>
{/* Dismiss button */}
{onDismiss && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDismiss() }}
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-black/60 hover:bg-black/80 text-slate-400 hover:text-white flex items-center justify-center transition-colors text-[10px]"
aria-label="Remove link preview"
>
<i className="fa-solid fa-xmark" />
</button>
)}
</div>
)
}