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,165 @@
import React from 'react'
const SOCIAL_ICONS = {
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter' },
deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt' },
instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram' },
behance: { icon: 'fa-brands fa-behance', label: 'Behance' },
artstation: { icon: 'fa-solid fa-palette', label: 'ArtStation' },
youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube' },
website: { icon: 'fa-solid fa-link', label: 'Website' },
}
function InfoRow({ icon, label, children }) {
return (
<div className="flex items-start gap-3 py-2.5 border-b border-white/5 last:border-0">
<i className={`fa-solid ${icon} fa-fw text-slate-500 mt-0.5 w-4 text-center`} />
<div className="flex-1 min-w-0">
<span className="text-xs text-slate-500 block mb-0.5">{label}</span>
<div className="text-sm text-slate-200">{children}</div>
</div>
</div>
)
}
/**
* TabAbout
* Bio, social links, metadata - replaces old sidebar profile card.
*/
export default function TabAbout({ user, profile, socialLinks, countryName, followerCount }) {
const uname = user.username || user.name
const displayName = user.name || uname
const about = profile?.about
const website = profile?.website
const joinDate = user.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
: null
const lastVisit = user.last_visit_at
? (() => {
try {
const d = new Date(user.last_visit_at)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch { return null }
})()
: null
const genderMap = { M: 'Male', F: 'Female', X: 'Non-binary / N/A' }
const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null
const socialEntries = socialLinks
? Object.entries(socialLinks).filter(([, link]) => link?.url)
: []
return (
<div
id="tabpanel-about"
role="tabpanel"
aria-labelledby="tab-about"
className="pt-6 max-w-2xl"
>
{/* Bio */}
{about ? (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20 backdrop-blur">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
<i className="fa-solid fa-quote-left text-purple-400 fa-fw" />
About
</h2>
<p className="text-sm text-slate-300 leading-relaxed whitespace-pre-line">{about}</p>
</div>
) : (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 text-center text-slate-500 text-sm">
No bio yet.
</div>
)}
{/* Info card */}
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
<i className="fa-solid fa-id-card text-sky-400 fa-fw" />
Profile Info
</h2>
<div className="divide-y divide-white/5">
{displayName && displayName !== uname && (
<InfoRow icon="fa-user" label="Display name">{displayName}</InfoRow>
)}
<InfoRow icon="fa-at" label="Username">
<span className="font-mono">@{uname}</span>
</InfoRow>
{genderLabel && (
<InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow>
)}
{countryName && (
<InfoRow icon="fa-earth-americas" label="Country">
<span className="flex items-center gap-2">
{profile?.country_code && (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
alt={countryName}
className="w-4 h-auto rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
/>
)}
{countryName}
</span>
</InfoRow>
)}
{website && (
<InfoRow icon="fa-link" label="Website">
<a
href={website.startsWith('http') ? website : `https://${website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="text-sky-400 hover:text-sky-300 hover:underline transition-colors"
>
{(() => {
try {
const url = website.startsWith('http') ? website : `https://${website}`
return new URL(url).hostname
} catch { return website }
})()}
</a>
</InfoRow>
)}
{joinDate && (
<InfoRow icon="fa-calendar-days" label="Member since">{joinDate}</InfoRow>
)}
{lastVisit && (
<InfoRow icon="fa-clock" label="Last seen">{lastVisit}</InfoRow>
)}
<InfoRow icon="fa-users" label="Followers">{Number(followerCount).toLocaleString()}</InfoRow>
</div>
</div>
{/* Social links */}
{socialEntries.length > 0 && (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
<i className="fa-solid fa-share-nodes text-sky-400 fa-fw" />
Social Links
</h2>
<div className="flex flex-wrap gap-2">
{socialEntries.map(([platform, link]) => {
const si = SOCIAL_ICONS[platform] ?? { icon: 'fa-solid fa-link', label: platform }
const href = link.url.startsWith('http') ? link.url : `https://${link.url}`
return (
<a
key={platform}
href={href}
target="_blank"
rel="nofollow noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm border border-white/10 text-slate-300 hover:text-white hover:bg-white/8 hover:border-sky-400/30 transition-all"
aria-label={si.label}
>
<i className={`${si.icon} fa-fw`} />
<span>{si.label}</span>
</a>
)
})}
</div>
</div>
)}
</div>
)
}