Files
SkinbaseNova/resources/js/components/profile/ProfileHero.jsx
Gregor Klevze dc51d65440 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
2026-03-03 09:48:31 +01:00

198 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react'
import { router } from '@inertiajs/react'
/**
* ProfileHero
* Cover banner + avatar + identity block + action buttons
*/
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName }) {
const [following, setFollowing] = useState(viewerIsFollowing)
const [count, setCount] = useState(followerCount)
const [loading, setLoading] = useState(false)
const [hovering, setHovering] = useState(false)
const uname = user.username || user.name || 'Unknown'
const displayName = user.name || uname
const joinDate = user.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null
const toggleFollow = async () => {
if (loading) return
setLoading(true)
try {
const res = await fetch(`/@${uname.toLowerCase()}/follow`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'Accept': 'application/json',
},
})
if (res.ok) {
const data = await res.json()
setFollowing(data.following)
setCount(data.follower_count)
}
} catch (_) {}
setLoading(false)
}
return (
<div className="relative overflow-hidden border-b border-white/10">
{/* Cover / hero background */}
<div
className="w-full"
style={{
height: 'clamp(160px, 22vw, 260px)',
background: heroBgUrl
? `url('${heroBgUrl}') center/cover no-repeat`
: 'linear-gradient(135deg, #0f1724 0%, #151e2e 50%, #090f1a 100%)',
position: 'relative',
}}
>
{/* Overlay */}
<div
className="absolute inset-0"
style={{
background: heroBgUrl
? 'linear-gradient(to right, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.75) 50%, rgba(15,23,36,0.45) 100%)'
: 'radial-gradient(ellipse at 20% 50%, rgba(77,163,255,.12) 0%, transparent 60%), radial-gradient(ellipse at 80% 20%, rgba(224,122,33,.08) 0%, transparent 50%)',
}}
/>
{/* Nebula grain decoration */}
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
</div>
{/* Identity block overlaps cover at bottom */}
<div className="max-w-6xl mx-auto px-4">
<div className="relative -mt-16 pb-5 flex flex-col sm:flex-row sm:items-end gap-4">
{/* Avatar */}
<div className="shrink-0 z-10">
<img
src={user.avatar_url || '/default/avatar_default.webp'}
alt={`${uname}'s avatar`}
className="w-24 h-24 sm:w-28 sm:h-28 rounded-2xl object-cover ring-4 ring-[#0f1724] shadow-xl shadow-black/60"
/>
</div>
{/* Name + meta */}
<div className="flex-1 min-w-0 pb-1">
<h1 className="text-2xl sm:text-3xl font-bold text-white leading-tight">
{displayName}
</h1>
<p className="text-slate-400 text-sm mt-0.5 font-mono">@{uname}</p>
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-slate-500">
{countryName && (
<span className="flex items-center gap-1.5">
{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>
)}
{joinDate && (
<span className="flex items-center gap-1">
<i className="fa-solid fa-calendar-days fa-fw opacity-60" />
Joined {joinDate}
</span>
)}
{profile?.website && (
<a
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="flex items-center gap-1 text-sky-400 hover:text-sky-300 transition-colors"
>
<i className="fa-solid fa-link fa-fw" />
{(() => {
try {
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
return new URL(url).hostname
} catch {
return profile.website
}
})()}
</a>
)}
</div>
</div>
{/* Action buttons */}
<div className="shrink-0 flex items-center gap-2 pb-1">
{isOwner ? (
<>
<a
href="/dashboard/profile"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border border-white/15 text-slate-300 hover:text-white hover:bg-white/5 transition-all"
aria-label="Edit profile"
>
<i className="fa-solid fa-pen fa-fw" />
<span className="hidden sm:inline">Edit Profile</span>
</a>
<a
href="/studio"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-all shadow-lg shadow-sky-900/30"
aria-label="Open Studio"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
<span className="hidden sm:inline">Studio</span>
</a>
</>
) : (
<>
{/* Follow button */}
<button
onClick={toggleFollow}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
disabled={loading}
aria-label={following ? 'Unfollow' : 'Follow'}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border transition-all ${
following
? hovering
? 'bg-red-500/10 border-red-400/40 text-red-400'
: 'bg-green-500/10 border-green-400/40 text-green-400'
: 'bg-sky-500/10 border-sky-400/40 text-sky-400 hover:bg-sky-500/20'
}`}
>
<i className={`fa-solid fa-fw ${
loading
? 'fa-circle-notch fa-spin'
: following
? hovering ? 'fa-user-minus' : 'fa-user-check'
: 'fa-user-plus'
}`} />
<span>{following ? (hovering ? 'Unfollow' : 'Following') : 'Follow'}</span>
<span className="text-xs opacity-60">({count.toLocaleString()})</span>
</button>
{/* Share */}
<button
onClick={() => {
if (navigator.share) {
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
} else {
navigator.clipboard.writeText(window.location.href)
}
}}
aria-label="Share profile"
className="p-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
<i className="fa-solid fa-share-nodes fa-fw" />
</button>
</>
)}
</div>
</div>
</div>
</div>
)
}