Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz
This commit is contained in:
@@ -10,6 +10,100 @@ const SOCIAL_ICONS = {
|
||||
website: { icon: 'fa-solid fa-link', label: 'Website' },
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function formatRelativeDate(value) {
|
||||
if (!value) return null
|
||||
|
||||
try {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
const now = new Date()
|
||||
const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000)
|
||||
const absSeconds = Math.abs(diffSeconds)
|
||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||
|
||||
if (absSeconds < 3600) {
|
||||
return formatter.format(Math.round(diffSeconds / 60), 'minute')
|
||||
}
|
||||
|
||||
if (absSeconds < 86400) {
|
||||
return formatter.format(Math.round(diffSeconds / 3600), 'hour')
|
||||
}
|
||||
|
||||
if (absSeconds < 604800) {
|
||||
return formatter.format(Math.round(diffSeconds / 86400), 'day')
|
||||
}
|
||||
|
||||
if (absSeconds < 2629800) {
|
||||
return formatter.format(Math.round(diffSeconds / 604800), 'week')
|
||||
}
|
||||
|
||||
return formatter.format(Math.round(diffSeconds / 2629800), 'month')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortDate(value) {
|
||||
if (!value) return null
|
||||
|
||||
try {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function truncateText(value, maxLength = 140) {
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) return ''
|
||||
if (text.length <= maxLength) return text
|
||||
|
||||
return `${text.slice(0, maxLength).trimEnd()}...`
|
||||
}
|
||||
|
||||
function buildInterestGroups(artworks = []) {
|
||||
const categoryMap = new Map()
|
||||
const contentTypeMap = new Map()
|
||||
|
||||
artworks.forEach((artwork) => {
|
||||
const categoryKey = String(artwork?.category_slug || artwork?.category || '').trim().toLowerCase()
|
||||
const categoryLabel = String(artwork?.category || '').trim()
|
||||
const contentTypeKey = String(artwork?.content_type_slug || artwork?.content_type || '').trim().toLowerCase()
|
||||
const contentTypeLabel = String(artwork?.content_type || '').trim()
|
||||
|
||||
if (categoryKey && categoryLabel) {
|
||||
categoryMap.set(categoryKey, {
|
||||
label: categoryLabel,
|
||||
count: (categoryMap.get(categoryKey)?.count ?? 0) + 1,
|
||||
})
|
||||
}
|
||||
|
||||
if (contentTypeKey && contentTypeLabel) {
|
||||
contentTypeMap.set(contentTypeKey, {
|
||||
label: contentTypeLabel,
|
||||
count: (contentTypeMap.get(contentTypeKey)?.count ?? 0) + 1,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const toSortedList = (source) => Array.from(source.values())
|
||||
.sort((left, right) => right.count - left.count || left.label.localeCompare(right.label))
|
||||
.slice(0, 5)
|
||||
|
||||
return {
|
||||
categories: toSortedList(categoryMap),
|
||||
contentTypes: toSortedList(contentTypeMap),
|
||||
}
|
||||
}
|
||||
|
||||
function InfoRow({ icon, label, children }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2.5 border-b border-white/5 last:border-0">
|
||||
@@ -22,11 +116,47 @@ function InfoRow({ icon, label, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, tone = 'sky' }) {
|
||||
const tones = {
|
||||
sky: 'text-sky-300 bg-sky-400/10 border-sky-300/15',
|
||||
amber: 'text-amber-200 bg-amber-300/10 border-amber-300/15',
|
||||
emerald: 'text-emerald-200 bg-emerald-400/10 border-emerald-300/15',
|
||||
violet: 'text-violet-200 bg-violet-400/10 border-violet-300/15',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_18px_44px_rgba(2,6,23,0.18)]">
|
||||
<div className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${tones[tone] || tones.sky}`}>
|
||||
<i className={`fa-solid ${icon}`} />
|
||||
</div>
|
||||
<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tracking-tight text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionCard({ icon, eyebrow, title, children, className = '' }) {
|
||||
return (
|
||||
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_52px_rgba(2,6,23,0.18)] md:p-6 ${className}`.trim()}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.05] text-sky-300">
|
||||
<i className={`${icon} text-base`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{eyebrow}</p>
|
||||
<h2 className="mt-1 text-xl font-semibold tracking-[-0.02em] text-white md:text-2xl">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TabAbout
|
||||
* Bio, social links, metadata - replaces old sidebar profile card.
|
||||
*/
|
||||
export default function TabAbout({ user, profile, socialLinks, countryName, followerCount }) {
|
||||
export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank }) {
|
||||
const uname = user.username || user.name
|
||||
const displayName = user.name || uname
|
||||
const about = profile?.about
|
||||
@@ -47,119 +177,344 @@ export default function TabAbout({ user, profile, socialLinks, countryName, foll
|
||||
|
||||
const genderMap = { M: 'Male', F: 'Female', X: 'Non-binary / N/A' }
|
||||
const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null
|
||||
const birthDate = profile?.birthdate
|
||||
? (() => {
|
||||
try {
|
||||
return new Date(profile.birthdate).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
||||
} catch { return null }
|
||||
})()
|
||||
: null
|
||||
const lastSeenRelative = formatRelativeDate(user.last_visit_at)
|
||||
|
||||
const socialEntries = socialLinks
|
||||
? Object.entries(socialLinks).filter(([, link]) => link?.url)
|
||||
: []
|
||||
const followers = recentFollowers ?? []
|
||||
const recentAchievements = Array.isArray(achievements?.recent) ? achievements.recent : []
|
||||
const stories = Array.isArray(creatorStories) ? creatorStories : []
|
||||
const comments = Array.isArray(profileComments) ? profileComments : []
|
||||
const interestGroups = buildInterestGroups(Array.isArray(artworks) ? artworks : [])
|
||||
const summaryCards = [
|
||||
{ icon: 'fa-user-group', label: 'Followers', value: formatNumber(followerCount), tone: 'sky' },
|
||||
{ icon: 'fa-images', label: 'Uploads', value: formatNumber(stats?.uploads_count ?? 0), tone: 'violet' },
|
||||
{ icon: 'fa-eye', label: 'Profile views', value: formatNumber(stats?.profile_views_count ?? 0), tone: 'emerald' },
|
||||
{ icon: 'fa-trophy', label: 'Weekly rank', value: leaderboardRank?.rank ? `#${formatNumber(leaderboardRank.rank)}` : 'Unranked', tone: 'amber' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-about"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-about"
|
||||
className="pt-6 max-w-2xl"
|
||||
className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
|
||||
>
|
||||
{/* 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(String(profile.country_code).toUpperCase())}.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 className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map((card) => (
|
||||
<StatCard key={card.label} {...card} />
|
||||
))}
|
||||
</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 className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_380px]">
|
||||
<div className="space-y-6">
|
||||
<SectionCard icon="fa-solid fa-circle-info" eyebrow="Profile story" title={`About ${displayName}`} className="bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.04),rgba(249,115,22,0.05))]">
|
||||
{about ? (
|
||||
<p className="whitespace-pre-line text-[15px] leading-8 text-slate-200/90">{about}</p>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-5 py-8 text-center text-sm text-slate-400">
|
||||
This creator has not written a public bio yet.
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon="fa-solid fa-address-card" eyebrow="Details" title="Profile information">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{displayName && displayName !== uname ? (
|
||||
<InfoRow icon="fa-user" label="Display name">{displayName}</InfoRow>
|
||||
) : null}
|
||||
<InfoRow icon="fa-at" label="Username"><span className="font-mono">@{uname}</span></InfoRow>
|
||||
{genderLabel ? <InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow> : null}
|
||||
{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(String(profile.country_code).toUpperCase())}.png`}
|
||||
alt={countryName}
|
||||
className="h-auto w-4 rounded-sm"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
) : null}
|
||||
{countryName}
|
||||
</span>
|
||||
</InfoRow>
|
||||
) : null}
|
||||
{website ? (
|
||||
<InfoRow icon="fa-link" label="Website">
|
||||
<a
|
||||
href={website.startsWith('http') ? website : `https://${website}`}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="text-sky-300 transition-colors hover:text-sky-200 hover:underline"
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
const url = website.startsWith('http') ? website : `https://${website}`
|
||||
return new URL(url).hostname
|
||||
} catch { return website }
|
||||
})()}
|
||||
</a>
|
||||
</InfoRow>
|
||||
) : null}
|
||||
{birthDate ? <InfoRow icon="fa-cake-candles" label="Birth date">{birthDate}</InfoRow> : null}
|
||||
{joinDate ? <InfoRow icon="fa-calendar-days" label="Member since">{joinDate}</InfoRow> : null}
|
||||
{lastVisit ? <InfoRow icon="fa-clock" label="Last seen">{lastSeenRelative ? `${lastSeenRelative} · ${lastVisit}` : lastVisit}</InfoRow> : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{followers.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-user-group" eyebrow="Community" title="Recent followers">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{followers.slice(0, 6).map((follower) => (
|
||||
<a
|
||||
key={follower.id}
|
||||
href={follower.profile_url ?? `/@${follower.username}`}
|
||||
className="group flex items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-3 transition-colors hover:border-white/14 hover:bg-white/[0.06]"
|
||||
>
|
||||
<img
|
||||
src={follower.avatar_url ?? '/images/avatar_default.webp'}
|
||||
alt={follower.username}
|
||||
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10 transition-all group-hover:ring-sky-400/30"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-slate-200 group-hover:text-white">{follower.uname || follower.username}</div>
|
||||
<div className="truncate text-xs text-slate-500">@{follower.username}</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{recentAchievements.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-trophy" eyebrow="Recent wins" title="Latest achievements">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{recentAchievements.slice(0, 4).map((achievement) => (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-4 transition-colors hover:bg-white/[0.05]"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-amber-300/15 bg-amber-300/10 text-amber-100">
|
||||
<i className={`fa-solid ${achievement.icon || 'fa-trophy'}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-white">{achievement.name}</div>
|
||||
{achievement.description ? (
|
||||
<div className="mt-1 line-clamp-2 text-sm leading-relaxed text-slate-400">{achievement.description}</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/75">
|
||||
{achievement.unlocked_at ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
{formatShortDate(achievement.unlocked_at) || 'Unlocked'}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
+{formatNumber(achievement.xp_reward ?? 0)} XP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{stories.length > 0 || comments.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-wave-square" eyebrow="Fresh from this creator" title="Recent activity">
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{stories.length > 0 ? (
|
||||
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Latest story</div>
|
||||
<span className="rounded-full border border-sky-300/15 bg-sky-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100/80">
|
||||
{formatShortDate(stories[0]?.published_at) || 'Published'}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/stories/${stories[0].slug}`}
|
||||
className="mt-3 block text-lg font-semibold tracking-tight text-white transition-colors hover:text-sky-200"
|
||||
>
|
||||
{stories[0].title}
|
||||
</a>
|
||||
{stories[0].excerpt ? (
|
||||
<p className="mt-2 text-sm leading-7 text-slate-400">
|
||||
{truncateText(stories[0].excerpt, 180)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/75">
|
||||
{stories[0].reading_time ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
{stories[0].reading_time} min read
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
{formatNumber(stories[0].views ?? 0)} views
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
{formatNumber(stories[0].comments_count ?? 0)} comments
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{comments.length > 0 ? (
|
||||
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Latest guestbook comment</div>
|
||||
<span className="rounded-full border border-amber-300/15 bg-amber-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-amber-100/80">
|
||||
{formatRelativeDate(comments[0]?.created_at) || 'Recently'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-start gap-3">
|
||||
<img
|
||||
src={comments[0].author_avatar || '/images/avatar_default.webp'}
|
||||
alt={comments[0].author_name}
|
||||
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.target.src = '/images/avatar_default.webp' }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
href={comments[0].author_profile_url}
|
||||
className="text-sm font-semibold text-white transition-colors hover:text-sky-200"
|
||||
>
|
||||
{comments[0].author_name}
|
||||
</a>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-400">
|
||||
{truncateText(comments[0].body, 180)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<SectionCard icon="fa-solid fa-sparkles" eyebrow="Creator snapshot" title="Profile snapshot" className="bg-[linear-gradient(180deg,rgba(15,23,42,0.72),rgba(2,6,23,0.5))]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Creator level</div>
|
||||
<div className="mt-2 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-3xl font-semibold tracking-tight text-white">Lv {formatNumber(user?.level ?? 1)}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">{user?.rank || 'Creator'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-sky-300/15 bg-sky-400/10 px-3 py-2 text-right">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100/70">XP</div>
|
||||
<div className="mt-1 text-lg font-semibold text-sky-100">{formatNumber(user?.xp ?? 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/8">
|
||||
<div className="h-full rounded-full bg-[linear-gradient(90deg,#38bdf8,#60a5fa,#f59e0b)]" style={{ width: `${Math.max(0, Math.min(100, Number(user?.progress_percent ?? 0)))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Weekly rank</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-white">{leaderboardRank?.rank ? `#${formatNumber(leaderboardRank.rank)}` : 'Not ranked'}</div>
|
||||
{leaderboardRank?.score ? <div className="mt-1 text-sm text-slate-400">Score {formatNumber(leaderboardRank.score)}</div> : null}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Community size</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-white">{formatNumber(followerCount)}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">Followers</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon="fa-solid fa-chart-simple" eyebrow="Highlights" title="Useful stats">
|
||||
<div className="space-y-3">
|
||||
<InfoRow icon="fa-images" label="Uploads">{formatNumber(stats?.uploads_count ?? 0)}</InfoRow>
|
||||
<InfoRow icon="fa-eye" label="Artwork views received">{formatNumber(stats?.artwork_views_received_count ?? 0)}</InfoRow>
|
||||
<InfoRow icon="fa-download" label="Downloads received">{formatNumber(stats?.downloads_received_count ?? 0)}</InfoRow>
|
||||
<InfoRow icon="fa-heart" label="Favourites received">{formatNumber(stats?.favourites_received_count ?? 0)}</InfoRow>
|
||||
<InfoRow icon="fa-comment" label="Comments received">{formatNumber(stats?.comments_received_count ?? 0)}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{interestGroups.categories.length > 0 || interestGroups.contentTypes.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-layer-group" eyebrow="Creative focus" title="Favourite categories & formats">
|
||||
<div className="space-y-5">
|
||||
{interestGroups.categories.length > 0 ? (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Top categories</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
{interestGroups.categories.map((category) => (
|
||||
<span
|
||||
key={category.label}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2 text-sm text-slate-200"
|
||||
>
|
||||
<span>{category.label}</span>
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-slate-400">{formatNumber(category.count)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{interestGroups.contentTypes.length > 0 ? (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Preferred formats</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
{interestGroups.contentTypes.map((contentType) => (
|
||||
<span
|
||||
key={contentType.label}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-3.5 py-2 text-sm text-sky-100"
|
||||
>
|
||||
<span>{contentType.label}</span>
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-sky-100/70">{formatNumber(contentType.count)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{socialEntries.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-share-nodes" eyebrow="Links" title="Social links">
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{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 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2.5 text-sm text-slate-300 transition-all hover:border-sky-400/30 hover:bg-white/[0.07] hover:text-white"
|
||||
aria-label={si.label}
|
||||
>
|
||||
<i className={`${si.icon} fa-fw`} />
|
||||
<span>{si.label}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,286 @@
|
||||
import React from 'react'
|
||||
import ProfileGalleryPanel from '../ProfileGalleryPanel'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import ArtworkGallery from '../../artwork/ArtworkGallery'
|
||||
|
||||
export default function TabArtworks({ artworks, featuredArtworks, username, isActive }) {
|
||||
function slugify(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function sortByPublishedAt(items) {
|
||||
return [...items].sort((left, right) => {
|
||||
const leftTime = left?.published_at ? new Date(left.published_at).getTime() : 0
|
||||
const rightTime = right?.published_at ? new Date(right.published_at).getTime() : 0
|
||||
return rightTime - leftTime
|
||||
})
|
||||
}
|
||||
|
||||
function isWallpaperArtwork(item) {
|
||||
const contentType = String(item?.content_type_slug || item?.content_type || '').toLowerCase()
|
||||
const category = String(item?.category_slug || item?.category || '').toLowerCase()
|
||||
|
||||
return contentType.includes('wallpaper') || category.includes('wallpaper')
|
||||
}
|
||||
|
||||
function useArtworkPreview(username, sort) {
|
||||
const [items, setItems] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const data = await response.json()
|
||||
if (active) {
|
||||
setItems(Array.isArray(data?.data) ? data.data : [])
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [sort, username])
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function SectionHeader({ eyebrow, title, description, action }) {
|
||||
return (
|
||||
<div className="mb-5 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-300/80">{eyebrow}</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">{title}</h2>
|
||||
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function artworkMeta(art) {
|
||||
return [art?.content_type, art?.category].filter(Boolean).join(' • ')
|
||||
}
|
||||
|
||||
function artworkStats(art) {
|
||||
return [
|
||||
{ label: 'Views', value: formatNumber(art?.views ?? 0), icon: 'fa-regular fa-eye' },
|
||||
{ label: 'Likes', value: formatNumber(art?.likes ?? 0), icon: 'fa-regular fa-heart' },
|
||||
{ label: 'Downloads', value: formatNumber(art?.downloads ?? 0), icon: 'fa-solid fa-download' },
|
||||
]
|
||||
}
|
||||
|
||||
function FeaturedShowcase({ featuredArtworks }) {
|
||||
if (!featuredArtworks?.length) return null
|
||||
|
||||
const leadArtwork = featuredArtworks[0]
|
||||
const secondaryArtworks = featuredArtworks.slice(1, 4)
|
||||
const leadMeta = artworkMeta(leadArtwork)
|
||||
const leadStats = artworkStats(leadArtwork)
|
||||
|
||||
return (
|
||||
<section className="relative mt-8 overflow-hidden rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.14),rgba(255,255,255,0.04),rgba(249,115,22,0.12))] shadow-[0_30px_90px_rgba(2,6,23,0.3)]">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(250,204,21,0.12),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(56,189,248,0.14),transparent_34%)]" />
|
||||
<div className="relative grid gap-6 p-5 md:p-7 xl:grid-cols-[minmax(0,1.28fr)_380px]">
|
||||
<a
|
||||
href={`/art/${leadArtwork.id}/${slugify(leadArtwork.name)}`}
|
||||
className="group relative overflow-hidden rounded-[30px] border border-white/10 bg-slate-950/60 shadow-[0_24px_60px_rgba(2,6,23,0.28)]"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.24),transparent_46%),linear-gradient(to_top,rgba(2,6,23,0.9),rgba(2,6,23,0.08))]" />
|
||||
<div className="aspect-[16/9] overflow-hidden">
|
||||
<img
|
||||
src={leadArtwork.thumb}
|
||||
alt={leadArtwork.name}
|
||||
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.05]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-x-0 top-0 flex items-start justify-between p-5 md:p-7">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100 backdrop-blur-sm">
|
||||
<i className="fa-solid fa-star text-[10px]" />
|
||||
Featured spotlight
|
||||
</div>
|
||||
<div className="hidden rounded-2xl border border-white/10 bg-slate-950/35 px-4 py-3 backdrop-blur-md md:block">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Featured set</div>
|
||||
<div className="mt-1 text-2xl font-semibold tracking-tight text-white">{formatNumber(featuredArtworks.length)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 p-5 md:p-7">
|
||||
{leadMeta ? (
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/85">{leadMeta}</div>
|
||||
) : null}
|
||||
<h2 className="mt-3 max-w-2xl text-2xl font-semibold tracking-[-0.04em] text-white md:text-[2.7rem] md:leading-[1.02]">
|
||||
{leadArtwork.name}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-200/90 md:text-[15px]">
|
||||
A standout first impression for the artwork landing page, built to pull attention before visitors move into trending picks and the full archive.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
<span className="rounded-full border border-white/15 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100/90">Top pick</span>
|
||||
{leadArtwork.width && leadArtwork.height ? (
|
||||
<span className="rounded-full border border-white/15 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100/90">
|
||||
{leadArtwork.width}x{leadArtwork.height}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
{leadStats.map((stat) => (
|
||||
<div key={stat.label} className="rounded-2xl border border-white/10 bg-slate-950/35 px-4 py-3 backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300/75">
|
||||
<i className={`${stat.icon} text-[10px]`} />
|
||||
{stat.label}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold tracking-tight text-white">{stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.66),rgba(2,6,23,0.5))] p-5 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Featured</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Curated gallery highlights</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-300">
|
||||
These picks create a cleaner visual entry point and give the artwork page more personality than a simple list of thumbnails.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200/85">Editorial layout</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200/85">Hero-led showcase</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{secondaryArtworks.map((art, index) => (
|
||||
<a
|
||||
key={art.id}
|
||||
href={`/art/${art.id}/${slugify(art.name)}`}
|
||||
className="group flex gap-4 rounded-[26px] border border-white/10 bg-white/[0.045] p-4 shadow-[0_14px_36px_rgba(2,6,23,0.18)] transition-all hover:-translate-y-0.5 hover:bg-white/[0.08]"
|
||||
>
|
||||
<div className="h-24 w-28 shrink-0 overflow-hidden rounded-[18px] bg-black/30 ring-1 ring-white/10">
|
||||
<img
|
||||
src={art.thumb}
|
||||
alt={art.name}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Feature {index + 2}</div>
|
||||
{artworkMeta(art) ? <div className="truncate text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100/70">{artworkMeta(art)}</div> : null}
|
||||
</div>
|
||||
<div className="mt-2 truncate text-lg font-semibold text-white">{art.name}</div>
|
||||
{art.label ? <div className="mt-1 line-clamp-2 text-sm leading-relaxed text-slate-400">{art.label}</div> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/80">
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{formatNumber(art?.views ?? 0)} views</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{formatNumber(art?.likes ?? 0)} likes</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewRail({ eyebrow, title, description, items }) {
|
||||
if (!items.length) return null
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
||||
<ArtworkGallery
|
||||
items={items}
|
||||
compact
|
||||
className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-4"
|
||||
resolveCardProps={() => ({ showActions: false })}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FullGalleryCta({ galleryUrl, username }) {
|
||||
return (
|
||||
<section className="mt-10 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 md:p-8">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-300/80">Full archive</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">Want the complete gallery?</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-400">
|
||||
The curated sections above are a friendlier starting point. The full gallery has the infinite-scroll archive with everything published by @{username}.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={galleryUrl || '#'}
|
||||
className="inline-flex items-center gap-2 self-start rounded-2xl border border-sky-300/25 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition-colors hover:bg-sky-400/15"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-right fa-fw" />
|
||||
Browse full gallery
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TabArtworks({ artworks, featuredArtworks, username, galleryUrl }) {
|
||||
const initialItems = artworks?.data ?? artworks ?? []
|
||||
const trendingItems = useArtworkPreview(username, 'trending')
|
||||
const popularItems = useArtworkPreview(username, 'views')
|
||||
|
||||
const wallpaperItems = useMemo(() => {
|
||||
const wallpapers = popularItems.filter(isWallpaperArtwork)
|
||||
return (wallpapers.length ? wallpapers : popularItems).slice(0, 4)
|
||||
}, [popularItems])
|
||||
|
||||
const latestItems = useMemo(() => sortByPublishedAt(initialItems).slice(0, 4), [initialItems])
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-artworks"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-artworks"
|
||||
className="pt-6"
|
||||
className="mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6"
|
||||
>
|
||||
<ProfileGalleryPanel
|
||||
artworks={artworks}
|
||||
featuredArtworks={featuredArtworks}
|
||||
username={username}
|
||||
<FeaturedShowcase featuredArtworks={featuredArtworks ?? []} />
|
||||
|
||||
<PreviewRail
|
||||
eyebrow="Trending"
|
||||
title="Trending artworks right now"
|
||||
description="A quick scan of the work currently pulling the most momentum on the creator profile."
|
||||
items={trendingItems.slice(0, 4)}
|
||||
/>
|
||||
|
||||
<PreviewRail
|
||||
eyebrow="Wallpaper picks"
|
||||
title="Popular wallpapers"
|
||||
description="Surface the strongest wallpaper-friendly pieces before sending people into the full archive."
|
||||
items={wallpaperItems}
|
||||
/>
|
||||
|
||||
<PreviewRail
|
||||
eyebrow="Latest"
|
||||
title="Recent additions"
|
||||
description="Fresh uploads from the profile, presented as a preview instead of the full endless gallery."
|
||||
items={latestItems}
|
||||
/>
|
||||
|
||||
<FullGalleryCta galleryUrl={galleryUrl} username={username} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,24 +5,50 @@ import PostComposer from '../../Feed/PostComposer'
|
||||
import PostCardSkeleton from '../../Feed/PostCardSkeleton'
|
||||
import FeedSidebar from '../../Feed/FeedSidebar'
|
||||
|
||||
function formatCompactNumber(value) {
|
||||
return Number(value ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function EmptyPostsState({ isOwner, username }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<div className="flex flex-col items-center justify-center rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-20 text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.04] text-slate-500">
|
||||
<i className="fa-regular fa-newspaper text-2xl" />
|
||||
</div>
|
||||
<p className="text-slate-400 font-medium mb-1">No posts yet</p>
|
||||
<p className="mb-1 text-lg font-semibold text-white">No posts yet</p>
|
||||
{isOwner ? (
|
||||
<p className="text-slate-600 text-sm max-w-xs">
|
||||
Share updates or showcase your artworks.
|
||||
<p className="max-w-sm text-sm leading-relaxed text-slate-400">
|
||||
Share works in progress, announce releases, or add a bit of personality beyond the gallery.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-600 text-sm">@{username} has not posted anything yet.</p>
|
||||
<p className="max-w-sm text-sm leading-relaxed text-slate-400">@{username} has not published any profile posts yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorPostsState({ onRetry }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-rose-400/20 bg-rose-400/10 px-6 py-12 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-rose-300/20 bg-rose-500/10 text-rose-200">
|
||||
<i className="fa-solid fa-triangle-exclamation text-lg" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold text-white">Posts could not be loaded</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-rose-100/80">
|
||||
The profile shell loaded, but the posts feed request failed. Retry without leaving the page.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-5 inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-white/10 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-white/15"
|
||||
>
|
||||
<i className="fa-solid fa-rotate-right" />
|
||||
Retry loading posts
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TabPosts
|
||||
* Profile Posts tab — shows the user's post feed with optional composer (for owner).
|
||||
@@ -51,6 +77,7 @@ export default function TabPosts({
|
||||
recentFollowers,
|
||||
socialLinks,
|
||||
countryName,
|
||||
profileUrl,
|
||||
onTabChange,
|
||||
}) {
|
||||
const [posts, setPosts] = useState([])
|
||||
@@ -58,21 +85,22 @@ export default function TabPosts({
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
// Fetch on mount
|
||||
React.useEffect(() => {
|
||||
fetchFeed(1)
|
||||
}, [username])
|
||||
|
||||
const fetchFeed = async (p = 1) => {
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/posts/profile/${username}`, { params: { page: p } })
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
setError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
@@ -87,28 +115,94 @@ export default function TabPosts({
|
||||
setPosts((prev) => prev.filter((p) => p.id !== postId))
|
||||
}, [])
|
||||
|
||||
const summaryCards = [
|
||||
{ label: 'Followers', value: formatCompactNumber(followerCount), icon: 'fa-user-group' },
|
||||
{ label: 'Artworks', value: formatCompactNumber(stats?.uploads_count ?? 0), icon: 'fa-image' },
|
||||
{ label: 'Awards', value: formatCompactNumber(stats?.awards_received_count ?? 0), icon: 'fa-trophy' },
|
||||
{ label: 'Location', value: countryName || 'Unknown', icon: 'fa-location-dot' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 py-4 items-start">
|
||||
{/* ── Main feed column ──────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Composer (owner only) */}
|
||||
<div className="py-6">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<section className="rounded-[30px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.04),rgba(249,115,22,0.06))] p-6 shadow-[0_20px_60px_rgba(2,6,23,0.24)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Profile posts</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-white">
|
||||
Updates, thoughts, and shared work from @{username}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-300">
|
||||
This stream adds the human layer to the profile: quick notes, shared artwork posts, and announcements that do not belong inside the gallery grid.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange?.('artworks')}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/[0.06] px-4 py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.1]"
|
||||
>
|
||||
<i className="fa-solid fa-images fa-fw" />
|
||||
View artworks
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange?.('about')}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm font-medium text-slate-300 transition-colors hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-id-card fa-fw" />
|
||||
About creator
|
||||
</button>
|
||||
{profileUrl ? (
|
||||
<a
|
||||
href={profileUrl}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm font-medium text-slate-300 transition-colors hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-user fa-fw" />
|
||||
Canonical profile
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:grid-cols-2">
|
||||
{summaryCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className="rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{card.label}</div>
|
||||
<i className={`fa-solid ${card.icon} text-slate-500`} />
|
||||
</div>
|
||||
<div className="mt-3 text-xl font-semibold tracking-tight text-white">{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<div className="min-w-0 space-y-4">
|
||||
{isOwner && authUser && (
|
||||
<PostComposer user={authUser} onPosted={handlePosted} />
|
||||
)}
|
||||
|
||||
{/* Skeletons while loading */}
|
||||
{!loaded && loading && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => <PostCardSkeleton key={i} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
{loaded && error && posts.length === 0 && (
|
||||
<ErrorPostsState onRetry={() => fetchFeed(1)} />
|
||||
)}
|
||||
|
||||
{loaded && !loading && !error && posts.length === 0 && (
|
||||
<EmptyPostsState isOwner={isOwner} username={username} />
|
||||
)}
|
||||
|
||||
{/* Post list */}
|
||||
{posts.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{posts.map((post) => (
|
||||
@@ -123,13 +217,12 @@ export default function TabPosts({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
className="rounded-2xl border border-white/10 bg-white/[0.04] px-6 py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.08] disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
@@ -137,22 +230,22 @@ export default function TabPosts({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar ───────────────────────────────────────────────────────── */}
|
||||
<aside className="w-72 xl:w-80 shrink-0 hidden lg:block sticky top-20 self-start">
|
||||
<FeedSidebar
|
||||
user={user}
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
socialLinks={socialLinks}
|
||||
countryName={countryName}
|
||||
isLoggedIn={!!authUser}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
</aside>
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24">
|
||||
<FeedSidebar
|
||||
user={user}
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
socialLinks={socialLinks}
|
||||
countryName={countryName}
|
||||
isLoggedIn={!!authUser}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user