Refactor dashboard and upload flows

Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
This commit is contained in:
2026-03-21 11:02:22 +01:00
parent 29c3ff8572
commit 979e011257
55 changed files with 2576 additions and 1923 deletions

View File

@@ -606,14 +606,73 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
if (uploadsV2Enabled) {
return (
<section className="px-4 py-1">
<div className="max-w-6xl mx-auto">
<UploadWizard
initialDraftId={draftId ?? null}
chunkSize={chunkSize}
contentTypes={Array.isArray(props?.content_types) ? props.content_types : []}
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
/>
<section className="min-h-[calc(100vh-4rem)] bg-[#07111c] text-slate-100">
<div className="relative isolate overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(251,146,60,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(7,17,28,1))]" />
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
<div className="overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 shadow-[0_30px_120px_rgba(2,8,23,0.38)]">
<div className="grid gap-8 border-b border-white/8 px-5 py-6 sm:px-8 lg:grid-cols-[1.45fr_0.85fr] lg:items-start lg:gap-10 lg:py-8">
<div>
<p className="text-[11px] uppercase tracking-[0.28em] text-sky-200/80">Skinbase Upload Studio</p>
<h1 className="mt-3 max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Upload artwork with less friction and better control.
</h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 sm:text-base">
The upload flow now stays focused on three steps: add the file, finish the metadata, then publish with confidence. The interface is simpler, but the secure processing pipeline stays intact.
</p>
<div className="mt-6 grid gap-3 sm:grid-cols-3">
{[
{
title: 'Fast onboarding',
description: 'Clearer file requirements and a friendlier first step.',
},
{
title: 'Safer publishing',
description: 'Processing state, rights, and readiness stay visible the whole time.',
},
{
title: 'Cleaner review',
description: 'Metadata and publish options are easier to scan before going live.',
},
].map((item) => (
<div key={item.title} className="rounded-2xl border border-white/8 bg-white/[0.04] p-4">
<p className="text-sm font-semibold text-white">{item.title}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">{item.description}</p>
</div>
))}
</div>
</div>
<aside className="rounded-[28px] border border-white/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_18px_70px_rgba(0,0,0,0.16)]">
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Before you start</p>
<div className="mt-4 space-y-4">
{[
'Choose the final file you actually want published. Replacing after upload requires a reset.',
'ZIP, RAR, and 7Z packs still need at least one screenshot for preview generation.',
'You will confirm rights and visibility before the final publish step.',
].map((item, index) => (
<div key={item} className="flex items-start gap-3">
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-sky-300/20 bg-sky-400/10 text-xs font-semibold text-sky-100">
{index + 1}
</span>
<p className="text-sm leading-6 text-slate-300">{item}</p>
</div>
))}
</div>
</aside>
</div>
<div className="px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
<UploadWizard
initialDraftId={draftId ?? null}
chunkSize={chunkSize}
contentTypes={Array.isArray(props?.content_types) ? props.content_types : []}
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
/>
</div>
</div>
</div>
</div>
</section>
)

View File

@@ -29,51 +29,45 @@ export default function CategorySelector({
allRoots = [],
onRootChangeAll,
}) {
const rootOptions = hasContentType ? categories : allRoots
const selectedRoot = categories.find((c) => String(c.id) === String(rootCategoryId || '')) ?? null
const hasSubcategories = Boolean(
selectedRoot && Array.isArray(selectedRoot.children) && selectedRoot.children.length > 0
)
if (!hasContentType) {
return (
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
Select a content type to load categories.
</div>
)
}
if (categories.length === 0) {
return (
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
No categories available for this content type.
</div>
)
}
return (
<div className="space-y-3">
{/* Root categories */}
<div className="flex flex-wrap gap-2" role="group" aria-label="Category">
{categories.map((root) => {
const active = String(root.id) === String(rootCategoryId || '')
return (
<button
key={root.id}
type="button"
aria-pressed={active}
onClick={() => onRootChange?.(String(root.id))}
className={[
'rounded-full border px-3.5 py-1.5 text-sm transition-all',
active
? 'border-violet-500/70 bg-violet-600/25 text-white shadow-sm'
: 'border-white/10 bg-white/5 text-white/65 hover:border-violet-300/40 hover:bg-violet-400/10 hover:text-white/90',
].join(' ')}
>
{root.name}
</button>
)
})}
</div>
{!hasContentType ? (
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
Select a content type to load categories.
</div>
) : categories.length === 0 ? (
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
No categories available for this content type.
</div>
) : (
<div className="flex flex-wrap gap-2" role="group" aria-label="Category">
{categories.map((root) => {
const active = String(root.id) === String(rootCategoryId || '')
return (
<button
key={root.id}
type="button"
aria-pressed={active}
onClick={() => onRootChange?.(String(root.id))}
className={[
'rounded-full border px-3.5 py-1.5 text-sm transition-all',
active
? 'border-violet-500/70 bg-violet-600/25 text-white shadow-sm'
: 'border-white/10 bg-white/5 text-white/65 hover:border-violet-300/40 hover:bg-violet-400/10 hover:text-white/90',
].join(' ')}
>
{root.name}
</button>
)
})}
</div>
)}
{/* Subcategories (shown when root has children) */}
{hasSubcategories && (
@@ -122,7 +116,7 @@ export default function CategorySelector({
}}
>
<option value="">Select root category</option>
{allRoots.map((root) => (
{rootOptions.map((root) => (
<option key={root.id} value={String(root.id)}>{root.name}</option>
))}
</select>

View File

@@ -49,6 +49,7 @@ export default function PublishPanel({
scheduledAt = null,
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
visibility = 'public', // 'public' | 'unlisted' | 'private'
showRightsConfirmation = true,
onPublishModeChange,
onScheduleAt,
onVisibilityChange,
@@ -93,8 +94,26 @@ export default function PublishPanel({
const rightsError = uploadReady && !hasRights ? 'Rights confirmation is required.' : null
const visibilityOptions = [
{
value: 'public',
label: 'Public',
hint: 'Visible to everyone',
},
{
value: 'unlisted',
label: 'Unlisted',
hint: 'Available by direct link',
},
{
value: 'private',
label: 'Private',
hint: 'Keep as draft visibility',
},
]
return (
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-5 space-y-5 h-fit">
<div className="h-fit space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur">
{/* Preview + title */}
<div className="flex items-start gap-3">
{/* Thumbnail */}
@@ -139,24 +158,45 @@ export default function PublishPanel({
<div className="border-t border-white/8" />
{/* Readiness checklist */}
<ReadinessChecklist items={checklist} />
<div className="rounded-2xl border border-white/8 bg-white/[0.03] p-4">
<ReadinessChecklist items={checklist} />
</div>
{/* Visibility */}
<div>
<label className="block text-[10px] uppercase tracking-wider text-white/40 mb-1.5" htmlFor="publish-visibility">
<label className="mb-2 block text-[10px] uppercase tracking-wider text-white/40" htmlFor="publish-visibility">
Visibility
</label>
<select
id="publish-visibility"
value={visibility}
onChange={(e) => onVisibilityChange?.(e.target.value)}
disabled={!canPublish && machineState !== 'ready_to_publish'}
className="w-full appearance-none rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
>
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="private">Private (draft)</option>
</select>
<div id="publish-visibility" className="grid gap-2">
{visibilityOptions.map((option) => {
const active = visibility === option.value
return (
<button
key={option.value}
type="button"
onClick={() => onVisibilityChange?.(option.value)}
disabled={!canPublish && machineState !== 'ready_to_publish'}
className={[
'flex items-start justify-between gap-3 rounded-2xl border px-4 py-3 text-left transition disabled:opacity-50',
active
? 'border-sky-300/30 bg-sky-400/10 text-white'
: 'border-white/10 bg-white/[0.03] text-white/75 hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<div>
<div className="text-sm font-semibold">{option.label}</div>
<div className="mt-1 text-xs text-white/50">{option.hint}</div>
</div>
<span className={[
'mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full border text-[10px]',
active ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/5 text-white/35',
].join(' ')}>
{active ? '✓' : ''}
</span>
</button>
)
})}
</div>
</div>
{/* Schedule picker only shows when upload is ready */}
@@ -171,20 +211,21 @@ export default function PublishPanel({
/>
)}
{/* Rights confirmation (required before publish) */}
<div>
<Checkbox
id="publish-rights-confirm"
checked={Boolean(metadata.rightsAccepted)}
onChange={(event) => onToggleRights?.(event.target.checked)}
variant="emerald"
size={18}
label={<span className="text-xs text-white/85">I confirm I own the rights to this content.</span>}
hint={<span className="text-[11px] text-white/50">Required before publishing.</span>}
error={rightsError}
required
/>
</div>
{showRightsConfirmation && (
<div>
<Checkbox
id="publish-rights-confirm"
checked={Boolean(metadata.rightsAccepted)}
onChange={(event) => onToggleRights?.(event.target.checked)}
variant="emerald"
size={18}
label={<span className="text-xs text-white/85">I confirm I own the rights to this content.</span>}
hint={<span className="text-[11px] text-white/50">Required before publishing.</span>}
error={rightsError}
required
/>
</div>
)}
{/* Primary action button */}
<button
@@ -193,7 +234,7 @@ export default function PublishPanel({
onClick={() => onPublish?.()}
title={!canPublish ? 'Complete all requirements first' : undefined}
className={[
'w-full rounded-xl py-2.5 text-sm font-semibold transition',
'w-full rounded-2xl py-3 text-sm font-semibold transition',
canSchedulePublish && !isPublishing
? publishMode === 'schedule'
? 'bg-violet-500/80 text-white hover:bg-violet-500 shadow-[0_4px_16px_rgba(139,92,246,0.25)]'

View File

@@ -51,11 +51,11 @@ export default function StudioStatusBar({
const stateColor = STATE_COLORS[machineState] ?? 'bg-white/8 text-white/50 border-white/15'
return (
<div className="sticky top-0 z-20 -mx-4 px-4 pt-2 pb-0 sm:-mx-6 sm:px-6">
<div className="sticky top-0 z-20 -mx-4 px-4 pb-0 pt-2 sm:-mx-6 sm:px-6">
{/* Blur backdrop */}
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-md" aria-hidden="true" />
<div className="relative">
<div className="relative overflow-hidden rounded-[24px] border border-white/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))] px-3 shadow-[0_14px_44px_rgba(2,8,23,0.24)] sm:px-4">
{/* Step pills row */}
<nav aria-label="Upload steps">
<ol className="flex flex-nowrap items-center gap-2 overflow-x-auto py-3 pr-1 sm:gap-3">
@@ -69,7 +69,7 @@ export default function StudioStatusBar({
const btnClass = [
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] sm:text-xs transition',
isActive
? 'border-sky-300/70 bg-sky-500/25 text-white'
? 'border-sky-300/70 bg-sky-500/25 text-white shadow-[0_10px_30px_rgba(14,165,233,0.14)]'
: isComplete
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25 cursor-pointer'
: isLocked
@@ -127,7 +127,7 @@ export default function StudioStatusBar({
{/* Progress bar (shown during upload/processing) */}
{showProgress && (
<div className="h-0.5 w-full overflow-hidden rounded-full bg-white/8">
<div className="mb-2 h-1 w-full overflow-hidden rounded-full bg-white/8">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-sky-400 via-cyan-300 to-emerald-300"
animate={{ width: `${progress}%` }}

View File

@@ -91,9 +91,13 @@ export default function UploadActions({
}
return (
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky fixed inset-x-0 bottom-0 z-20 px-4 pb-3 lg:static lg:px-0 lg:pb-0' : ''}`}>
<div className="mx-auto w-full max-w-4xl rounded-xl border border-white/10 bg-nova-800/80 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4 lg:shadow-none">
<div className="flex flex-wrap items-center justify-end gap-2.5">
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky bottom-0 z-20 px-4 pb-3 lg:static lg:px-0 lg:pb-0' : ''}`}>
<div className="mx-auto w-full max-w-4xl rounded-[24px] border border-white/10 bg-[#08111c]/88 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4 lg:shadow-none">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.18em] text-white/35">
{step === 1 ? 'Step 1 of 3' : step === 2 ? 'Step 2 of 3' : 'Step 3 of 3'}
</div>
<div className="flex flex-wrap items-center justify-end gap-2.5">
{canGoBack && (
<button
type="button"
@@ -149,6 +153,7 @@ export default function UploadActions({
{renderPrimary()}
</div>
</div>
</div>
</footer>
)
}

View File

@@ -67,7 +67,7 @@ export default function UploadDropzone({
}
return (
<section className={`rounded-xl bg-gradient-to-br p-0 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors ${invalid ? 'ring-1 ring-red-400/40 from-red-500/10 to-slate-900/45' : 'ring-1 ring-white/10 from-slate-900/80 to-slate-900/50'}`}>
<section className={`rounded-[28px] bg-gradient-to-br p-0 shadow-[0_20px_60px_rgba(0,0,0,0.30)] transition-colors ${invalid ? 'ring-1 ring-red-400/40 from-red-500/10 to-slate-900/45' : 'ring-1 ring-white/10 from-slate-900/80 to-slate-900/50'}`}>
{/* Intended props: file, dragState, accept, onDrop, onBrowse, onReset, disabled */}
<motion.div
data-testid="upload-dropzone"
@@ -100,7 +100,7 @@ export default function UploadDropzone({
}}
animate={prefersReducedMotion ? undefined : { scale: dragging ? 1.01 : 1 }}
transition={dragTransition}
className={`group rounded-xl border-2 border-dashed border-white/15 py-6 px-4 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-sky-500/5 hover:bg-sky-500/12'}`}
className={`group rounded-[26px] border-2 border-dashed border-white/15 px-5 py-7 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 sm:px-6 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-[linear-gradient(180deg,rgba(14,165,233,0.08),rgba(255,255,255,0.02))] hover:bg-sky-500/12'}`}
>
<h3 className="mt-3 text-sm font-semibold text-white">{title}</h3>
<p className="mt-1 text-xs text-soft">{description}</p>
@@ -122,7 +122,7 @@ export default function UploadDropzone({
</div>
) : (
<>
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full border border-sky-400/60 bg-sky-500/12 text-sky-100 shadow-sm">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl border border-sky-400/40 bg-sky-500/12 text-sky-100 shadow-[0_14px_40px_rgba(14,165,233,0.18)]">
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" />
<path d="M7 10l5-5 5 5" />
@@ -130,10 +130,14 @@ export default function UploadDropzone({
</svg>
</div>
<p className="mt-1 text-xs text-soft">Accepted: JPG, JPEG, PNG, WEBP, ZIP, RAR, 7Z, TAR, GZ</p>
<p className="text-xs text-soft">Max size: images 50MB · archives 200MB</p>
<div className="mt-3 flex flex-wrap items-center justify-center gap-2 text-[11px] text-white/65">
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">JPG, PNG, WEBP</span>
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">ZIP, RAR, 7Z</span>
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">50MB images</span>
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">200MB archives</span>
</div>
<span className={`btn-secondary mt-3 inline-flex text-sm ${locked ? 'opacity-80' : 'group-focus-visible:bg-white/15'}`}>
<span className={`btn-secondary mt-4 inline-flex text-sm ${locked ? 'opacity-80' : 'group-focus-visible:bg-white/15'}`}>
Click to browse files
</span>
</>
@@ -155,7 +159,7 @@ export default function UploadDropzone({
/>
{(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && (
<div className="mt-3 rounded-lg ring-1 ring-white/10 bg-black/25 px-3 py-2 text-left text-xs text-white/80">
<div className="mt-4 rounded-2xl ring-1 ring-white/10 bg-black/25 px-4 py-3 text-left text-xs text-white/80">
<div className="font-medium text-white/85">Selected file</div>
<div className="mt-1 truncate">{fileName || fileHint}</div>
{fileMeta && (

View File

@@ -4,8 +4,8 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
/**
* UploadOverlay
*
* A frosted-glass floating panel that rises from the bottom of the step content
* area while an upload or processing job is in flight.
* A centered modal-style progress overlay shown while an upload or processing
* job is in flight.
*
* Shows:
* - State icon + label + live percentage
@@ -109,107 +109,135 @@ export default function UploadOverlay({
{isVisible && (
<motion.div
key="upload-overlay"
role="status"
aria-live="polite"
aria-label={`${meta.label}${progress > 0 ? `${progress}%` : ''}`}
initial={prefersReducedMotion ? false : { opacity: 0, y: 24, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: 16, scale: 0.98 }}
initial={prefersReducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={prefersReducedMotion ? {} : { opacity: 0 }}
transition={overlayTransition}
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none"
className="fixed inset-0 z-[80] flex items-center justify-center p-4 sm:p-6"
>
{/* Fade-out gradient so step content peeks through above */}
<div
className="absolute inset-x-0 -top-12 h-12 bg-gradient-to-t from-slate-950/70 to-transparent pointer-events-none rounded-b-2xl"
aria-hidden="true"
/>
<div className="absolute inset-0 bg-slate-950/72 backdrop-blur-sm" aria-hidden="true" />
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="upload-overlay-title"
aria-describedby="upload-overlay-description"
initial={prefersReducedMotion ? false : { opacity: 0, y: 18, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: 12, scale: 0.98 }}
transition={overlayTransition}
className="relative w-full max-w-xl overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(6,14,24,0.96),rgba(2,8,23,0.92))] px-5 pb-5 pt-5 shadow-[0_30px_120px_rgba(2,8,23,0.72)] ring-1 ring-inset ring-white/8 backdrop-blur-xl sm:px-6 sm:pb-6 sm:pt-6"
>
<div
role="status"
aria-live="polite"
aria-label={`${meta.label}${progress > 0 ? `${progress}%` : ''}`}
>
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<div className={`flex items-center gap-2 ${meta.color}`}>
{meta.icon}
<span id="upload-overlay-title" className="text-xl font-semibold tracking-tight">
{meta.label}
</span>
{machineState !== 'error' && (
<span className="relative flex h-2.5 w-2.5 shrink-0" aria-hidden="true">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-current opacity-80" />
</span>
)}
</div>
<p id="upload-overlay-description" className="mt-2 text-sm text-white/60">
{machineState === 'error'
? 'The upload was interrupted. You can retry safely or start over.'
: 'Keep this tab open while we finish the upload and process your artwork.'}
</p>
</div>
<div className="pointer-events-auto mx-0 rounded-b-2xl rounded-t-xl border border-white/10 bg-slate-950/88 px-5 pb-5 pt-4 shadow-2xl shadow-black/70 ring-1 ring-inset ring-white/6 backdrop-blur-xl">
{/* ── Header: icon + state label + percentage ── */}
<div className="flex items-center justify-between gap-3">
<div className={`flex items-center gap-2 ${meta.color}`}>
{meta.icon}
<span className="text-sm font-semibold tracking-wide">
{meta.label}
</span>
{/* Pulsing dot for active states */}
{machineState !== 'error' && (
<span className="relative flex h-2 w-2 shrink-0" aria-hidden="true">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-current opacity-80" />
<span className={`shrink-0 tabular-nums text-2xl font-bold ${meta.color}`}>
{progress}%
</span>
)}
</div>
{machineState !== 'error' && (
<span className={`tabular-nums text-sm font-bold ${meta.color}`}>
{progress}%
</span>
)}
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<span className={`text-lg font-semibold ${meta.color}`}>
{meta.label}
</span>
{machineState !== 'error' && (
<span className="text-sm text-white/45">Secure pipeline active</span>
)}
</div>
{/* ── Progress bar ── */}
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-white/8">
<motion.div
className={`h-full rounded-full bg-gradient-to-r ${meta.barColor}`}
animate={{ width: machineState === 'error' ? '100%' : `${progress}%` }}
transition={barTransition}
style={machineState === 'error' ? { opacity: 0.35 } : {}}
/>
</div>
<div className="mt-4 h-3 w-full overflow-hidden rounded-full bg-white/8">
<motion.div
className={`h-full rounded-full bg-gradient-to-r ${meta.barColor}`}
animate={{ width: machineState === 'error' ? '100%' : `${progress}%` }}
transition={barTransition}
style={machineState === 'error' ? { opacity: 0.35 } : {}}
/>
</div>
{/* ── Sublabel / transparency message ── */}
<AnimatePresence mode="wait" initial={false}>
{machineState !== 'error' && displayLabel && (
<motion.p
key={displayLabel}
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="mt-2 text-xs text-white/50"
>
{displayLabel}
</motion.p>
)}
</AnimatePresence>
<AnimatePresence mode="wait" initial={false}>
{machineState !== 'error' && displayLabel && (
<motion.p
key={displayLabel}
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="mt-4 text-sm text-white/60"
>
{displayLabel}
</motion.p>
)}
</AnimatePresence>
{/* ── Error details + actions ── */}
<AnimatePresence initial={false}>
{machineState === 'error' && (
<motion.div
key="error-block"
initial={prefersReducedMotion ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={prefersReducedMotion ? {} : { opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-3 rounded-lg border border-rose-400/20 bg-rose-500/10 px-3 py-2.5">
<p className="text-xs text-rose-200 leading-relaxed">
{error || 'Something went wrong. You can retry safely.'}
</p>
<div className="mt-2.5 flex gap-2">
<button
type="button"
onClick={onRetry}
className="rounded-md border border-rose-300/30 bg-rose-400/15 px-3 py-1 text-xs font-medium text-rose-100 transition hover:bg-rose-400/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60"
>
Retry upload
</button>
<button
type="button"
onClick={onReset}
className="rounded-md border border-white/20 bg-white/8 px-3 py-1 text-xs font-medium text-white/60 transition hover:bg-white/14 hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
>
Start over
</button>
{machineState !== 'error' && (
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-white/30">
Progress updates are live
</p>
)}
</div>
<AnimatePresence initial={false}>
{machineState === 'error' && (
<motion.div
key="error-block"
initial={prefersReducedMotion ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={prefersReducedMotion ? {} : { opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-4">
<p className="text-sm leading-relaxed text-rose-100">
{error || 'Something went wrong. You can retry safely.'}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={onRetry}
className="rounded-lg border border-rose-300/30 bg-rose-400/15 px-3.5 py-2 text-sm font-medium text-rose-100 transition hover:bg-rose-400/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60"
>
Retry upload
</button>
<button
type="button"
onClick={onReset}
className="rounded-lg border border-white/20 bg-white/8 px-3.5 py-2 text-sm font-medium text-white/70 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
>
Start over
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>

View File

@@ -16,7 +16,7 @@ export default function UploadSidebar({
onToggleRights,
}) {
return (
<aside className="rounded-2xl border border-white/7 bg-gradient-to-br from-slate-900/55 to-slate-900/35 p-6 shadow-[0_10px_24px_rgba(0,0,0,0.22)] sm:p-7">
<aside className="rounded-[28px] border border-white/8 bg-gradient-to-br from-slate-900/55 to-slate-900/35 p-6 shadow-[0_18px_60px_rgba(0,0,0,0.22)] sm:p-7">
{showHeader && (
<div className="mb-5 rounded-xl border border-white/8 bg-white/[0.04] p-4">
<h3 className="text-lg font-semibold text-white">{title}</h3>
@@ -25,7 +25,7 @@ export default function UploadSidebar({
)}
<div className="space-y-5">
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-3">
<h4 className="text-sm font-semibold text-white">Basics</h4>
<p className="mt-1 text-xs text-white/60">Add a clear title and short description.</p>
@@ -58,7 +58,7 @@ export default function UploadSidebar({
</div>
</section>
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-3">
<h4 className="text-sm font-semibold text-white">Tags</h4>
<p className="mt-1 text-xs text-white/60">Use keywords people would search for. Press Enter, comma, or Tab to add a tag.</p>
@@ -74,7 +74,7 @@ export default function UploadSidebar({
/>
</section>
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<Checkbox
id="upload-sidebar-rights"
checked={Boolean(metadata.rightsAccepted)}

View File

@@ -201,14 +201,12 @@ export default function UploadWizard({
const metadataErrors = useMemo(() => {
const errors = {}
if (!String(metadata.title || '').trim()) errors.title = 'Title is required.'
if (!String(metadata.description || '').trim()) errors.description = 'Description is required.'
if (!metadata.contentType) errors.contentType = 'Content type is required.'
if (!metadata.rootCategoryId) errors.category = 'Root category is required.'
if (metadata.rootCategoryId && requiresSubCategory && !metadata.subCategoryId) {
errors.category = 'Subcategory is required for the selected category.'
}
if (!metadata.rightsAccepted) errors.rights = 'Rights confirmation is required.'
if (!Array.isArray(metadata.tags) || metadata.tags.length === 0) errors.tags = 'At least one tag is required.'
return errors
}, [metadata, requiresSubCategory])
@@ -465,7 +463,7 @@ export default function UploadWizard({
return (
<section
ref={stepContentRef}
className="space-y-4 pb-32 text-white lg:pb-6"
className="space-y-5 pb-32 text-white lg:pb-8"
data-is-archive={isArchive ? 'true' : 'false'}
>
{notices.length > 0 && (
@@ -492,7 +490,7 @@ export default function UploadWizard({
{/* Restored draft banner */}
{showRestoredBanner && (
<div className="rounded-xl ring-1 ring-sky-300/25 bg-sky-500/10 px-4 py-2.5 text-sm text-sky-100">
<div className="rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-3 text-sm text-sky-100 shadow-[0_14px_44px_rgba(14,165,233,0.10)]">
<div className="flex items-center justify-between gap-3">
<span>Draft restored. Continue from your previous upload session.</span>
<button
@@ -518,11 +516,11 @@ export default function UploadWizard({
/>
{/* ── Main body: two-column on desktop ─────────────────────────────── */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:gap-8">
{/* Left / main column: step content */}
<div className="min-w-0 flex-1">
{/* Step content + floating progress overlay */}
<div className={`relative transition-[padding-bottom] duration-300 ${showOverlay ? 'pb-36' : ''}`}>
{/* Step content + centered progress overlay */}
<div className="relative">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`step-${activeStep}`}
@@ -547,7 +545,7 @@ export default function UploadWizard({
{/* Wizard action bar (nav: back/next/start/retry) */}
{machine.state !== machineStates.complete && (
<div className="mt-4">
<div className="mt-5">
<UploadActions
step={activeStep}
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
@@ -585,7 +583,7 @@ export default function UploadWizard({
{/* Right column: PublishPanel (sticky sidebar on lg+) */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && (
<div className="hidden lg:block lg:w-72 xl:w-80 shrink-0 lg:sticky lg:top-20 lg:self-start">
<div className="hidden shrink-0 lg:block lg:w-80 xl:w-[22rem] lg:sticky lg:top-20 lg:self-start">
<PublishPanel
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
@@ -600,6 +598,7 @@ export default function UploadWizard({
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
showRightsConfirmation={activeStep === 3}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}
@@ -617,8 +616,9 @@ export default function UploadWizard({
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
<button
type="button"
aria-label="Open publish panel"
onClick={() => setShowMobilePublishPanel((v) => !v)}
className="flex items-center gap-2 rounded-full bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-900/40 transition hover:bg-sky-400 active:scale-95"
className="flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-[0_18px_50px_rgba(14,165,233,0.35)] transition hover:bg-sky-400 active:scale-95"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
@@ -671,6 +671,7 @@ export default function UploadWizard({
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
showRightsConfirmation={activeStep === 3}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}

View File

@@ -48,7 +48,7 @@ function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk
return Promise.resolve({ data: { processing_state: statusValue, status: statusValue } })
}
if (url === '/api/uploads/session-1/publish') {
if (/^\/api\/uploads\/[^/]+\/publish$/.test(url)) {
return Promise.resolve({ data: { success: true, status: 'published' } })
}
@@ -114,6 +114,7 @@ async function completeStep1ToReady() {
describe('UploadWizard step flow', () => {
let originalImage
let originalScrollTo
let originalScrollIntoView
let consoleErrorSpy
@@ -122,7 +123,9 @@ describe('UploadWizard step flow', () => {
window.URL.revokeObjectURL = vi.fn()
originalImage = global.Image
originalScrollTo = window.scrollTo
originalScrollIntoView = Element.prototype.scrollIntoView
window.scrollTo = vi.fn()
Element.prototype.scrollIntoView = vi.fn()
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const text = args.map((arg) => String(arg)).join(' ')
@@ -143,6 +146,7 @@ describe('UploadWizard step flow', () => {
afterEach(() => {
global.Image = originalImage
window.scrollTo = originalScrollTo
Element.prototype.scrollIntoView = originalScrollIntoView
consoleErrorSpy?.mockRestore()
cleanup()

View File

@@ -29,9 +29,9 @@ export default function Step1FileUpload({
machine,
}) {
return (
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
{/* Step header */}
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
<div className="rounded-2xl border border-white/8 bg-white/[0.04] px-5 py-4">
<h2
ref={headingRef}
tabIndex={-1}
@@ -45,9 +45,31 @@ export default function Step1FileUpload({
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{[
{
title: '1. Add the file',
body: 'Drop an image or archive pack into the upload area.',
},
{
title: '2. Check validation',
body: 'We flag unsupported formats, missing screenshots, and basic file issues immediately.',
},
{
title: '3. Start upload',
body: 'Once the file is clean, the secure processing pipeline takes over.',
},
].map((item) => (
<div key={item.title} className="rounded-2xl border border-white/8 bg-white/[0.03] p-4">
<p className="text-sm font-semibold text-white">{item.title}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">{item.body}</p>
</div>
))}
</div>
{/* Locked notice */}
{fileSelectionLocked && (
<div className="flex items-center gap-2 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-100 ring-1 ring-amber-300/30">
<div className="flex items-center gap-2 rounded-2xl bg-amber-500/10 px-4 py-3 text-xs text-amber-100 ring-1 ring-amber-300/30">
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clipRule="evenodd" />
</svg>

View File

@@ -36,9 +36,9 @@ export default function Step2Details({
onToggleRights,
}) {
return (
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
{/* Step header */}
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
<div className="rounded-2xl border border-white/8 bg-white/[0.04] px-5 py-4">
<h2
ref={headingRef}
tabIndex={-1}
@@ -52,7 +52,7 @@ export default function Step2Details({
</div>
{/* Uploaded asset summary */}
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-4">
<div className="rounded-2xl ring-1 ring-white/8 bg-white/[0.025] p-5">
<p className="mb-3 text-[11px] uppercase tracking-wide text-white/45">Uploaded asset</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* Thumbnail / Archive icon */}
@@ -96,7 +96,7 @@ export default function Step2Details({
</div>
{/* Content type selector */}
<section className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-4 sm:p-5">
<section className="rounded-2xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-5 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Content type</h3>
@@ -116,7 +116,7 @@ export default function Step2Details({
</section>
{/* Category selector */}
<section className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-4 sm:p-5">
<section className="rounded-2xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-5 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Category</h3>

View File

@@ -76,9 +76,9 @@ export default function Step3Publish({
]
return (
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
{/* Step header */}
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
<div className="rounded-2xl border border-white/8 bg-white/[0.04] px-5 py-4">
<h2
ref={headingRef}
tabIndex={-1}
@@ -92,7 +92,7 @@ export default function Step3Publish({
</div>
{/* Preview + summary */}
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-4">
<div className="rounded-2xl ring-1 ring-white/8 bg-white/[0.025] p-5">
<div className="flex flex-col gap-4 sm:flex-row">
{/* Artwork thumbnail */}
<div className="shrink-0">

View File

@@ -359,26 +359,60 @@ function SuggestionChip({ href, label, icon, highlight = false, onNavigate }) {
)
}
function OverviewMetric({ label, value, href, icon, accent = 'sky', onNavigate }) {
function OverviewMetric({ label, value, href, icon, accent = 'sky', caption = null, onNavigate }) {
const accents = {
sky: 'text-sky-200 border-sky-300/20 bg-sky-400/10',
amber: 'text-amber-200 border-amber-300/20 bg-amber-400/10',
emerald: 'text-emerald-200 border-emerald-300/20 bg-emerald-400/10',
rose: 'text-rose-200 border-rose-300/20 bg-rose-400/10',
slate: 'text-slate-200 border-white/10 bg-white/5',
sky: {
icon: 'text-sky-100 border-sky-300/20 bg-sky-400/12',
card: 'hover:border-sky-300/35 hover:bg-[#102033]',
glow: 'from-sky-400/18 via-sky-400/8 to-transparent',
caption: 'text-sky-100/75',
},
amber: {
icon: 'text-amber-100 border-amber-300/20 bg-amber-400/12',
card: 'hover:border-amber-300/35 hover:bg-[#1a2130]',
glow: 'from-amber-400/18 via-amber-400/8 to-transparent',
caption: 'text-amber-100/75',
},
emerald: {
icon: 'text-emerald-100 border-emerald-300/20 bg-emerald-400/12',
card: 'hover:border-emerald-300/35 hover:bg-[#0f2130]',
glow: 'from-emerald-400/18 via-emerald-400/8 to-transparent',
caption: 'text-emerald-100/75',
},
rose: {
icon: 'text-rose-100 border-rose-300/20 bg-rose-400/12',
card: 'hover:border-rose-300/35 hover:bg-[#1d1d31]',
glow: 'from-rose-400/18 via-rose-400/8 to-transparent',
caption: 'text-rose-100/75',
},
slate: {
icon: 'text-slate-100 border-white/10 bg-white/5',
card: 'hover:border-white/20 hover:bg-[#102033]',
glow: 'from-white/10 via-white/5 to-transparent',
caption: 'text-slate-300/80',
},
}
const tone = accents[accent] || accents.slate
return (
<a
href={href}
onClick={() => onNavigate?.(href, label)}
className="group rounded-2xl border border-white/10 bg-[#0b1826]/85 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-sky-300/35 hover:bg-[#102033]"
className={[
'group relative overflow-hidden rounded-2xl border border-white/10 bg-[#0b1826]/85 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5',
tone.card,
].join(' ')}
>
<div className={`pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b ${tone.glow}`} />
<div className="flex items-center justify-between gap-3">
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${accents[accent] || accents.slate}`}>
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${tone.icon}`}>
<i className={icon} aria-hidden="true" />
</span>
<span className="text-2xl font-semibold text-white">{value}</span>
<div className="text-right">
<span className="block text-2xl font-semibold text-white">{value}</span>
{caption ? <span className={`mt-1 block text-[11px] uppercase tracking-[0.16em] ${tone.caption}`}>{caption}</span> : null}
</div>
</div>
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-400">{label}</p>
</a>
@@ -386,15 +420,64 @@ function OverviewMetric({ label, value, href, icon, accent = 'sky', onNavigate }
}
function SectionLinkCard({ item, onNavigate, onTogglePin, isPinned = false }) {
const accents = {
sky: {
icon: 'border-sky-300/20 bg-sky-400/12 text-sky-100',
badge: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
preview: 'text-sky-200/80',
open: 'text-sky-100',
hover: 'hover:border-sky-300/35 hover:bg-[#102033]',
glow: 'from-sky-400/16 via-sky-400/8 to-transparent',
},
emerald: {
icon: 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100',
badge: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
preview: 'text-emerald-200/80',
open: 'text-emerald-100',
hover: 'hover:border-emerald-300/35 hover:bg-[#102033]',
glow: 'from-emerald-400/16 via-emerald-400/8 to-transparent',
},
amber: {
icon: 'border-amber-300/20 bg-amber-400/12 text-amber-100',
badge: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
preview: 'text-amber-200/80',
open: 'text-amber-100',
hover: 'hover:border-amber-300/35 hover:bg-[#1a2130]',
glow: 'from-amber-400/16 via-amber-400/8 to-transparent',
},
rose: {
icon: 'border-rose-300/20 bg-rose-400/12 text-rose-100',
badge: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
preview: 'text-rose-200/80',
open: 'text-rose-100',
hover: 'hover:border-rose-300/35 hover:bg-[#1d1d31]',
glow: 'from-rose-400/16 via-rose-400/8 to-transparent',
},
slate: {
icon: 'border-white/10 bg-white/5 text-sky-200',
badge: 'border-white/10 bg-white/5 text-slate-200',
preview: 'text-sky-200/80',
open: 'text-sky-100',
hover: 'hover:border-white/20 hover:bg-[#102033]',
glow: 'from-white/10 via-white/5 to-transparent',
},
}
const tone = accents[item.accent] || accents.slate
return (
<article className="group rounded-2xl border border-white/10 bg-[#0b1826]/80 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-sky-300/35 hover:bg-[#102033]">
<article className={[
'group relative overflow-hidden rounded-2xl border border-white/10 bg-[#0b1826]/80 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5',
tone.hover,
].join(' ')}>
<div className={`pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b ${tone.glow}`} />
<div className="flex items-start justify-between gap-3">
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-lg text-sky-200">
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border text-lg ${tone.icon}`}>
<i className={item.icon} aria-hidden="true" />
</span>
<div className="flex items-center gap-2">
{item.badge ? (
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100">
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone.badge}`}>
{item.badge}
</span>
) : null}
@@ -418,12 +501,12 @@ function SectionLinkCard({ item, onNavigate, onTogglePin, isPinned = false }) {
<div className="mt-4">
<h3 className="text-base font-semibold text-white transition group-hover:text-sky-100">{item.label}</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">{item.description}</p>
{item.preview ? <p className="mt-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-200/80">{item.preview}</p> : null}
{item.preview ? <p className={`mt-3 text-xs font-semibold uppercase tracking-[0.14em] ${tone.preview}`}>{item.preview}</p> : null}
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.18em] text-slate-400">
<span>{item.meta}</span>
<a href={item.href} onClick={() => onNavigate?.(item.href, item.label)} className="text-sky-200 transition group-hover:translate-x-0.5">
<a href={item.href} onClick={() => onNavigate?.(item.href, item.label)} className={`${tone.open} transition group-hover:translate-x-0.5`}>
Open
</a>
</div>
@@ -849,8 +932,8 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
},
{
eyebrow: 'Community',
title: 'Followers, following, and saved work',
description: 'Move between your people-focused spaces without digging through the navigation.',
title: 'Audience, network, and saved work',
description: 'Stay close to the people around your account, from new followers to the creators shaping your feed.',
items: [
{
label: 'Followers',
@@ -860,6 +943,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
meta: 'Dashboard Followers',
badge: overviewStats.followers > 0 ? String(overviewStats.followers) : null,
preview: previewLabelForRoute('/dashboard/followers', overviewStats),
accent: 'sky',
},
{
label: 'Following',
@@ -869,6 +953,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
meta: 'Dashboard Following',
badge: overviewStats.following > 0 ? String(overviewStats.following) : null,
preview: previewLabelForRoute('/dashboard/following', overviewStats),
accent: 'emerald',
},
{
label: 'Favorites',
@@ -878,6 +963,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
meta: 'Dashboard Favorites',
badge: overviewStats.favorites > 0 ? String(overviewStats.favorites) : null,
preview: previewLabelForRoute('/dashboard/favorites', overviewStats),
accent: 'rose',
},
],
},
@@ -933,6 +1019,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
href: '/dashboard/notifications',
icon: 'fa-solid fa-bell',
accent: overviewStats.notifications > 0 ? 'amber' : 'slate',
caption: overviewStats.notifications > 0 ? 'Needs review' : 'All clear',
},
{
label: 'Followers',
@@ -940,13 +1027,15 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
href: '/dashboard/followers',
icon: 'fa-solid fa-user-group',
accent: 'sky',
caption: overviewStats.followers > 0 ? 'Audience' : 'Build audience',
},
{
label: 'Following',
value: overviewStats.following,
href: '/dashboard/following',
icon: 'fa-solid fa-users',
accent: 'slate',
accent: 'emerald',
caption: overviewStats.following > 0 ? 'Network' : 'Find creators',
},
{
label: 'Saved favorites',
@@ -954,6 +1043,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
href: '/dashboard/favorites',
icon: 'fa-solid fa-bookmark',
accent: 'rose',
caption: overviewStats.favorites > 0 ? 'Inspiration' : 'Nothing saved',
},
{
label: 'Artworks',
@@ -961,6 +1051,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
href: '/dashboard/artworks',
icon: 'fa-solid fa-layer-group',
accent: 'emerald',
caption: overviewStats.artworks > 0 ? 'Portfolio' : 'Start uploading',
},
{
label: 'Stories',
@@ -968,6 +1059,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
href: isCreator ? '/creator/stories' : '/creator/stories/create',
icon: 'fa-solid fa-pen-nib',
accent: 'amber',
caption: overviewStats.stories > 0 ? 'Creator voice' : 'Tell your story',
},
]
const suggestions = [
@@ -1069,7 +1161,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
<h1 className="mt-3 max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Welcome back, {username}
</h1>
<div className="mt-4 flex items-center gap-2">
<div className="mt-4 flex flex-wrap items-center gap-2">
<LevelBadge level={level} rank={rank} />
</div>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 sm:text-base">
@@ -1090,7 +1182,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<HeroStat label="Level" value={`Lv. ${level}`} tone="sky" />
<HeroStat label="Rank" value={rank} tone="amber" />
<HeroStat

View File

@@ -1,29 +1,36 @@
import React, { useEffect, useState } from 'react'
function actorLabel(item) {
if (!item?.user) {
return 'Someone'
if (!item?.actor) {
return item?.type === 'notification' ? 'System' : 'Someone'
}
return item.user.username ? `@${item.user.username}` : item.user.name || 'User'
return item.actor.username ? `@${item.actor.username}` : item.actor.name || 'User'
}
function describeActivity(item) {
const artworkTitle = item?.artwork?.title || 'an artwork'
const mentionTarget = item?.mentioned_user?.username || item?.mentioned_user?.name || 'someone'
const reactionLabel = item?.reaction?.label || 'reacted'
switch (item?.type) {
case 'comment':
return `commented on ${artworkTitle}`
case 'reply':
return `replied on ${artworkTitle}`
case 'reaction':
return `${reactionLabel.toLowerCase()} on ${artworkTitle}`
case 'mention':
return `mentioned @${mentionTarget} on ${artworkTitle}`
return item?.context?.artwork_title ? `commented on ${item.context.artwork_title}` : 'commented on your artwork'
case 'new_follower':
return 'started following you'
case 'notification':
return item?.message || 'sent a notification'
default:
return 'shared new activity'
return item?.message || 'shared new activity'
}
}
function activityIcon(type) {
switch (type) {
case 'comment':
return 'fa-solid fa-comment-dots'
case 'new_follower':
return 'fa-solid fa-user-plus'
case 'notification':
return 'fa-solid fa-bell'
default:
return 'fa-solid fa-bolt'
}
}
@@ -76,17 +83,26 @@ export default function ActivityFeed() {
}, [])
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Activity Feed</h2>
<span className="text-xs text-gray-400">Recent actions</span>
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Live activity</p>
<h2 className="mt-2 text-xl font-semibold text-white">Activity Feed</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-300">
Recent followers, artwork comments, and notifications that deserve your attention.
</p>
</div>
<span className="inline-flex items-center justify-center rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-300 sm:justify-start">Recent actions</span>
</div>
{loading ? <p className="text-sm text-gray-400">Loading activity...</p> : null}
{loading ? <p className="text-sm text-slate-400">Loading activity...</p> : null}
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
{!loading && !error && items.length === 0 ? (
<p className="text-sm text-gray-400">No recent activity yet.</p>
<div className="rounded-2xl border border-white/8 bg-white/5 px-5 py-6 text-sm text-slate-300">
<p className="font-medium text-white">No recent activity yet.</p>
<p className="mt-2 text-slate-400">New followers, comments, and notifications will appear here as they happen.</p>
</div>
) : null}
{!loading && !error && items.length > 0 ? (
@@ -96,19 +112,43 @@ export default function ActivityFeed() {
key={item.id}
className={`rounded-xl border p-3 transition ${
item.is_unread
? 'border-cyan-500/40 bg-cyan-500/10'
: 'border-gray-700 bg-gray-900/60'
? 'border-sky-400/30 bg-sky-400/10'
: 'border-white/8 bg-white/[0.04]'
}`}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-gray-100">
<span className="font-semibold text-white">{actorLabel(item)}</span> {describeActivity(item)}
</p>
<div className="flex items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-slate-950/60">
{item.actor?.avatar ? (
<img src={item.actor.avatar} alt={actorLabel(item)} className="h-full w-full object-cover" />
) : (
<i className={`${activityIcon(item.type)} text-sm text-sky-100/80`} />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-sm text-slate-100">
<span className="font-semibold text-white">{actorLabel(item)}</span>{' '}
<span>{describeActivity(item)}</span>
</p>
{item.message && item.type !== 'notification' ? (
<p className="mt-1 text-xs text-slate-400">{item.message}</p>
) : null}
</div>
<span className="text-[11px] uppercase tracking-wide text-slate-400 sm:shrink-0">{timeLabel(item.created_at)}</span>
</div>
{item.context?.artwork_url ? (
<a
href={item.context.artwork_url}
className="mt-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-200 transition hover:border-white/20 hover:bg-white/10"
>
View artwork
<i className="fa-solid fa-arrow-right text-[10px]" />
</a>
) : null}
</div>
</div>
{item.comment?.body ? (
<p className="mt-2 line-clamp-2 text-xs text-gray-300">{item.comment.body}</p>
) : null}
<p className="mt-2 text-xs text-gray-400">{timeLabel(item.created_at)}</p>
</article>
))}
</div>

View File

@@ -2,8 +2,8 @@ import React, { useEffect, useState } from 'react'
function Widget({ label, value }) {
return (
<div className="rounded-xl border border-gray-700 bg-gray-900/70 p-4 shadow-lg transition hover:scale-[1.02]">
<p className="text-xs uppercase tracking-wide text-gray-400">{label}</p>
<div className="rounded-2xl border border-white/8 bg-white/[0.04] p-4 shadow-lg transition hover:-translate-y-0.5 hover:border-white/15 hover:bg-white/[0.06]">
<p className="text-[11px] uppercase tracking-[0.16em] text-slate-400">{label}</p>
<p className="mt-2 text-2xl font-semibold text-white">{value}</p>
</div>
)
@@ -37,18 +37,23 @@ export default function CreatorAnalytics({ isCreator }) {
}, [])
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Creator Analytics</h2>
<a href="/creator/analytics" className="text-xs text-cyan-300 hover:text-cyan-200">
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Creator space</p>
<h2 className="mt-2 text-xl font-semibold text-white">Creator Analytics</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-300">Snapshot metrics for the work you publish and the audience building around it.</p>
</div>
<a href="/creator/analytics" className="inline-flex items-center justify-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-400/15 sm:justify-start">
Open analytics
<i className="fa-solid fa-arrow-right text-[10px]" />
</a>
</div>
{loading ? <p className="text-sm text-gray-400">Loading analytics...</p> : null}
{loading ? <p className="text-sm text-slate-400">Loading analytics...</p> : null}
{!loading && !isCreator && !data?.is_creator ? (
<div className="rounded-xl border border-gray-700 bg-gray-900/60 p-4 text-sm text-gray-300">
<div className="rounded-2xl border border-white/8 bg-white/[0.04] p-4 text-sm text-slate-300">
Upload your first artwork to unlock creator-only insights.
</div>
) : null}

View File

@@ -29,31 +29,35 @@ export default function RecentAchievements() {
}, [])
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="flex items-center justify-between gap-3">
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-gray-400">Achievements</p>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Achievements</p>
<h2 className="mt-2 text-xl font-semibold text-white">Recent Unlocks</h2>
<p className="mt-2 max-w-xs text-sm leading-6 text-slate-300">Recent badges and milestones that reflect how your account is developing.</p>
</div>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-gray-300">
<span className="inline-flex items-center justify-center rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300 sm:justify-start">
{data?.counts?.unlocked || 0} / {data?.counts?.total || 0}
</span>
</div>
{loading ? <p className="mt-4 text-sm text-gray-400">Loading achievements...</p> : null}
{loading ? <p className="mt-4 text-sm text-slate-400">Loading achievements...</p> : null}
{!loading && (!Array.isArray(data?.recent) || data.recent.length === 0) ? (
<p className="mt-4 text-sm text-gray-400">No achievements unlocked yet.</p>
<div className="mt-4 rounded-2xl border border-white/8 bg-white/[0.04] px-4 py-5 text-sm text-slate-300">
<p className="font-medium text-white">No achievements unlocked yet.</p>
<p className="mt-2 text-slate-400">Keep posting, engaging, and growing your profile to start earning them.</p>
</div>
) : null}
{!loading && Array.isArray(data?.recent) && data.recent.length > 0 ? (
<div className="mt-4 space-y-3">
{data.recent.map((achievement) => (
<article key={achievement.id} className="rounded-xl border border-gray-700 bg-gray-900/60 p-3">
<article key={achievement.id} className="rounded-2xl border border-white/8 bg-white/[0.04] p-4 transition hover:border-white/15 hover:bg-white/[0.06]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-white">{achievement.name}</p>
<p className="mt-1 text-xs text-gray-400">{achievement.description}</p>
<p className="mt-1 text-xs text-slate-400">{achievement.description}</p>
</div>
<AchievementBadge achievement={achievement} compact />
</div>

View File

@@ -4,6 +4,8 @@ import LevelBadge from '../../components/xp/LevelBadge'
export default function RecommendedCreators() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [busyId, setBusyId] = useState(null)
useEffect(() => {
let cancelled = false
@@ -13,6 +15,11 @@ export default function RecommendedCreators() {
const response = await window.axios.get('/api/dashboard/recommended-creators')
if (!cancelled) {
setItems(Array.isArray(response.data?.data) ? response.data.data : [])
setError('')
}
} catch {
if (!cancelled) {
setError('Could not load creator recommendations right now.')
}
} finally {
if (!cancelled) {
@@ -28,19 +35,51 @@ export default function RecommendedCreators() {
}
}, [])
async function handleFollow(creator) {
if (!creator?.username || busyId === creator.id) {
return
}
setBusyId(creator.id)
try {
const response = await window.axios.post(`/@${creator.username}/follow`)
const isFollowing = Boolean(response.data?.following)
if (isFollowing) {
setItems((current) => current.filter((item) => item.id !== creator.id))
}
} catch {
setError('Could not update follow state right now.')
} finally {
setBusyId(null)
}
}
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Recommended Creators</h2>
<a className="text-xs text-cyan-300 hover:text-cyan-200" href="/creators/top">
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Community</p>
<h2 className="mt-2 text-xl font-semibold text-white">Recommended Creators</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-300">
Strong accounts you are not following yet, selected to help you improve your feed and discover new audiences.
</p>
</div>
<a className="inline-flex items-center justify-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-400/15 sm:justify-start" href="/creators/top">
See all
<i className="fa-solid fa-arrow-right text-[10px]" />
</a>
</div>
{loading ? <p className="text-sm text-gray-400">Loading creators...</p> : null}
{loading ? <p className="text-sm text-slate-400">Loading creators...</p> : null}
{error ? <p className="mb-4 text-sm text-rose-300">{error}</p> : null}
{!loading && items.length === 0 ? (
<p className="text-sm text-gray-400">No creator recommendations right now.</p>
<div className="rounded-2xl border border-white/8 bg-white/5 px-5 py-6 text-sm text-slate-300">
<p className="font-medium text-white">No creator recommendations right now.</p>
<p className="mt-2 text-slate-400">Browse the full creator directory to keep expanding your network.</p>
</div>
) : null}
{!loading && items.length > 0 ? (
@@ -48,31 +87,50 @@ export default function RecommendedCreators() {
{items.map((creator) => (
<article
key={creator.id}
className="flex items-center justify-between rounded-xl border border-gray-700 bg-gray-900/70 p-3 transition hover:scale-[1.02]"
className="flex flex-col gap-4 rounded-2xl border border-white/8 bg-white/[0.04] p-4 transition hover:-translate-y-0.5 hover:border-white/15 hover:bg-white/[0.06] sm:flex-row sm:items-center sm:justify-between"
>
<a href={creator.url || '#'} className="flex min-w-0 items-center gap-3">
<a href={creator.url || '#'} className="flex min-w-0 items-center gap-4">
<img
src={creator.avatar || '/images/default-avatar.png'}
alt={creator.username || creator.name || 'Creator'}
className="h-10 w-10 rounded-full border border-gray-600 object-cover"
className="h-12 w-12 rounded-2xl border border-white/10 object-cover"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">
{creator.username ? `@${creator.username}` : creator.name}
</p>
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-semibold text-white">
{creator.username ? `@${creator.username}` : creator.name}
</p>
<span className="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide text-sky-100">
Suggested
</span>
</div>
<div className="mt-1">
<LevelBadge level={creator.level} rank={creator.rank} compact />
</div>
<p className="text-xs text-gray-400">{creator.followers_count} followers</p>
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-400">
<span>{Number(creator.followers_count || 0).toLocaleString()} followers</span>
<span>{Number(creator.uploads_count || 0).toLocaleString()} uploads</span>
</div>
</div>
</a>
<a
href={creator.url || '#'}
className="rounded-lg border border-cyan-400/60 px-3 py-1 text-xs font-semibold text-cyan-200 transition hover:bg-cyan-500/20"
>
Follow
</a>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center sm:self-auto">
<a
href={creator.url || '#'}
className="inline-flex items-center justify-center rounded-full border border-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-200 transition hover:border-white/20 hover:bg-white/5"
>
View profile
</a>
<button
type="button"
onClick={() => handleFollow(creator)}
disabled={busyId === creator.id || !creator.username}
className="inline-flex items-center justify-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-3.5 py-2 text-xs font-semibold uppercase tracking-wide text-emerald-100 transition hover:border-emerald-300/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className={`fa-solid ${busyId === creator.id ? 'fa-circle-notch fa-spin' : 'fa-user-plus'} text-[10px]`} />
Follow
</button>
</div>
</article>
))}
</div>

View File

@@ -31,21 +31,26 @@ export default function TopCreatorsWidget() {
const items = Array.isArray(data?.items) ? data.items.slice(0, 5) : []
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="flex items-center justify-between gap-3">
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-gray-400">Leaderboard</p>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Leaderboard</p>
<h2 className="mt-2 text-xl font-semibold text-white">Top Creators</h2>
<p className="mt-2 max-w-xs text-sm leading-6 text-slate-300">A quick weekly pulse on who is gaining the most traction across the platform.</p>
</div>
<a href="/leaderboard?type=creators&period=weekly" className="text-xs font-semibold uppercase tracking-[0.14em] text-sky-300 hover:text-sky-200">
<a href="/leaderboard?type=creators&period=weekly" className="inline-flex items-center justify-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-400/15 sm:justify-start">
View all
<i className="fa-solid fa-arrow-right text-[10px]" />
</a>
</div>
{loading ? <p className="mt-4 text-sm text-gray-400">Loading leaderboard...</p> : null}
{loading ? <p className="mt-4 text-sm text-slate-400">Loading leaderboard...</p> : null}
{!loading && items.length === 0 ? (
<p className="mt-4 text-sm text-gray-400">No creators ranked yet.</p>
<div className="mt-4 rounded-2xl border border-white/8 bg-white/[0.04] px-4 py-5 text-sm text-slate-300">
<p className="font-medium text-white">No creators ranked yet.</p>
<p className="mt-2 text-slate-400">Rankings will appear here once weekly creator scoring is available.</p>
</div>
) : null}
{!loading && items.length > 0 ? (
@@ -54,18 +59,21 @@ export default function TopCreatorsWidget() {
const entity = item.entity || {}
return (
<a key={item.rank} href={entity.url || '#'} className="flex items-center gap-3 rounded-xl border border-gray-700 bg-gray-900/60 p-3 transition hover:border-sky-400/40">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-sm font-black text-white">
<a key={item.rank} href={entity.url || '#'} className="flex items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.04] p-4 transition hover:-translate-y-0.5 hover:border-white/15 hover:bg-white/[0.06]">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-sky-300/20 bg-sky-400/12 text-sm font-black text-white">
#{item.rank}
</div>
{entity.avatar ? <img src={entity.avatar} alt={entity.name || 'Creator'} className="h-11 w-11 rounded-xl object-cover" loading="lazy" /> : null}
{entity.avatar ? <img src={entity.avatar} alt={entity.name || 'Creator'} className="h-11 w-11 rounded-xl border border-white/10 object-cover" loading="lazy" /> : null}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{entity.name}</p>
<div className="mt-1 flex items-center gap-2">
<LevelBadge level={entity.level} rank={entity.rank} compact />
</div>
</div>
<span className="text-sm font-semibold text-sky-300">{Math.round(item.score)}</span>
<div className="shrink-0 text-right">
<span className="block text-sm font-semibold text-sky-300">{Math.round(item.score)}</span>
<span className="mt-1 block text-[11px] uppercase tracking-[0.16em] text-slate-400">Weekly score</span>
</div>
</a>
)
})}

View File

@@ -38,16 +38,19 @@ export default function XPProgressWidget({ initialLevel = 1, initialRank = 'Newb
}, [])
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="flex items-start justify-between gap-3">
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-gray-400">Progression</p>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Progression</p>
<h2 className="mt-2 text-xl font-semibold text-white">XP Progress</h2>
<p className="mt-2 max-w-xs text-sm leading-6 text-slate-300">Track how close you are to the next level and keep your creator momentum visible.</p>
</div>
<div className="sm:self-start">
<LevelBadge level={data.level} rank={data.rank} compact />
</div>
<LevelBadge level={data.level} rank={data.rank} compact />
</div>
<div className="mt-4">
<div className="mt-5 rounded-2xl border border-white/8 bg-white/[0.04] p-4">
<XPProgressBar
xp={data.xp}
currentLevelXp={data.current_level_xp}
@@ -55,9 +58,14 @@ export default function XPProgressWidget({ initialLevel = 1, initialRank = 'Newb
progressPercent={data.progress_percent}
maxLevel={data.max_level}
/>
<div className="mt-4 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.16em] text-slate-400">
<span>Total XP</span>
<span className="font-semibold text-sky-100">{Number(data.xp || 0).toLocaleString()}</span>
</div>
</div>
{loading ? <p className="mt-3 text-xs text-gray-400">Syncing your latest XP...</p> : null}
{loading ? <p className="mt-4 text-xs text-slate-400">Syncing your latest XP...</p> : null}
</section>
)
}

View File

@@ -134,6 +134,9 @@ export default function useFileValidation(primaryFile, screenshots, isArchive) {
const primaryRunRef = useRef(0)
const screenshotRunRef = useRef(0)
const effectiveIsArchive = typeof isArchive === 'boolean'
? isArchive
: detectFileType(primaryFile) === 'archive'
// Primary file validation
useEffect(() => {
@@ -168,7 +171,7 @@ export default function useFileValidation(primaryFile, screenshots, isArchive) {
let cancelled = false
;(async () => {
const result = await validateScreenshots(screenshots, isArchive)
const result = await validateScreenshots(screenshots, effectiveIsArchive)
if (cancelled || runId !== screenshotRunRef.current) return
setScreenshotErrors(result.errors)
setScreenshotPerFileErrors(result.perFileErrors)
@@ -177,15 +180,15 @@ export default function useFileValidation(primaryFile, screenshots, isArchive) {
return () => {
cancelled = true
}
}, [screenshots, isArchive])
}, [screenshots, effectiveIsArchive])
// Clear screenshots when file changes to a non-archive
useEffect(() => {
if (!isArchive) {
if (!effectiveIsArchive) {
setScreenshotErrors([])
setScreenshotPerFileErrors([])
}
}, [isArchive])
}, [effectiveIsArchive])
// Revoke preview URL on unmount
useEffect(() => {