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
166 lines
6.5 KiB
JavaScript
166 lines
6.5 KiB
JavaScript
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>
|
|
)
|
|
}
|