Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,198 @@
import React, { useMemo, useRef, useState } from 'react';
import axios from 'axios';
const MAX_BYTES = 2 * 1024 * 1024;
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
function readImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read avatar file.'));
reader.onload = () => {
const image = new Image();
image.onerror = () => reject(new Error('Invalid image data.'));
image.onload = () => resolve(image);
image.src = String(reader.result || '');
};
reader.readAsDataURL(file);
});
}
function canvasToBlob(canvas, mimeType, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to prepare avatar preview.'));
return;
}
resolve(blob);
}, mimeType, quality);
});
}
async function cropToSquareWebp(file) {
const image = await readImage(file);
const side = Math.min(image.width, image.height);
const sourceX = Math.floor((image.width - side) / 2);
const sourceY = Math.floor((image.height - side) / 2);
const outputSize = Math.min(1024, side);
const canvas = document.createElement('canvas');
canvas.width = outputSize;
canvas.height = outputSize;
const context = canvas.getContext('2d', { alpha: false });
if (!context) {
throw new Error('Browser canvas is unavailable.');
}
context.fillStyle = '#ffffff';
context.fillRect(0, 0, outputSize, outputSize);
context.drawImage(image, sourceX, sourceY, side, side, 0, 0, outputSize, outputSize);
const blob = await canvasToBlob(canvas, 'image/webp', 0.9);
return new File([blob], 'avatar.webp', { type: 'image/webp' });
}
export default function AvatarUploader({ uploadUrl, initialSrc, csrfToken }) {
const inputRef = useRef(null);
const [error, setError] = useState('');
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [avatarSrc, setAvatarSrc] = useState(initialSrc || '');
const helperText = useMemo(() => {
if (isUploading) {
return `Uploading ${progress}%...`;
}
return 'JPG, PNG, or WebP up to 2MB. Image is center-cropped to square.';
}, [isUploading, progress]);
const validateClientFile = (file) => {
if (!file) {
throw new Error('No file selected.');
}
if (!ALLOWED_TYPES.has(file.type)) {
throw new Error('Only JPG, PNG, and WebP are allowed.');
}
if (file.size > MAX_BYTES) {
throw new Error('Avatar file must be 2MB or smaller.');
}
};
const upload = async (file) => {
validateClientFile(file);
setError('');
setProgress(0);
setIsUploading(true);
try {
const squaredFile = await cropToSquareWebp(file);
const previewUrl = URL.createObjectURL(squaredFile);
setAvatarSrc(previewUrl);
const formData = new FormData();
formData.append('avatar', squaredFile);
const response = await axios.post(uploadUrl, formData, {
headers: {
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (event) => {
if (!event.total) {
return;
}
const next = Math.round((event.loaded * 100) / event.total);
setProgress(next);
},
});
const data = response?.data || {};
if (typeof data.url === 'string' && data.url.length > 0) {
setAvatarSrc(data.url);
}
} catch (uploadError) {
const message = uploadError?.response?.data?.message || uploadError?.message || 'Avatar upload failed.';
setError(message);
} finally {
setIsUploading(false);
}
};
const onDrop = async (event) => {
event.preventDefault();
setIsDragging(false);
const file = event.dataTransfer?.files?.[0];
if (file) {
await upload(file);
}
};
const onPick = async (event) => {
const file = event.target.files?.[0];
if (file) {
await upload(file);
}
if (event.target) {
event.target.value = '';
}
};
return (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-900">Avatar</p>
<div className="flex items-center gap-4">
<img
src={avatarSrc || '/img/default-avatar.webp'}
alt="Current avatar preview"
width="96"
height="96"
className="h-24 w-24 rounded-full border border-gray-300 object-cover"
loading="lazy"
decoding="async"
/>
<div
role="button"
tabIndex={0}
onDragOver={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
inputRef.current?.click();
}
}}
className={`w-full rounded-lg border-2 border-dashed p-4 text-sm transition ${isDragging ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 bg-white'}`}
aria-label="Upload avatar"
>
<p className="text-gray-700">Drag & drop avatar here, or click to choose a file.</p>
<p className="mt-1 text-xs text-gray-500">{helperText}</p>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={onPick}
/>
</div>
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,232 @@
import React, { useMemo, useRef, useState } from 'react'
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
export default function ProfileCoverEditor({
isOpen,
onClose,
coverUrl,
coverPosition,
onCoverUpdated,
onCoverRemoved,
}) {
const previewRef = useRef(null)
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [removing, setRemoving] = useState(false)
const [position, setPosition] = useState(coverPosition ?? 50)
const csrfToken = useMemo(
() => document.querySelector('meta[name="csrf-token"]')?.content ?? '',
[]
)
if (!isOpen) {
return null
}
const updatePositionFromPointer = (clientY) => {
const el = previewRef.current
if (!el) return
const rect = el.getBoundingClientRect()
if (rect.height <= 0) return
const normalized = ((clientY - rect.top) / rect.height) * 100
setPosition(Math.round(clamp(normalized, 0, 100)))
}
const handlePointerDown = (event) => {
updatePositionFromPointer(event.clientY)
const onMove = (moveEvent) => updatePositionFromPointer(moveEvent.clientY)
const onUp = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
const handleUpload = async (event) => {
const file = event.target.files?.[0]
if (!file) return
setUploading(true)
try {
const body = new FormData()
body.append('cover', file)
const response = await fetch('/api/profile/cover/upload', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
body,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Cover upload failed.')
}
const nextPosition = Number.isFinite(payload.cover_position) ? payload.cover_position : 50
setPosition(nextPosition)
onCoverUpdated(payload.cover_url, nextPosition)
} catch (error) {
window.alert(error?.message || 'Cover upload failed.')
} finally {
setUploading(false)
event.target.value = ''
}
}
const handleSavePosition = async () => {
if (!coverUrl) return
setSaving(true)
try {
const response = await fetch('/api/profile/cover/position', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
body: JSON.stringify({ position }),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Could not save position.')
}
onCoverUpdated(coverUrl, payload.cover_position ?? position)
onClose()
} catch (error) {
window.alert(error?.message || 'Could not save position.')
} finally {
setSaving(false)
}
}
const handleRemove = async () => {
if (!coverUrl) return
setRemoving(true)
try {
const response = await fetch('/api/profile/cover', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Could not remove cover.')
}
setPosition(payload.cover_position ?? 50)
onCoverRemoved()
onClose()
} catch (error) {
window.alert(error?.message || 'Could not remove cover.')
} finally {
setRemoving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-3xl rounded-2xl border border-white/10 bg-[#0d1524] shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<h3 className="text-lg font-semibold text-white">Edit Cover</h3>
<button
type="button"
onClick={onClose}
className="rounded-lg p-2 text-slate-400 hover:bg-white/10 hover:text-white"
aria-label="Close cover editor"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
<div className="space-y-4 p-5">
<div className="rounded-xl border border-dashed border-slate-600/70 bg-slate-900/50 p-3">
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">
<i className="fa-solid fa-upload" />
{uploading ? 'Uploading...' : 'Upload Cover'}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleUpload}
disabled={uploading}
/>
</label>
<p className="mt-2 text-xs text-slate-400">Allowed: JPG, PNG, WEBP. Max 5MB. Recommended: 1920x480.</p>
</div>
<div>
<p className="mb-2 text-sm text-slate-300">Drag vertically to reposition the cover.</p>
<div
ref={previewRef}
onPointerDown={handlePointerDown}
className="relative h-44 w-full cursor-ns-resize overflow-hidden rounded-xl border border-white/10 bg-[#101a2a]"
style={{
background: coverUrl
? `url('${coverUrl}') center ${position}% / cover no-repeat`
: 'linear-gradient(135deg, #0f1724 0%, #151e2e 50%, #090f1a 100%)',
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-[#0f1724]/70 to-[#0f1724]/30" />
<div
className="pointer-events-none absolute left-0 right-0 border-t border-dashed border-sky-400/80"
style={{ top: `${position}%` }}
/>
</div>
<div className="mt-2 flex items-center justify-between text-xs text-slate-400">
<span>Position</span>
<span>{position}%</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={handleRemove}
disabled={removing || !coverUrl}
className="inline-flex items-center gap-2 rounded-lg border border-red-400/30 px-4 py-2 text-sm font-medium text-red-300 hover:bg-red-500/10 disabled:opacity-50"
>
<i className={`fa-solid ${removing ? 'fa-circle-notch fa-spin' : 'fa-trash'}`} />
Remove Cover
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-white/15 px-4 py-2 text-sm text-slate-300 hover:bg-white/10"
>
Cancel
</button>
<button
type="button"
onClick={handleSavePosition}
disabled={saving || !coverUrl}
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500 disabled:opacity-50"
>
<i className={`fa-solid ${saving ? 'fa-circle-notch fa-spin' : 'fa-floppy-disk'}`} />
Save Position
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import React, { useState } from 'react'
import MasonryGallery from '../gallery/MasonryGallery'
const SORT_OPTIONS = [
{ value: 'latest', label: 'Latest' },
{ value: 'trending', label: 'Trending' },
{ value: 'rising', label: 'Rising' },
{ value: 'views', label: 'Most Viewed' },
{ value: 'favs', label: 'Most Favourited' },
]
function GalleryToolbar({ sort, onSort }) {
return (
<div className="mb-5 flex flex-wrap items-center gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">Sort</span>
<div className="flex flex-wrap gap-1 rounded-2xl border border-white/10 bg-white/[0.03] p-1">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onSort(opt.value)}
className={`rounded-xl px-3.5 py-2 text-xs font-medium transition-all ${
sort === opt.value
? 'bg-sky-500/20 text-sky-300 ring-1 ring-sky-400/40'
: 'text-slate-400 hover:bg-white/5 hover:text-white'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
)
}
export default function ProfileGalleryPanel({ artworks, username }) {
const [sort, setSort] = useState('latest')
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
const handleSort = async (newSort) => {
setSort(newSort)
setItems([])
try {
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/artworks?sort=${newSort}`, {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
return
}
const data = await response.json()
setItems(data.data ?? data)
setNextCursor(data.next_cursor ?? null)
} catch (_) {}
}
return (
<>
<div className="mx-auto w-full max-w-6xl px-4 md:px-6">
<GalleryToolbar sort={sort} onSort={handleSort} />
</div>
<div className="w-full px-4 md:px-6 xl:px-8">
<MasonryGallery
key={`profile-${username}-${sort}`}
artworks={items}
galleryType="profile"
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
initialNextCursor={nextCursor}
limit={24}
/>
</div>
</>
)
}

View File

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

View File

@@ -0,0 +1,77 @@
import React, { useEffect, useRef } from 'react'
export const TABS = [
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
{ id: 'stories', label: 'Stories', icon: 'fa-feather-pointed' },
{ id: 'achievements', label: 'Achievements', icon: 'fa-trophy' },
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
{ id: 'about', label: 'About', icon: 'fa-id-card' },
{ id: 'stats', label: 'Stats', icon: 'fa-chart-bar' },
{ id: 'favourites', label: 'Favourites', icon: 'fa-heart' },
{ id: 'activity', label: 'Activity', icon: 'fa-bolt' },
]
/**
* ProfileTabs
* Sticky tab navigation that:
* - Scrolls horizontally on mobile
* - Shows active underline / glow
* - Updates URL query param on tab change
*/
export default function ProfileTabs({ activeTab, onTabChange }) {
const navRef = useRef(null)
const activeRef = useRef(null)
useEffect(() => {
if (activeRef.current && navRef.current) {
activeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
}
}, [activeTab])
return (
<div className="sticky top-0 z-30 border-b border-white/10 bg-[#08111f]/80 backdrop-blur-2xl">
<nav
ref={navRef}
className="profile-tabs-sticky overflow-x-auto scrollbar-hide"
aria-label="Profile sections"
role="tablist"
>
<div className="mx-auto flex w-max min-w-full gap-2 px-3 py-3 justify-center xl:items-stretch">
{TABS.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
ref={isActive ? activeRef : null}
onClick={() => onTabChange(tab.id)}
role="tab"
aria-selected={isActive}
aria-controls={`tabpanel-${tab.id}`}
className={`
group relative flex items-center gap-2.5 rounded-2xl border px-3.5 py-3 text-sm font-medium whitespace-nowrap
outline-none transition-all duration-150 focus-visible:ring-2 focus-visible:ring-sky-400/70
${isActive
? 'border-sky-300/25 bg-gradient-to-br from-sky-400/18 via-white/[0.06] to-cyan-400/10 text-white shadow-[0_16px_32px_rgba(14,165,233,0.12)]'
: 'border-white/8 bg-white/[0.03] text-slate-400 hover:border-white/15 hover:bg-white/[0.05] hover:text-slate-100'
}
`}
>
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl border text-sm ${isActive ? 'border-sky-300/20 bg-sky-400/10 text-sky-200' : 'border-white/10 bg-white/[0.04] text-slate-500 group-hover:text-slate-300'}`}>
<i className={`fa-solid ${tab.icon} fa-fw`} />
</span>
{tab.label}
{isActive && (
<span
className="absolute inset-x-4 bottom-0 h-0.5 rounded-full bg-sky-300 shadow-[0_0_10px_rgba(125,211,252,0.8)]"
aria-hidden="true"
/>
)}
</button>
)
})}
</div>
</nav>
</div>
)
}

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,291 @@
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>
)
}
function CoverMedia({ collection, isOwner }) {
const coverImage = collection?.cover_image
const coverMaturity = !isOwner && collection?.cover_image_maturity ? collection.cover_image_maturity : null
const shouldBlur = Boolean(coverMaturity?.should_blur)
const isMature = Boolean(coverMaturity?.is_mature_effective)
if (!coverImage) {
return (
<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>
)
}
return (
<div className="relative 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,filter] duration-500 group-hover:scale-[1.04] ${shouldBlur ? 'scale-[1.02] blur-xl' : ''}`}
loading="lazy"
/>
{isMature ? <div className="absolute left-3 top-3 rounded-full border border-amber-300/20 bg-black/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Mature cover</div> : null}
{shouldBlur ? <div className="absolute inset-x-3 bottom-3 rounded-full border border-amber-300/20 bg-black/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Blurred by your settings</div> : null}
</div>
)
}
export default function CollectionCard({ collection, isOwner, onDelete, onToggleFeature, onMoveUp, onMoveDown, canMoveUp, canMoveDown, busy = false, saveContext = null, saveContextMeta = null }) {
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">
<CoverMedia collection={collection} isOwner={isOwner} />
<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

@@ -0,0 +1,587 @@
import React from 'react'
const SOCIAL_ICONS = {
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter' },
deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt' },
instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram' },
behance: { icon: 'fa-brands fa-behance', label: 'Behance' },
artstation: { icon: 'fa-solid fa-palette', label: 'ArtStation' },
youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube' },
website: { icon: 'fa-solid fa-link', label: 'Website' },
}
function 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 formatContributionDate(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 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">
<i className={`fa-solid ${icon} fa-fw text-slate-500 mt-0.5 w-4 text-center`} />
<div className="flex-1 min-w-0">
<span className="text-xs text-slate-500 block mb-0.5">{label}</span>
<div className="text-sm text-slate-200">{children}</div>
</div>
</div>
)
}
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, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank, groupContributionHistory }) {
const uname = user.username || user.name
const displayName = user.name || uname
const about = profile?.about
const website = profile?.website
const joinDate = user.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
: null
const lastVisit = user.last_visit_at
? (() => {
try {
const d = new Date(user.last_visit_at)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch { return null }
})()
: null
const genderMap = { M: 'Male', F: 'Female', X: 'Non-binary / N/A' }
const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null
const 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 contributionHistory = Array.isArray(groupContributionHistory) ? groupContributionHistory : []
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="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map((card) => (
<StatCard key={card.label} {...card} />
))}
</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>
{contributionHistory.length > 0 ? (
<SectionCard icon="fa-solid fa-people-group" eyebrow="Collaborative work" title="Group contribution history">
<div className="grid gap-4">
{contributionHistory.map((entry) => (
<a
key={entry.group?.slug}
href={entry.group?.profile_url || '#'}
className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4 transition-colors hover:border-white/14 hover:bg-white/[0.06]"
>
<div className="flex items-start gap-3">
{entry.group?.avatar_url ? (
<img src={entry.group.avatar_url} alt={entry.group?.name} className="h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-400">
<i className="fa-solid fa-people-group" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate text-sm font-semibold text-white">{entry.group?.name}</div>
{entry.role ? <span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{String(entry.role).replaceAll('_', ' ')}</span> : null}
{entry.trusted_indicator ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Trusted</span> : null}
</div>
{entry.group?.headline ? <p className="mt-1 text-sm text-slate-400">{truncateText(entry.group.headline, 100)}</p> : null}
{entry.summary ? <p className="mt-3 text-sm text-slate-300">{entry.summary}</p> : null}
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
<span>{Number(entry.counts?.credited_artworks || 0).toLocaleString()} credited artworks</span>
<span>{Number(entry.counts?.releases || 0).toLocaleString()} releases</span>
<span>{Number(entry.counts?.projects || 0).toLocaleString()} projects</span>
{entry.joined_at ? <span>Joined {formatContributionDate(entry.joined_at)}</span> : null}
</div>
{Array.isArray(entry.role_labels) && entry.role_labels.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{entry.role_labels.map((label) => (
<span key={`${entry.group?.slug}-${label}`} className="rounded-full border border-sky-300/15 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">
{label}
</span>
))}
</div>
) : null}
{Array.isArray(entry.recent_release_titles) && entry.recent_release_titles.length > 0 ? (
<div className="mt-3 text-xs text-slate-400">
Recent releases: {entry.recent_release_titles.join(' • ')}
</div>
) : null}
</div>
</div>
</a>
))}
</div>
</SectionCard>
) : null}
{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>
)
}

View File

@@ -0,0 +1,35 @@
import React from 'react'
import AchievementsList from '../../achievements/AchievementsList'
export default function TabAchievements({ achievements }) {
const unlocked = Array.isArray(achievements?.unlocked) ? achievements.unlocked : []
const locked = Array.isArray(achievements?.locked) ? achievements.locked : []
return (
<div
id="tabpanel-achievements"
role="tabpanel"
aria-labelledby="tab-achievements"
className="pt-6"
>
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Achievements</h2>
<p className="mt-2 text-sm text-slate-300">
Milestones, creator wins, and level-based unlocks collected on Skinbase.
</p>
</div>
<div className="flex gap-3 text-xs text-slate-400">
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5">
{achievements?.counts?.unlocked || 0} unlocked
</span>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5">
{achievements?.counts?.total || 0} total
</span>
</div>
</div>
<AchievementsList unlocked={unlocked} locked={locked} />
</div>
)
}

View File

@@ -0,0 +1,6 @@
import React from 'react'
import ActivityTab from '../activity/ActivityTab'
export default function TabActivity({ user }) {
return <ActivityTab user={user} />
}

View File

@@ -0,0 +1,290 @@
import React, { useEffect, useMemo, useState } from 'react'
import ArtworkGallery from '../../artwork/ArtworkGallery'
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)
const leadShouldBlur = Boolean(leadArtwork?.maturity?.should_blur)
const leadIsMature = Boolean(leadArtwork?.maturity?.is_mature_effective)
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,filter] duration-700 group-hover:scale-[1.05] ${leadShouldBlur ? 'scale-[1.02] blur-xl' : ''}`}
loading="lazy"
/>
</div>
{leadIsMature ? <div className="absolute left-5 top-20 rounded-full border border-amber-300/20 bg-black/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Mature content</div> : null}
{leadShouldBlur ? <div className="absolute inset-x-5 bottom-28 rounded-full border border-amber-300/20 bg-black/60 px-3 py-1 text-center text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Blurred by your settings</div> : null}
<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,filter] duration-300 group-hover:scale-[1.04] ${art?.maturity?.should_blur ? 'scale-[1.02] blur-xl' : ''}`}
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="mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6"
>
<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>
)
}

View File

@@ -0,0 +1,223 @@
import React, { useEffect, useState } from 'react'
import CollectionCard from '../collections/CollectionCard'
import CollectionEmptyState from '../collections/CollectionEmptyState'
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"
role="tabpanel"
aria-labelledby="tab-collections"
className="pt-6"
>
<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>
</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

@@ -0,0 +1,126 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import ArtworkGallery from '../../artwork/ArtworkGallery'
function FavSkeleton() {
return (
<div className="rounded-2xl overflow-hidden bg-white/5 animate-pulse">
<div className="aspect-square bg-white/8" />
</div>
)
}
/**
* TabFavourites
* Shows artworks the user has favourited.
*/
export default function TabFavourites({ favourites, isOwner, username }) {
const initialItems = Array.isArray(favourites)
? favourites
: (favourites?.data ?? [])
const [items, setItems] = useState(initialItems)
const [nextCursor, setNextCursor] = useState(favourites?.next_cursor ?? null)
const [loadingMore, setLoadingMore] = useState(false)
const loadMoreRef = useRef(null)
useEffect(() => {
setItems(initialItems)
setNextCursor(favourites?.next_cursor ?? null)
}, [favourites, initialItems])
const loadMore = useCallback(async () => {
if (!nextCursor || loadingMore) return
setLoadingMore(true)
try {
const res = await fetch(
`/api/profile/${encodeURIComponent(username)}/favourites?cursor=${encodeURIComponent(nextCursor)}`,
{ headers: { Accept: 'application/json' } }
)
if (res.ok) {
const data = await res.json()
setItems((prev) => [...prev, ...(data.data ?? data)])
setNextCursor(data.next_cursor ?? null)
}
} catch (_) {}
setLoadingMore(false)
}, [loadingMore, nextCursor, username])
useEffect(() => {
const node = loadMoreRef.current
if (!node || !nextCursor) {
return undefined
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadMore()
}
},
{
rootMargin: '320px 0px',
}
)
observer.observe(node)
return () => observer.disconnect()
}, [loadMore, nextCursor])
return (
<div
id="tabpanel-favourites"
role="tabpanel"
aria-labelledby="tab-favourites"
className="pt-6"
>
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
<i className="fa-solid fa-heart text-pink-400 fa-fw" />
{isOwner ? 'Your Favourites' : 'Favourites'}
</h2>
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500">
<i className="fa-solid fa-heart text-3xl" />
</div>
<p className="text-slate-400 font-medium">No favourites yet</p>
<p className="text-slate-600 text-sm mt-1">Artworks added to favourites will appear here.</p>
</div>
) : (
<>
<ArtworkGallery
items={items}
layout="grid"
className="grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4"
resolveCardProps={(_, index) => ({
loading: index < 8 ? 'eager' : 'lazy',
})}
>
{loadingMore && Array.from({ length: 4 }).map((_, i) => <FavSkeleton key={`sk-${i}`} />)}
</ArtworkGallery>
{nextCursor && (
<div ref={loadMoreRef} className="h-6 w-full" aria-hidden="true" />
)}
{nextCursor && (
<div className="mt-8 text-center">
<button
onClick={loadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm font-medium border border-white/10 transition-all"
>
{loadingMore
? <><i className="fa-solid fa-circle-notch fa-spin fa-fw" /> Loading</>
: <><i className="fa-solid fa-chevron-down fa-fw" /> Load more</>
}
</button>
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,184 @@
import React, { useState, useCallback } from 'react'
import axios from 'axios'
import PostCard from '../../Feed/PostCard'
import PostComposer from '../../Feed/PostComposer'
import PostCardSkeleton from '../../Feed/PostCardSkeleton'
import FeedSidebar from '../../Feed/FeedSidebar'
function EmptyPostsState({ isOwner, username }) {
return (
<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="mb-1 text-lg font-semibold text-white">No posts yet</p>
{isOwner ? (
<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="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).
*
* Props:
* username string
* isOwner boolean
* authUser object|null { id, username, name, avatar }
* user object full user from ProfileController
* profile object
* stats object|null
* followerCount number
* recentFollowers array
* socialLinks object
* countryName string|null
* onTabChange function(tab)
*/
export default function TabPosts({
username,
isOwner,
authUser,
user,
profile,
stats,
followerCount,
recentFollowers,
suggestedUsers,
socialLinks,
countryName,
onTabChange,
}) {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
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)
}
}
const handlePosted = useCallback((newPost) => {
setPosts((prev) => [newPost, ...prev])
}, [])
const handleDeleted = useCallback((postId) => {
setPosts((prev) => prev.filter((p) => p.id !== postId))
}, [])
return (
<div className="py-6">
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">
<div className="min-w-0 space-y-4">
{isOwner && authUser && (
<div className="sticky top-24 z-20">
<PostComposer user={authUser} onPosted={handlePosted} />
</div>
)}
{!loaded && loading && (
<div className="space-y-4">
{[1, 2, 3].map((i) => <PostCardSkeleton key={i} />)}
</div>
)}
{loaded && error && posts.length === 0 && (
<ErrorPostsState onRetry={() => fetchFeed(1)} />
)}
{loaded && !loading && !error && posts.length === 0 && (
<EmptyPostsState isOwner={isOwner} username={username} />
)}
{posts.length > 0 && (
<div className="space-y-4">
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
isLoggedIn={!!authUser}
viewerUsername={authUser?.username ?? null}
onDelete={handleDeleted}
/>
))}
</div>
)}
{loaded && hasMore && (
<div className="flex justify-center py-4">
<button
onClick={() => fetchFeed(page + 1)}
disabled={loading}
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</>
) : 'Load more posts'}
</button>
</div>
)}
</div>
<aside className="hidden xl:block xl:sticky xl:top-24">
<FeedSidebar
user={user}
profile={profile}
stats={stats}
followerCount={followerCount}
recentFollowers={recentFollowers}
suggestedUsers={suggestedUsers}
socialLinks={socialLinks}
countryName={countryName}
isLoggedIn={!!authUser}
onTabChange={onTabChange}
/>
</aside>
</div>
</div>
)
}

View File

@@ -0,0 +1,107 @@
import React from 'react'
function KpiCard({ icon, label, value, color = 'text-sky-400' }) {
return (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20 backdrop-blur flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl bg-white/5 flex items-center justify-center shrink-0 ${color}`}>
<i className={`fa-solid ${icon} text-xl`} />
</div>
<div>
<p className="text-2xl font-bold text-white tabular-nums">{Number(value ?? 0).toLocaleString()}</p>
<p className="text-xs text-slate-500 mt-0.5 uppercase tracking-wider">{label}</p>
</div>
</div>
)
}
/**
* TabStats
* KPI overview cards. Charts can be added here once chart infrastructure exists.
*/
export default function TabStats({ stats, followerCount, followAnalytics }) {
const medalTotals = stats?.medal_totals ?? null
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' },
{ icon: 'fa-download', label: 'Downloads', value: stats?.downloads_received_count, color: 'text-green-400' },
{ icon: 'fa-eye', label: 'Artwork Views', value: stats?.artwork_views_received_count, color: 'text-blue-400' },
{ icon: 'fa-heart', label: 'Favourites Received', value: stats?.favourites_received_count, color: 'text-pink-400' },
{ icon: 'fa-users', label: 'Followers', value: followerCount, color: 'text-amber-400' },
{ 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
return (
<div
id="tabpanel-stats"
role="tabpanel"
aria-labelledby="tab-stats"
className="pt-6"
>
{!hasStats ? (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-10 text-center shadow-xl shadow-black/20">
<i className="fa-solid fa-chart-bar text-3xl text-slate-600 mb-3 block" />
<p className="text-slate-400 font-medium">No stats available yet</p>
<p className="text-slate-600 text-sm mt-1">Stats will appear once there is activity on this profile.</p>
</div>
) : (
<>
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
<i className="fa-solid fa-chart-bar text-green-400 fa-fw" />
Lifetime Statistics
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{kpis.map((kpi) => (
<KpiCard key={kpi.label} {...kpi} />
))}
</div>
<div className="mt-8 rounded-2xl border border-white/10 bg-white/4 p-5 shadow-xl shadow-black/20">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Medal Breakdown</h3>
<p className="mt-2 text-sm text-slate-400">Real medal totals collected across all public artworks.</p>
</div>
<div className="text-right">
<div className="text-xs uppercase tracking-widest text-slate-500">Weighted Score</div>
<div className="mt-1 text-2xl font-bold text-white tabular-nums">{Number(medalTotals?.score_total ?? 0).toLocaleString()}</div>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
{[
{ label: 'Gold', value: medalTotals?.gold ?? 0, color: 'text-amber-300' },
{ label: 'Silver', value: medalTotals?.silver ?? 0, color: 'text-slate-300' },
{ label: 'Bronze', value: medalTotals?.bronze ?? 0, color: 'text-orange-300' },
{ label: 'Total Medals', value: medalTotals?.count ?? 0, color: 'text-cyan-300' },
].map((item) => (
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/15 px-4 py-4">
<div className="text-[11px] uppercase tracking-widest text-slate-500">{item.label}</div>
<div className={`mt-2 text-2xl font-semibold tabular-nums ${item.color}`}>{Number(item.value).toLocaleString()}</div>
</div>
))}
</div>
</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>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,54 @@
import React from 'react'
import LevelBadge from '../../xp/LevelBadge'
export default function TabStories({ stories, username }) {
const list = Array.isArray(stories) ? stories : []
if (!list.length) {
return (
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-6 py-12 text-center text-slate-300">
No stories published yet.
</div>
)
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{list.map((story) => (
<a
key={story.id}
href={`/stories/${story.slug}`}
className="group overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg transition duration-200 hover:scale-[1.01] hover:border-sky-500/40"
>
{story.cover_url ? (
<img src={story.cover_url} alt={story.title} className="h-44 w-full object-cover transition-transform duration-300 group-hover:scale-105" />
) : (
<div className="h-44 w-full bg-gradient-to-br from-gray-900 via-slate-900 to-sky-950" />
)}
<div className="space-y-2 p-4">
<div className="flex items-center justify-between gap-2">
<LevelBadge level={story.creator_level} rank={story.creator_rank} compact />
<span className="text-[11px] uppercase tracking-[0.16em] text-gray-500">Story</span>
</div>
<h3 className="line-clamp-2 text-base font-semibold text-white">{story.title}</h3>
<p className="line-clamp-2 text-xs text-gray-300">{story.excerpt || ''}</p>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>{story.reading_time || 1} min read</span>
<span>{story.views || 0} views</span>
<span>{story.likes_count || 0} likes</span>
</div>
</div>
</a>
))}
</div>
<a
href={`/stories/creator/${username}`}
className="inline-flex rounded-lg border border-sky-400/30 bg-sky-500/10 px-3 py-2 text-sm text-sky-300 transition hover:scale-[1.01] hover:text-sky-200"
>
View all stories
</a>
</div>
)
}