293 lines
15 KiB
JavaScript
293 lines
15 KiB
JavaScript
import React, { useState } from 'react'
|
|
import ProfileCoverEditor from './ProfileCoverEditor'
|
|
import LevelBadge from '../xp/LevelBadge'
|
|
import XPProgressBar from '../xp/XPProgressBar'
|
|
import FollowButton from '../social/FollowButton'
|
|
import FollowersPreview from '../social/FollowersPreview'
|
|
import MutualFollowersBadge from '../social/MutualFollowersBadge'
|
|
|
|
function formatCompactNumber(value) {
|
|
const numeric = Number(value ?? 0)
|
|
return numeric.toLocaleString()
|
|
}
|
|
|
|
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
|
|
const [following, setFollowing] = useState(viewerIsFollowing)
|
|
const [count, setCount] = useState(followerCount)
|
|
const [editorOpen, setEditorOpen] = useState(false)
|
|
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
|
|
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
|
|
|
|
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 bio = profile?.bio || profile?.about || ''
|
|
const progressPercent = Math.round(Number(user?.progress_percent ?? 0))
|
|
const heroStats = [
|
|
{ label: 'Followers', value: formatCompactNumber(count) },
|
|
{ label: 'Level', value: `Lv ${formatCompactNumber(user?.level ?? 1)}` },
|
|
]
|
|
|
|
return (
|
|
<>
|
|
<div className="relative mx-auto max-w-7xl px-4 pt-4 md:pt-6">
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-x-10 top-8 -z-10 h-44 rounded-full blur-3xl"
|
|
style={{
|
|
background: 'linear-gradient(90deg, rgba(56,189,248,0.18), rgba(249,115,22,0.14), rgba(59,130,246,0.12))',
|
|
}}
|
|
/>
|
|
|
|
<div className="relative overflow-hidden rounded-[32px] border border-white/10 bg-[#09111f]/80 shadow-[0_24px_80px_rgba(2,6,23,0.55)]">
|
|
<div
|
|
className="w-full h-[208px] md:h-[248px] xl:h-[288px]"
|
|
style={{
|
|
background: coverUrl
|
|
? `url('${coverUrl}') center ${coverPosition}% / cover no-repeat`
|
|
: 'linear-gradient(140deg, #07101d 0%, #0b1726 42%, #07111e 100%)',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<div className="absolute left-4 top-4 z-20 flex flex-wrap items-center gap-2 md:left-6 md:top-6">
|
|
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-200 backdrop-blur-md">
|
|
<span className="h-2 w-2 rounded-full bg-sky-400 shadow-[0_0_12px_rgba(56,189,248,0.9)]" />
|
|
Creator profile
|
|
</span>
|
|
{leaderboardRank?.rank ? (
|
|
<span className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100 backdrop-blur-md">
|
|
<i className="fa-solid fa-sparkles text-[10px]" />
|
|
Top #{leaderboardRank.rank} this week
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{isOwner ? (
|
|
<div className="absolute right-4 top-4 z-20 md:right-6 md:top-6">
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditorOpen(true)}
|
|
className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-black/35 px-3.5 py-2 text-xs font-medium text-white backdrop-blur-md transition-colors hover:bg-black/55"
|
|
aria-label="Edit cover image"
|
|
>
|
|
<i className="fa-solid fa-image" />
|
|
Edit Cover
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
background: coverUrl
|
|
? 'linear-gradient(180deg, rgba(2,6,23,0.16) 0%, rgba(2,6,23,0.28) 38%, rgba(2,6,23,0.9) 100%)'
|
|
: 'radial-gradient(ellipse at 16% 40%, rgba(77,163,255,.18) 0%, transparent 60%), radial-gradient(ellipse at 84% 22%, rgba(224,122,33,.14) 0%, transparent 54%)',
|
|
}}
|
|
/>
|
|
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
|
|
</div>
|
|
|
|
<div className="relative px-4 pb-6 md:px-7 md:pb-7">
|
|
<div className="relative -mt-16 flex flex-col gap-5 md:-mt-20 md:flex-row md:items-start md:gap-6">
|
|
<div className="mx-auto z-10 shrink-0 md:mx-0">
|
|
<img
|
|
src={user.avatar_url || '/default/avatar_default.webp'}
|
|
alt={`${uname}'s avatar`}
|
|
className="h-[112px] w-[112px] rounded-[28px] border border-white/15 bg-[#0b1320] object-cover shadow-[0_0_0_8px_rgba(9,17,31,0.92),0_22px_44px_rgba(2,6,23,0.5)] md:h-[132px] md:w-[132px]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1 text-center md:text-left">
|
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_430px] xl:items-start">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
|
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
|
<i className="fa-solid fa-stars text-[10px] text-sky-300" />
|
|
Profile spotlight
|
|
</span>
|
|
</div>
|
|
|
|
<h1 className="mt-3 text-[30px] font-semibold leading-tight tracking-[-0.03em] text-white md:text-[42px]">
|
|
{displayName}
|
|
</h1>
|
|
<p className="mt-1 font-mono text-sm text-slate-400 md:text-[15px]">@{uname}</p>
|
|
|
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
|
<LevelBadge level={user?.level} rank={user?.rank} />
|
|
{!isOwner ? <MutualFollowersBadge context={followContext} /> : null}
|
|
{countryName ? (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
|
|
{profile?.country_code ? (
|
|
<img
|
|
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
|
|
alt={countryName}
|
|
className="h-auto w-4 rounded-sm"
|
|
onError={(event) => { event.target.style.display = 'none' }}
|
|
/>
|
|
) : null}
|
|
{countryName}
|
|
</span>
|
|
) : null}
|
|
{joinDate ? (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
|
|
<i className="fa-solid fa-calendar-days fa-fw text-slate-500" />
|
|
Joined {joinDate}
|
|
</span>
|
|
) : null}
|
|
{profile?.website ? (
|
|
<a
|
|
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
|
|
target="_blank"
|
|
rel="nofollow noopener noreferrer"
|
|
className="inline-flex items-center gap-1.5 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1.5 text-xs text-sky-200 transition-colors hover:border-sky-300/35 hover:bg-sky-400/15"
|
|
>
|
|
<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>
|
|
) : null}
|
|
</div>
|
|
|
|
{bio ? (
|
|
<p className="mx-auto mt-4 max-w-2xl text-sm leading-relaxed text-slate-300/90 md:mx-0 md:text-[15px]">
|
|
{bio}
|
|
</p>
|
|
) : null}
|
|
|
|
<XPProgressBar
|
|
xp={user?.xp}
|
|
currentLevelXp={user?.current_level_xp}
|
|
nextLevelXp={user?.next_level_xp}
|
|
progressPercent={user?.progress_percent}
|
|
maxLevel={user?.max_level}
|
|
className="mt-4 max-w-3xl"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-3 xl:pt-1">
|
|
{!isOwner && recentFollowers?.length > 0 ? (
|
|
<FollowersPreview
|
|
users={followContext?.follower_overlap?.users?.length ? followContext.follower_overlap.users : recentFollowers}
|
|
label={followContext?.follower_overlap?.label || `${formatCompactNumber(followerCount)} followers`}
|
|
href={`/@${uname}/activity`}
|
|
/>
|
|
) : null}
|
|
|
|
<div className="flex flex-wrap items-center justify-center gap-2 xl:flex-nowrap xl:justify-end">
|
|
{extraActions}
|
|
{isOwner ? (
|
|
<>
|
|
<a
|
|
href="/dashboard/profile"
|
|
className="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-200 transition-all hover:bg-white/[0.08] hover:text-white"
|
|
aria-label="Edit profile"
|
|
>
|
|
<i className="fa-solid fa-pen fa-fw" />
|
|
Edit Profile
|
|
</a>
|
|
<a
|
|
href="/studio"
|
|
className="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-sky-500 to-cyan-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-[0_18px_36px_rgba(14,165,233,0.28)] transition-transform hover:-translate-y-0.5"
|
|
aria-label="Open Studio"
|
|
>
|
|
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
|
|
Studio
|
|
</a>
|
|
</>
|
|
) : (
|
|
<>
|
|
<FollowButton
|
|
username={uname}
|
|
initialFollowing={following}
|
|
initialCount={count}
|
|
className="shrink-0 whitespace-nowrap"
|
|
followingClassName="border border-emerald-400/40 bg-emerald-500/12 text-emerald-300 hover:bg-emerald-500/18"
|
|
idleClassName="border border-sky-400/40 bg-sky-500/12 text-sky-200 hover:bg-sky-500/20"
|
|
sizeClassName="px-3.5 py-2 text-sm"
|
|
onChange={({ following: nextFollowing, followersCount }) => {
|
|
setFollowing(nextFollowing)
|
|
setCount(followersCount)
|
|
}}
|
|
/>
|
|
|
|
<button
|
|
type="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="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
|
|
>
|
|
<i className="fa-solid fa-share-nodes fa-fw" />
|
|
Share
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.72),rgba(9,17,31,0.92))] p-3 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{heroStats.map((fact) => (
|
|
<div
|
|
key={fact.label}
|
|
className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2.5"
|
|
>
|
|
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{fact.label}</div>
|
|
<div className="mt-1 text-sm font-semibold tracking-tight text-white md:text-[15px]">{fact.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-medium text-slate-300">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-cyan-300" />
|
|
Progress {progressPercent}%
|
|
</span>
|
|
|
|
{joinDate ? (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-medium text-slate-300">
|
|
<i className="fa-solid fa-calendar-days text-[10px] text-slate-500" />
|
|
Since {joinDate}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ProfileCoverEditor
|
|
isOpen={editorOpen}
|
|
onClose={() => setEditorOpen(false)}
|
|
coverUrl={coverUrl}
|
|
coverPosition={coverPosition}
|
|
onCoverUpdated={(nextUrl, nextPosition) => {
|
|
setCoverUrl(nextUrl)
|
|
setCoverPosition(nextPosition)
|
|
}}
|
|
onCoverRemoved={() => {
|
|
setCoverUrl(null)
|
|
setCoverPosition(50)
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|