optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -3,13 +3,15 @@ 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, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
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)
@@ -22,11 +24,10 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null
const bio = profile?.bio || profile?.about || ''
const heroFacts = [
const progressPercent = Math.round(Number(user?.progress_percent ?? 0))
const heroStats = [
{ label: 'Followers', value: formatCompactNumber(count) },
{ label: 'Level', value: `Lv ${formatCompactNumber(user?.level ?? 1)}` },
{ label: 'Progress', value: `${Math.round(Number(user?.progress_percent ?? 0))}%` },
{ label: 'Member since', value: joinDate ?? 'Recently joined' },
]
return (
@@ -99,7 +100,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
</div>
<div className="min-w-0 flex-1 text-center md:text-left">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px] xl:items-start">
<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">
@@ -115,6 +116,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
<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 ? (
@@ -171,7 +173,15 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
</div>
<div className="space-y-3 xl:pt-1">
<div className="flex flex-wrap items-center justify-center gap-2 xl:justify-end">
{!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 ? (
<>
@@ -198,8 +208,10 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
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)
@@ -216,7 +228,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
}
}}
aria-label="Share profile"
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
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
@@ -225,16 +237,32 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
)}
</div>
<div className="grid grid-cols-2 gap-2 text-left">
{heroFacts.map((fact) => (
<div
key={fact.label}
className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{fact.label}</div>
<div className="mt-1.5 text-sm font-semibold tracking-tight text-white md:text-base">{fact.value}</div>
</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>
@@ -246,10 +274,8 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
</div>
<ProfileCoverEditor
open={editorOpen}
isOpen={editorOpen}
onClose={() => setEditorOpen(false)}
currentCoverUrl={coverUrl}
currentPosition={coverPosition}
coverUrl={coverUrl}
coverPosition={coverPosition}
onCoverUpdated={(nextUrl, nextPosition) => {

View File

@@ -0,0 +1,197 @@
import React from 'react'
function typeMeta(type) {
switch (type) {
case 'upload':
return { icon: 'fa-solid fa-image', label: 'Upload', tone: 'text-sky-200 bg-sky-400/12 border-sky-300/20' }
case 'comment':
return { icon: 'fa-solid fa-comment-dots', label: 'Comment', tone: 'text-amber-100 bg-amber-400/12 border-amber-300/20' }
case 'reply':
return { icon: 'fa-solid fa-reply', label: 'Reply', tone: 'text-orange-100 bg-orange-400/12 border-orange-300/20' }
case 'like':
return { icon: 'fa-solid fa-heart', label: 'Like', tone: 'text-rose-100 bg-rose-400/12 border-rose-300/20' }
case 'favourite':
return { icon: 'fa-solid fa-bookmark', label: 'Favourite', tone: 'text-pink-100 bg-pink-400/12 border-pink-300/20' }
case 'follow':
return { icon: 'fa-solid fa-user-plus', label: 'Follow', tone: 'text-emerald-100 bg-emerald-400/12 border-emerald-300/20' }
case 'achievement':
return { icon: 'fa-solid fa-trophy', label: 'Achievement', tone: 'text-yellow-100 bg-yellow-400/12 border-yellow-300/20' }
case 'forum_post':
return { icon: 'fa-solid fa-signs-post', label: 'Forum thread', tone: 'text-violet-100 bg-violet-400/12 border-violet-300/20' }
case 'forum_reply':
return { icon: 'fa-solid fa-comments', label: 'Forum reply', tone: 'text-indigo-100 bg-indigo-400/12 border-indigo-300/20' }
default:
return { icon: 'fa-solid fa-bolt', label: 'Activity', tone: 'text-slate-100 bg-white/6 border-white/10' }
}
}
function profileName(actor) {
if (!actor) return 'Creator'
return actor.username ? `@${actor.username}` : actor.name || 'Creator'
}
function headline(activity) {
switch (activity?.type) {
case 'upload':
return activity?.artwork?.title ? `Uploaded ${activity.artwork.title}` : 'Uploaded new artwork'
case 'comment':
return activity?.artwork?.title ? `Commented on ${activity.artwork.title}` : 'Posted a new comment'
case 'reply':
return activity?.artwork?.title ? `Replied on ${activity.artwork.title}` : 'Posted a reply'
case 'like':
return activity?.artwork?.title ? `Liked ${activity.artwork.title}` : 'Liked an artwork'
case 'favourite':
return activity?.artwork?.title ? `Favourited ${activity.artwork.title}` : 'Saved an artwork'
case 'follow':
return activity?.target_user ? `Started following @${activity.target_user.username || activity.target_user.name}` : 'Started following a creator'
case 'achievement':
return activity?.achievement?.name ? `Unlocked ${activity.achievement.name}` : 'Unlocked a new achievement'
case 'forum_post':
return activity?.forum?.thread?.title ? `Started forum thread ${activity.forum.thread.title}` : 'Started a new forum thread'
case 'forum_reply':
return activity?.forum?.thread?.title ? `Replied in ${activity.forum.thread.title}` : 'Posted a forum reply'
default:
return 'Shared new activity'
}
}
function body(activity) {
if (activity?.comment?.body) return activity.comment.body
if (activity?.forum?.post?.excerpt) return activity.forum.post.excerpt
if (activity?.achievement?.description) return activity.achievement.description
return ''
}
function cta(activity) {
if (activity?.comment?.url) return { href: activity.comment.url, label: 'Open comment' }
if (activity?.artwork?.url) return { href: activity.artwork.url, label: 'View artwork' }
if (activity?.forum?.post?.url) return { href: activity.forum.post.url, label: 'Open reply' }
if (activity?.forum?.thread?.url) return { href: activity.forum.thread.url, label: 'Open thread' }
if (activity?.target_user?.profile_url) return { href: activity.target_user.profile_url, label: 'View profile' }
return null
}
function AchievementIcon({ achievement }) {
const raw = String(achievement?.icon || '').trim()
const className = raw.startsWith('fa-') ? raw : `fa-solid ${raw || 'fa-trophy'}`
return (
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-yellow-300/20 bg-yellow-400/12 text-yellow-100">
<i className={className} />
</div>
)
}
export default function ActivityCard({ activity }) {
const meta = typeMeta(activity?.type)
const nextAction = cta(activity)
const copy = body(activity)
return (
<article className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.96),rgba(7,11,19,0.92))] p-5 shadow-[0_18px_45px_rgba(0,0,0,0.28)] backdrop-blur-xl">
<div className="flex flex-col gap-4 md:flex-row md:items-start">
<div className="flex items-start gap-4 md:w-[17rem] md:shrink-0">
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950/70">
{activity?.actor?.avatar_url ? (
<img src={activity.actor.avatar_url} alt={profileName(activity.actor)} className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="flex h-full w-full items-center justify-center text-slate-500">
<i className="fa-solid fa-user" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{profileName(activity.actor)}</div>
{activity?.actor?.badge?.label ? (
<div className="mt-1 inline-flex items-center rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">
{activity.actor.badge.label}
</div>
) : null}
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-slate-500">{activity?.time_ago || ''}</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${meta.tone}`}>
<i className={meta.icon} />
{meta.label}
</span>
</div>
<h3 className="mt-3 text-lg font-semibold tracking-[-0.02em] text-white">{headline(activity)}</h3>
{copy ? <p className="mt-2 max-w-3xl text-sm leading-7 text-slate-400">{copy}</p> : null}
</div>
<div className="text-xs text-slate-500 md:text-right">{activity?.created_at ? new Date(activity.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''}</div>
</div>
{activity?.artwork ? (
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
{activity.artwork.thumb ? (
<img src={activity.artwork.thumb} alt={activity.artwork.title} className="h-16 w-16 rounded-2xl object-cover ring-1 ring-white/10" loading="lazy" />
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500">
<i className="fa-solid fa-image" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{activity.artwork.title}</div>
<div className="mt-2 flex flex-wrap gap-3 text-xs text-slate-400">
<span>{Number(activity.artwork.stats?.likes || 0).toLocaleString()} likes</span>
<span>{Number(activity.artwork.stats?.views || 0).toLocaleString()} views</span>
<span>{Number(activity.artwork.stats?.comments || 0).toLocaleString()} comments</span>
</div>
</div>
</div>
) : null}
{activity?.target_user ? (
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
<div className="h-12 w-12 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/70">
{activity.target_user.avatar_url ? (
<img src={activity.target_user.avatar_url} alt={activity.target_user.username || activity.target_user.name} className="h-full w-full object-cover" loading="lazy" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Target creator</div>
<div className="mt-1 text-sm font-medium text-white">@{activity.target_user.username || activity.target_user.name}</div>
</div>
</div>
) : null}
{activity?.achievement ? (
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
<AchievementIcon achievement={activity.achievement} />
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Achievement unlocked</div>
<div className="mt-1 text-sm font-medium text-white">{activity.achievement.name}</div>
{activity.achievement.description ? <div className="mt-1 text-sm text-slate-400">{activity.achievement.description}</div> : null}
</div>
</div>
) : null}
{activity?.forum?.thread ? (
<div className="mt-4 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Forum activity</div>
<div className="mt-1 text-sm font-medium text-white">{activity.forum.thread.title}</div>
<div className="mt-2 text-xs text-slate-400">{activity.forum.thread.category_name}</div>
</div>
) : null}
{nextAction ? (
<a
href={nextAction.href}
className="mt-4 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3.5 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white"
>
{nextAction.label}
<i className="fa-solid fa-arrow-right text-[10px]" />
</a>
) : null}
</div>
</div>
</article>
)
}

View File

@@ -0,0 +1,63 @@
import React from 'react'
import ActivityCard from './ActivityCard'
export default function ActivityFeed({ activities, loading, loadingMore, error, sentinelRef }) {
if (loading) {
return (
<div className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] p-6 text-sm text-slate-400 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
Loading activity...
</div>
)
}
if (error) {
return (
<div className="rounded-[28px] border border-rose-300/20 bg-rose-500/10 p-6 text-sm text-rose-100 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
{error}
</div>
)
}
if (!activities.length) {
return (
<div className="rounded-[28px] border border-dashed border-white/10 bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] px-6 py-12 text-center shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.04] text-sky-300">
<i className="fa-solid fa-wave-square text-xl" />
</div>
<h3 className="mt-5 text-lg font-semibold text-white">No activity yet</h3>
<p className="mx-auto mt-2 max-w-lg text-sm leading-7 text-slate-400">
Upload artwork, join a conversation, follow creators, or post in the forum to start building this profile timeline.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<a href="/upload" className="inline-flex items-center gap-2 rounded-full border border-sky-300/30 bg-sky-400/12 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100 transition hover:border-sky-200/50 hover:bg-sky-400/18">
<i className="fa-solid fa-upload" />
Upload artwork
</a>
<a href="/uploads/latest" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white">
<i className="fa-solid fa-comment-dots" />
Comment on artwork
</a>
<a href="/discover/trending" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white">
<i className="fa-solid fa-user-plus" />
Follow creators
</a>
</div>
</div>
)
}
return (
<div className="space-y-4">
{activities.map((activity) => (
<ActivityCard key={activity.id} activity={activity} />
))}
<div ref={sentinelRef} className="h-12" aria-hidden="true" />
{loadingMore ? (
<div className="text-center text-sm text-slate-400">Loading more activity...</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import React from 'react'
const FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'uploads', label: 'Uploads' },
{ key: 'comments', label: 'Comments' },
{ key: 'likes', label: 'Likes' },
{ key: 'forum', label: 'Forum' },
{ key: 'following', label: 'Following' },
]
export default function ActivityFilters({ activeFilter, onChange }) {
return (
<div className="flex flex-wrap gap-2">
{FILTERS.map((filter) => {
const active = activeFilter === filter.key
return (
<button
key={filter.key}
type="button"
onClick={() => onChange(filter.key)}
className={[
'inline-flex items-center rounded-full border px-3.5 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] transition-all',
active
? 'border-sky-300/35 bg-sky-400/14 text-sky-100 shadow-[0_0_0_1px_rgba(125,211,252,0.1)]'
: 'border-white/10 bg-white/[0.04] text-slate-300 hover:border-white/20 hover:bg-white/[0.08] hover:text-white',
].join(' ')}
>
{filter.label}
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,138 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ActivityFeed from './ActivityFeed'
import ActivityFilters from './ActivityFilters'
function endpointForUser(user) {
return `/api/profile/${encodeURIComponent(user.username || user.name || '')}/activity`
}
export default function ActivityTab({ user }) {
const [activeFilter, setActiveFilter] = useState('all')
const [activities, setActivities] = useState([])
const [meta, setMeta] = useState({ current_page: 1, has_more: false, total: 0 })
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState('')
const requestIdRef = useRef(0)
const sentinelRef = useRef(null)
const fetchFeed = useCallback(async ({ filter, page, append }) => {
const requestId = requestIdRef.current + 1
requestIdRef.current = requestId
if (append) {
setLoadingMore(true)
} else {
setLoading(true)
}
try {
setError('')
const params = new URLSearchParams({
filter,
page: String(page),
per_page: '20',
})
const response = await fetch(`${endpointForUser(user)}?${params.toString()}`, {
headers: {
Accept: 'application/json',
},
credentials: 'same-origin',
})
if (!response.ok) {
throw new Error('Failed to load profile activity.')
}
const payload = await response.json()
if (requestId !== requestIdRef.current) return
setActivities((current) => append ? [...current, ...(payload.data || [])] : (payload.data || []))
setMeta(payload.meta || { current_page: page, has_more: false, total: 0 })
} catch {
if (requestId === requestIdRef.current) {
setError('Could not load this activity timeline right now.')
}
} finally {
if (requestId === requestIdRef.current) {
setLoading(false)
setLoadingMore(false)
}
}
}, [user])
useEffect(() => {
fetchFeed({ filter: activeFilter, page: 1, append: false })
}, [activeFilter, fetchFeed])
const hasMore = Boolean(meta?.has_more)
const nextPage = Number(meta?.current_page || 1) + 1
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel || loading || loadingMore || !hasMore || !('IntersectionObserver' in window)) {
return undefined
}
const observer = new IntersectionObserver((entries) => {
const [entry] = entries
if (entry?.isIntersecting) {
fetchFeed({ filter: activeFilter, page: nextPage, append: true })
}
}, { rootMargin: '240px 0px' })
observer.observe(sentinel)
return () => observer.disconnect()
}, [activeFilter, fetchFeed, hasMore, loading, loadingMore, nextPage])
const summary = useMemo(() => {
const total = Number(meta?.total || activities.length || 0)
return total ? `${total.toLocaleString()} recent actions` : 'No recent actions'
}, [activities.length, meta?.total])
return (
<div
id="tabpanel-activity"
role="tabpanel"
aria-labelledby="tab-activity"
className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
>
<div className="rounded-[32px] border border-white/[0.06] bg-[linear-gradient(135deg,rgba(56,189,248,0.14),rgba(10,16,26,0.94),rgba(249,115,22,0.08))] p-5 shadow-[0_22px_70px_rgba(0,0,0,0.26)] md:p-6">
<div className="flex flex-col gap-5 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/75">Activity</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">Recent actions and contributions</h2>
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-[15px]">
A living timeline of uploads, discussions, follows, achievements, and forum participation from {user.username || user.name}.
</p>
</div>
<div className="self-start rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-300">
{summary}
</div>
</div>
<div className="mt-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<ActivityFilters activeFilter={activeFilter} onChange={setActiveFilter} />
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2">
<i className="fa-solid fa-bolt text-sky-300" />
Timeline updates automatically as new actions are logged
</span>
</div>
</div>
</div>
<div className="mt-6">
<ActivityFeed
activities={activities}
loading={loading}
loadingMore={loadingMore}
error={error}
sentinelRef={sentinelRef}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,277 @@
import React from 'react'
import CollectionVisibilityBadge from './CollectionVisibilityBadge'
async function requestJson(url, { method = 'GET', body } = {}) {
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
const message = payload?.message || 'Request failed.'
throw new Error(message)
}
return payload
}
function formatUpdated(value) {
if (!value) return 'Updated recently'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Updated recently'
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(date)
}
function StatPill({ icon, label, value }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[11px] font-medium text-slate-300">
<i className={`fa-solid ${icon} text-[10px] text-slate-400`} />
<span className="text-white">{value}</span>
<span>{label}</span>
</span>
)
}
export default function CollectionCard({ collection, isOwner, onDelete, onToggleFeature, onMoveUp, onMoveDown, canMoveUp, canMoveDown, busy = false, saveContext = null, saveContextMeta = null }) {
const coverImage = collection?.cover_image
const [saved, setSaved] = React.useState(Boolean(collection?.saved))
const [saveBusy, setSaveBusy] = React.useState(false)
React.useEffect(() => {
setSaved(Boolean(collection?.saved))
setSaveBusy(false)
}, [collection?.id, collection?.saved])
function handleDelete(event) {
event.preventDefault()
event.stopPropagation()
onDelete?.(collection)
}
function stop(event) {
event.stopPropagation()
}
function handleToggleFeature(event) {
event.preventDefault()
event.stopPropagation()
onToggleFeature?.(collection)
}
async function handleSaveToggle(event) {
event.preventDefault()
event.stopPropagation()
if (saveBusy) return
const targetUrl = saved ? collection?.unsave_url : collection?.save_url
if (!targetUrl) {
if (collection?.login_url) {
window.location.assign(collection.login_url)
}
return
}
setSaveBusy(true)
try {
const payload = await requestJson(targetUrl, {
method: saved ? 'DELETE' : 'POST',
body: saved ? undefined : {
context: saveContext,
context_meta: saveContextMeta || undefined,
},
})
setSaved(Boolean(payload?.saved))
} catch (error) {
window.console?.error?.(error)
} finally {
setSaveBusy(false)
}
}
return (
<a
href={collection?.url || '#'}
className={`group relative overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] shadow-[0_22px_60px_rgba(2,6,23,0.22)] transition-all duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:bg-white/[0.06] ${busy ? 'opacity-70' : ''} lg:max-w-[360px] lg:mx-auto`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(249,115,22,0.12),transparent_28%)] opacity-0 transition duration-300 group-hover:opacity-100" />
<div className="relative">
{coverImage ? (
<div className="aspect-[16/10] overflow-hidden bg-slate-950">
<img
src={coverImage}
alt={collection?.title || 'Collection cover'}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
loading="lazy"
/>
</div>
) : (
<div className="flex aspect-[16/10] items-center justify-center bg-[linear-gradient(135deg,rgba(8,17,31,1),rgba(15,23,42,1),rgba(8,17,31,1))] text-slate-500">
<i className="fa-solid fa-layer-group text-4xl" />
</div>
)}
<div className="p-5">
<div className="mb-3 flex flex-wrap items-center gap-2">
{collection?.is_featured ? (
<span className="inline-flex items-center rounded-full border border-amber-300/25 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">
Featured
</span>
) : null}
{collection?.mode === 'smart' ? (
<span className="inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">
Smart
</span>
) : null}
{!isOwner && collection?.program_key ? (
<span className="inline-flex items-center rounded-full border border-lime-300/25 bg-lime-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-lime-100">
Program · {collection.program_key}
</span>
) : null}
{!isOwner && collection?.partner_label ? (
<span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">
Partner · {collection.partner_label}
</span>
) : null}
{!isOwner && collection?.sponsorship_label ? (
<span className="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">
Sponsor · {collection.sponsorship_label}
</span>
) : null}
{isOwner ? <CollectionVisibilityBadge visibility={collection?.visibility} /> : null}
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-lg font-semibold tracking-[-0.03em] text-white">{collection?.title}</h3>
{collection?.subtitle ? <p className="mt-1 truncate text-sm text-slate-300">{collection.subtitle}</p> : null}
{collection?.owner?.name ? <p className="mt-1 truncate text-sm text-slate-400">Curated by {collection.owner.name}{collection?.owner?.username ? ` • @${collection.owner.username}` : ''}</p> : null}
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
{(collection?.artworks_count ?? 0).toLocaleString()} artworks
</p>
</div>
</div>
{collection?.description_excerpt ? (
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300">{collection.description_excerpt}</p>
) : collection?.smart_summary ? (
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300">{collection.smart_summary}</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
<StatPill icon="fa-heart" label="likes" value={(collection?.likes_count ?? 0).toLocaleString()} />
<StatPill icon="fa-bell" label="followers" value={(collection?.followers_count ?? 0).toLocaleString()} />
{collection?.collaborators_count > 1 ? <StatPill icon="fa-user-group" label="curators" value={(collection?.collaborators_count ?? 0).toLocaleString()} /> : null}
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-slate-400">
<span>{collection?.is_featured ? 'Featured' : 'Updated'} {formatUpdated(collection?.featured_at || collection?.updated_at)}</span>
<div className="flex items-center gap-2" onClick={stop}>
{!isOwner && (collection?.save_url || collection?.unsave_url || collection?.login_url) ? (
<button
type="button"
onClick={handleSaveToggle}
disabled={saveBusy}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] transition ${saved ? 'border-violet-300/20 bg-violet-400/10 text-violet-100 hover:bg-violet-400/15' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]'} disabled:opacity-60`}
>
<i className={`fa-solid ${saveBusy ? 'fa-circle-notch fa-spin' : (saved ? 'fa-bookmark' : 'fa-bookmark')} text-[11px]`} />
{saved ? 'Saved' : 'Save'}
</button>
) : null}
<span className="inline-flex items-center gap-1 text-slate-200">
<i className="fa-solid fa-arrow-right text-[11px]" />
Open
</span>
</div>
</div>
{isOwner ? (
<div className="mt-4 flex flex-wrap gap-2" onClick={stop}>
{collection?.visibility === 'public' ? (
<button
type="button"
onClick={handleToggleFeature}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition ${collection?.is_featured ? 'border-amber-300/25 bg-amber-300/10 text-amber-100 hover:bg-amber-300/15' : 'border-sky-300/20 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15'}`}
>
<i className={`fa-solid ${collection?.is_featured ? 'fa-star' : 'fa-sparkles'} fa-fw`} />
{collection?.is_featured ? 'Featured' : 'Feature'}
</button>
) : null}
<a
href={collection?.edit_url || collection?.manage_url || '#'}
className="inline-flex items-center gap-2 rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-100 transition hover:bg-white/[0.09]"
>
<i className="fa-solid fa-pen-to-square fa-fw" />
Edit
</a>
<a
href={collection?.manage_url || collection?.edit_url || '#'}
className="inline-flex items-center gap-2 rounded-xl border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100 transition hover:bg-sky-400/15"
>
<i className="fa-solid fa-grip fa-fw" />
Manage Artworks
</a>
{onMoveUp ? (
<button
type="button"
disabled={!canMoveUp}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onMoveUp(collection)
}}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${canMoveUp ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.09]' : 'border-white/8 bg-white/[0.03] text-slate-500'}`}
>
<i className="fa-solid fa-arrow-up fa-fw" />
Up
</button>
) : null}
{onMoveDown ? (
<button
type="button"
disabled={!canMoveDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onMoveDown(collection)
}}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${canMoveDown ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.09]' : 'border-white/8 bg-white/[0.03] text-slate-500'}`}
>
<i className="fa-solid fa-arrow-down fa-fw" />
Down
</button>
) : null}
{collection?.delete_url ? (
<button
type="button"
onClick={handleDelete}
className="inline-flex items-center gap-2 rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/15"
>
<i className="fa-solid fa-trash-can fa-fw" />
Delete
</button>
) : null}
</div>
) : null}
</div>
</div>
</a>
)
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
export default function CollectionEmptyState({ isOwner, createUrl }) {
const smartUrl = createUrl ? `${createUrl}?mode=smart` : null
return (
<div className="relative overflow-hidden rounded-[28px] 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.1))] px-6 py-14 text-center shadow-[0_26px_80px_rgba(2,6,23,0.28)]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_32%),radial-gradient(circle_at_bottom_right,rgba(249,115,22,0.12),transparent_30%)]" />
<div className="relative mx-auto max-w-xl">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.06] text-sky-200 shadow-[0_18px_40px_rgba(2,6,23,0.28)]">
<i className="fa-solid fa-layer-group text-3xl" />
</div>
<h3 className="text-2xl font-semibold tracking-[-0.03em] text-white">
{isOwner ? 'Create your first collection' : 'No public collections yet'}
</h3>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-slate-300">
{isOwner
? 'Collections turn your gallery into intentional showcases. Build them manually or let smart rules keep them fresh from your own artwork library.'
: 'This creator has not published any collections.'}
</p>
{isOwner && createUrl ? (
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<a
href={createUrl}
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"
>
<i className="fa-solid fa-plus fa-fw" />
Create Manual Collection
</a>
<a
href={smartUrl || createUrl}
className="inline-flex items-center gap-2 rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
Create Smart Collection
</a>
</div>
) : null}
{isOwner ? <p className="mx-auto mt-6 max-w-lg text-xs uppercase tracking-[0.18em] text-slate-400">Examples: Featured wallpapers, best of 2026, cyberpunk studies, blue neon universe</p> : null}
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
const STYLES = {
public: 'border-emerald-400/25 bg-emerald-400/10 text-emerald-100',
unlisted: 'border-amber-300/25 bg-amber-300/10 text-amber-100',
private: 'border-white/15 bg-white/6 text-slate-200',
}
const LABELS = {
public: 'Public',
unlisted: 'Unlisted',
private: 'Private',
}
export default function CollectionVisibilityBadge({ visibility, className = '' }) {
const value = String(visibility || 'public').toLowerCase()
const label = LABELS[value] || 'Public'
const style = STYLES[value] || STYLES.public
return (
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${style} ${className}`.trim()}>
{label}
</span>
)
}

View File

@@ -1,155 +1,6 @@
import React, { useRef, useState } from 'react'
import LevelBadge from '../../xp/LevelBadge'
import React from 'react'
import ActivityTab from '../activity/ActivityTab'
const DEFAULT_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
function CommentItem({ comment }) {
return (
<div className="flex gap-3 py-4 border-b border-white/5 last:border-0">
<a href={comment.author_profile_url} className="shrink-0 mt-0.5">
<img
src={comment.author_avatar || DEFAULT_AVATAR}
alt={comment.author_name}
className="w-9 h-9 rounded-xl object-cover ring-1 ring-white/10"
onError={(e) => { e.target.src = DEFAULT_AVATAR }}
loading="lazy"
/>
</a>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<a
href={comment.author_profile_url}
className="text-sm font-semibold text-slate-200 hover:text-white transition-colors"
>
{comment.author_name}
</a>
<LevelBadge level={comment.author_level} rank={comment.author_rank} compact />
<span className="text-slate-600 text-xs ml-auto whitespace-nowrap">
{(() => {
try {
const d = new Date(comment.created_at)
const diff = Date.now() - d.getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
if (days < 30) return `${days}d ago`
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch { return '' }
})()}
</span>
</div>
<p className="text-sm text-slate-400 leading-relaxed break-words whitespace-pre-line">
{comment.body}
</p>
{comment.author_signature && (
<p className="text-xs text-slate-600 mt-2 italic border-t border-white/5 pt-1 truncate">
{comment.author_signature}
</p>
)}
</div>
</div>
)
}
/**
* TabActivity
* Profile comments list + comment form for authenticated visitors.
* Also acts as "Activity" tab.
*/
export default function TabActivity({ profileComments, user, isOwner, isLoggedIn }) {
const uname = user.username || user.name
const formRef = useRef(null)
const [submitted, setSubmitted] = useState(false)
return (
<div
id="tabpanel-activity"
role="tabpanel"
aria-labelledby="tab-activity"
className="pt-6 max-w-2xl"
>
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
<i className="fa-solid fa-comments text-orange-400 fa-fw" />
Comments
{profileComments?.length > 0 && (
<span className="ml-1 px-1.5 py-0.5 rounded bg-white/5 text-slate-400 font-normal text-[11px]">
{profileComments.length}
</span>
)}
</h2>
{/* Comments list */}
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20 mb-5">
{!profileComments?.length ? (
<p className="text-slate-500 text-sm text-center py-8">
No comments yet. Be the first to leave one!
</p>
) : (
<div>
{profileComments.map((comment) => (
<CommentItem key={comment.id} comment={comment} />
))}
</div>
)}
</div>
{/* Comment form */}
{!isOwner && (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20">
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
<i className="fa-solid fa-pen text-sky-400 fa-fw" />
Write a Comment
</h3>
{isLoggedIn ? (
submitted ? (
<div className="flex items-center gap-2 text-green-400 text-sm p-3 rounded-xl bg-green-500/10 ring-1 ring-green-500/20">
<i className="fa-solid fa-check fa-fw" />
Your comment has been posted!
</div>
) : (
<form
ref={formRef}
method="POST"
action={`/@${uname.toLowerCase()}/comment`}
onSubmit={() => setSubmitted(false)}
>
<input type="hidden" name="_token" value={
(() => document.querySelector('meta[name="csrf-token"]')?.content ?? '')()
} />
<textarea
name="body"
rows={4}
required
minLength={2}
maxLength={2000}
placeholder={`Write a comment for ${uname}`}
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-slate-200 placeholder:text-slate-600 resize-none focus:outline-none focus:ring-2 focus:ring-sky-400/40 focus:border-sky-400/30 transition-all"
/>
<div className="mt-3 flex justify-end">
<button
type="submit"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-sm font-semibold transition-all shadow-lg shadow-sky-900/30"
>
<i className="fa-solid fa-paper-plane fa-fw" />
Post Comment
</button>
</div>
</form>
)
) : (
<p className="text-sm text-slate-400 text-center py-4">
<a href="/login" className="text-sky-400 hover:text-sky-300 hover:underline transition-colors">
Log in
</a>
{' '}to leave a comment.
</p>
)}
</div>
)}
</div>
)
export default function TabActivity({ user }) {
return <ActivityTab user={user} />
}

View File

@@ -1,49 +1,138 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import CollectionCard from '../collections/CollectionCard'
import CollectionEmptyState from '../collections/CollectionEmptyState'
/**
* TabCollections
* Collections feature placeholder.
*/
export default function TabCollections({ collections }) {
if (collections?.length > 0) {
return (
<div
id="tabpanel-collections"
role="tabpanel"
aria-labelledby="tab-collections"
className="pt-6"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{collections.map((col) => (
<div
key={col.id}
className="bg-white/4 ring-1 ring-white/10 rounded-2xl overflow-hidden group hover:ring-sky-400/30 transition-all cursor-pointer shadow-xl shadow-black/20"
>
{col.cover_image ? (
<div className="aspect-video overflow-hidden bg-black/30">
<img
src={col.cover_image}
alt={col.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</div>
) : (
<div className="aspect-video bg-white/5 flex items-center justify-center text-slate-600">
<i className="fa-solid fa-layer-group text-3xl" />
</div>
)}
<div className="p-4">
<h3 className="font-semibold text-white truncate">{col.title}</h3>
<p className="text-sm text-slate-500 mt-0.5">{col.items_count ?? 0} artworks</p>
</div>
</div>
))}
</div>
</div>
)
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
async function deleteCollection(url) {
const response = await fetch(url, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Unable to delete collection.')
}
return payload
}
async function requestJson(url, { method = 'POST', body } = {}) {
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Unable to update collection presentation.')
}
return payload
}
const FILTERS = ['all', 'featured', 'smart', 'manual']
export default function TabCollections({ collections, isOwner, createUrl, reorderUrl, featuredUrl, featureLimit = 3 }) {
const [items, setItems] = useState(Array.isArray(collections) ? collections : [])
const [busyId, setBusyId] = useState(null)
const [filter, setFilter] = useState('all')
useEffect(() => {
setItems(Array.isArray(collections) ? collections : [])
}, [collections])
async function handleDelete(collection) {
if (!collection?.delete_url) return
if (!window.confirm(`Delete "${collection.title}"? Artworks will remain untouched.`)) return
setBusyId(collection.id)
try {
await deleteCollection(collection.delete_url)
setItems((current) => current.filter((item) => item.id !== collection.id))
} catch (error) {
window.alert(error.message)
} finally {
setBusyId(null)
}
}
async function handleToggleFeature(collection) {
const url = collection?.is_featured ? collection?.unfeature_url : collection?.feature_url
const method = collection?.is_featured ? 'DELETE' : 'POST'
if (!url) return
setBusyId(collection.id)
try {
const payload = await requestJson(url, { method })
setItems((current) => current.map((item) => (
item.id === collection.id
? {
...item,
is_featured: payload?.collection?.is_featured ?? !item.is_featured,
featured_at: payload?.collection?.featured_at ?? item.featured_at,
updated_at: payload?.collection?.updated_at ?? item.updated_at,
}
: item
)))
} catch (error) {
window.alert(error.message)
} finally {
setBusyId(null)
}
}
async function handleMove(collection, direction) {
const index = items.findIndex((item) => item.id === collection.id)
const nextIndex = index + direction
if (index < 0 || nextIndex < 0 || nextIndex >= items.length || !reorderUrl) return
const next = [...items]
const temp = next[index]
next[index] = next[nextIndex]
next[nextIndex] = temp
setItems(next)
try {
const payload = await requestJson(reorderUrl, {
method: 'POST',
body: { collection_ids: next.map((item) => item.id) },
})
if (Array.isArray(payload?.collections)) {
setItems(payload.collections)
}
} catch (error) {
window.alert(error.message)
setItems(Array.isArray(collections) ? collections : [])
}
}
const featuredItems = items.filter((collection) => collection.is_featured)
const smartItems = items.filter((collection) => collection.mode === 'smart')
const filteredItems = items.filter((collection) => {
if (filter === 'featured') return collection.is_featured
if (filter === 'smart') return collection.mode === 'smart'
if (filter === 'manual') return collection.mode !== 'smart'
return true
})
return (
<div
id="tabpanel-collections"
@@ -51,15 +140,84 @@ export default function TabCollections({ collections }) {
aria-labelledby="tab-collections"
className="pt-6"
>
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl px-8 py-16 text-center shadow-xl shadow-black/20 backdrop-blur">
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mx-auto mb-5 text-slate-500">
<i className="fa-solid fa-layer-group text-3xl" />
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collections</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Curated showcases from the gallery</h2>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-300">
Collections now support featured presentation, smart rule-based curation, and richer profile storytelling.
</p>
</div>
<div className="flex flex-wrap gap-3">
{featuredUrl ? <a href={featuredUrl} className="inline-flex items-center gap-2 self-start rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-compass fa-fw" />Browse Featured</a> : null}
{isOwner && createUrl ? <a href={createUrl} 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 hover:bg-sky-400/15"><i className="fa-solid fa-plus fa-fw" />Create Collection</a> : null}
</div>
<h3 className="text-lg font-bold text-white mb-2">Collections Coming Soon</h3>
<p className="text-slate-500 text-sm max-w-sm mx-auto">
Group your artworks into curated collections.
</p>
</div>
<div className="mb-5 flex flex-wrap items-center gap-2">
{FILTERS.map((value) => (
<button
key={value}
type="button"
onClick={() => setFilter(value)}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition ${filter === value ? 'border-sky-300/25 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.07]'}`}
>
{value}
</button>
))}
</div>
{items.length > 0 && featuredItems.length > 0 && filter === 'all' ? (
<section className="mb-6 overflow-hidden rounded-[28px] border border-amber-300/15 bg-[linear-gradient(135deg,rgba(251,191,36,0.08),rgba(255,255,255,0.04),rgba(56,189,248,0.08))] p-5 shadow-[0_26px_70px_rgba(2,6,23,0.22)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Featured Collections</p>
<h3 className="mt-2 text-xl font-semibold text-white">Premium profile showcases</h3>
</div>
{isOwner ? <p className="text-xs uppercase tracking-[0.18em] text-slate-300">{featuredItems.length}/{featureLimit} featured</p> : null}
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{featuredItems.map((collection, index) => (
<CollectionCard
key={`featured-${collection.id}`}
collection={collection}
isOwner={isOwner}
onDelete={handleDelete}
onToggleFeature={handleToggleFeature}
onMoveUp={isOwner ? () => handleMove(collection, -1) : null}
onMoveDown={isOwner ? () => handleMove(collection, 1) : null}
canMoveUp={index > 0}
canMoveDown={index < featuredItems.length - 1}
busy={busyId === collection.id}
/>
))}
</div>
</section>
) : null}
{isOwner && items.length > 0 && featuredItems.length === 0 ? <div className="mb-5 rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">Feature your best collections to pin them at the top of your profile.</div> : null}
{isOwner && items.length > 0 && smartItems.length === 0 ? <div className="mb-5 rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">Create a smart collection from your tags or categories to keep a showcase updated automatically.</div> : null}
{items.length === 0 ? (
<CollectionEmptyState isOwner={isOwner} createUrl={createUrl} />
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{filteredItems.map((collection, index) => (
<CollectionCard
key={collection.id}
collection={collection}
isOwner={isOwner}
onDelete={handleDelete}
onToggleFeature={handleToggleFeature}
onMoveUp={isOwner ? () => handleMove(collection, -1) : null}
onMoveDown={isOwner ? () => handleMove(collection, 1) : null}
canMoveUp={index > 0}
canMoveDown={index < filteredItems.length - 1}
busy={busyId === collection.id}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -75,6 +75,7 @@ export default function TabPosts({
stats,
followerCount,
recentFollowers,
suggestedUsers,
socialLinks,
countryName,
profileUrl,
@@ -117,7 +118,7 @@ export default function TabPosts({
const summaryCards = [
{ label: 'Followers', value: formatCompactNumber(followerCount), icon: 'fa-user-group' },
{ label: 'Artworks', value: formatCompactNumber(stats?.uploads_count ?? 0), icon: 'fa-image' },
{ label: 'Uploads', 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' },
]
@@ -239,6 +240,7 @@ export default function TabPosts({
stats={stats}
followerCount={followerCount}
recentFollowers={recentFollowers}
suggestedUsers={suggestedUsers}
socialLinks={socialLinks}
countryName={countryName}
isLoggedIn={!!authUser}

View File

@@ -18,7 +18,7 @@ function KpiCard({ icon, label, value, color = 'text-sky-400' }) {
* TabStats
* KPI overview cards. Charts can be added here once chart infrastructure exists.
*/
export default function TabStats({ stats, followerCount }) {
export default function TabStats({ stats, followerCount, followAnalytics }) {
const kpis = [
{ icon: 'fa-eye', label: 'Profile Views', value: stats?.profile_views_count, color: 'text-sky-400' },
{ icon: 'fa-images', label: 'Uploads', value: stats?.uploads_count, color: 'text-violet-400' },
@@ -29,6 +29,12 @@ export default function TabStats({ stats, followerCount }) {
{ icon: 'fa-trophy', label: 'Awards Received', value: stats?.awards_received_count, color: 'text-yellow-400' },
{ icon: 'fa-comment', label: 'Comments Received', value: stats?.comments_received_count, color: 'text-orange-400' },
]
const trendCards = [
{ icon: 'fa-arrow-trend-up', label: 'Followers Today', value: followAnalytics?.daily?.gained ?? 0, color: 'text-emerald-400' },
{ icon: 'fa-user-minus', label: 'Unfollows Today', value: followAnalytics?.daily?.lost ?? 0, color: 'text-rose-400' },
{ icon: 'fa-chart-line', label: 'Weekly Net', value: followAnalytics?.weekly?.net ?? 0, color: 'text-sky-400' },
{ icon: 'fa-percent', label: 'Weekly Growth %', value: followAnalytics?.weekly?.growth_rate ?? 0, color: 'text-amber-400' },
]
const hasStats = stats !== null && stats !== undefined
@@ -56,6 +62,15 @@ export default function TabStats({ stats, followerCount }) {
<KpiCard key={kpi.label} {...kpi} />
))}
</div>
<h3 className="mt-8 mb-4 text-xs font-semibold uppercase tracking-widest text-slate-500 flex items-center gap-2">
<i className="fa-solid fa-user-group text-emerald-400 fa-fw" />
Follow Growth
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{trendCards.map((card) => (
<KpiCard key={card.label} {...card} />
))}
</div>
<p className="text-xs text-slate-600 mt-6 text-center">
More detailed analytics (charts, trends) coming soon.
</p>