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

@@ -168,25 +168,40 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
const studioGroups = Array.isArray(props.studio_groups) ? props.studio_groups : []
const currentGroup = props.studioGroup || null
const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator)
const canManageWorlds = canManageNews
const navGroups = baseNavGroups.map((group) => {
if (!canManageNews || group.label !== 'Content') {
if ((!canManageNews && !canManageWorlds) || group.label !== 'Content') {
return group
}
const extraItems = []
if (canManageNews) {
extraItems.push({ label: 'News', href: '/studio/news', icon: 'fa-solid fa-newspaper' })
}
if (canManageWorlds) {
extraItems.push({ label: 'Worlds', href: '/studio/worlds', icon: 'fa-solid fa-globe' })
}
return {
...group,
items: [
...group.items,
{ label: 'News', href: '/studio/news', icon: 'fa-solid fa-newspaper' },
],
items: [...group.items, ...extraItems],
}
})
const quickCreateItems = (canManageNews
? [...baseQuickCreateItems, { label: 'News Article', href: '/studio/news/create', icon: 'fa-solid fa-newspaper' }]
: baseQuickCreateItems
).map((item) => {
const quickCreatePool = [...baseQuickCreateItems]
if (canManageNews) {
quickCreatePool.push({ label: 'News Article', href: '/studio/news/create', icon: 'fa-solid fa-newspaper' })
}
if (canManageWorlds) {
quickCreatePool.push({ label: 'World', href: '/studio/worlds/create', icon: 'fa-solid fa-globe' })
}
const quickCreateItems = quickCreatePool.map((item) => {
if (currentGroup?.urls && item.label === 'Artwork') {
return { ...item, href: currentGroup.urls?.studio_artworks ? `/upload?group=${currentGroup.slug}` : item.href }
}

View File

@@ -187,6 +187,10 @@ function operatorOptionsForField(field) {
return [{ value: 'equals', label: 'Is' }]
}
if (field === 'tags') {
return [{ value: 'equals', label: 'Has tag' }]
}
return [
{ value: 'contains', label: 'Contains' },
{ value: 'equals', label: 'Equals' },
@@ -304,6 +308,11 @@ function buildRuleSummary(rule, smartRuleOptions) {
return rule.value ? 'Mature artworks only' : 'Artworks not marked mature'
}
if (rule.field === 'tags') {
const tag = String(rule.value || '').trim()
return tag ? `Has tag: ${tag}` : 'Tag — no value set'
}
const label = humanizeField(rule.field, smartRuleOptions)
const value = String(rule.value || '').trim() || 'Any value'
return `${label} ${rule.operator} ${value}`
@@ -581,6 +590,33 @@ function StudioTabButton({ active, label, icon, onClick }) {
)
}
function AdvancedSection({ title, icon, children, defaultOpen = false }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="overflow-hidden rounded-[24px] border border-white/10">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="flex w-full items-center justify-between px-5 py-4 text-left transition hover:bg-white/[0.03]"
>
<div className="flex items-center gap-3">
<span className="flex h-7 w-7 items-center justify-center rounded-xl border border-white/10 bg-white/[0.04]">
<i className={`fa-solid ${icon} text-[11px] text-slate-400`} />
</span>
<span className="text-sm font-semibold tracking-[-0.01em] text-white">{title}</span>
</div>
<i className={`fa-solid ${open ? 'fa-chevron-up' : 'fa-chevron-down'} text-[10px] text-slate-500 transition-transform`} />
</button>
{open ? (
<div className="space-y-5 border-t border-white/10 px-5 pb-6 pt-5">
{children}
</div>
) : null}
</div>
)
}
function SmartRuleRow({
rule,
index,
@@ -670,6 +706,16 @@ function SmartRuleRow({
)}
</select>
</Field>
) : rule.field === 'tags' ? (
<Field label="Value" help="Type a tag name exactly as it appears on your artworks.">
<input
type="text"
value={rule.value}
onChange={(event) => onValueChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="e.g. dark-fantasy"
/>
</Field>
) : valueOptions.length ? (
<Field label="Value">
<select
@@ -1803,10 +1849,12 @@ export default function CollectionManage() {
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collection Studio</p>
<h1 className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-white md:text-4xl">
{mode === 'create' ? 'Create a v4 collection' : collectionState?.title || 'Manage collection'}
{mode === 'create' ? 'New Collection' : collectionState?.title || 'Manage Collection'}
</h1>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-300">
Collections now carry lifecycle, presentation, campaign, and series metadata alongside the artwork curation itself. Use manual mode for exact storytelling or smart rules for creator-first automation.
{mode === 'create'
? 'Give your collection a title and choose who can see it — that is all you need to get started. Expand the sections below to add descriptions, presentation options, scheduling, and more.'
: 'Manage your collection details, artworks, members, and settings from the tabs below.'}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -1843,19 +1891,20 @@ export default function CollectionManage() {
<ModeButton
active={!isSmartMode}
title="Manual"
description="Curate artworks yourself, control the exact order, and choose a specific cover from attached pieces."
description="You choose every artwork. Control the exact order, set a cover, and tell the story your way."
icon="fa-hand-sparkles"
onClick={() => updateForm('mode', 'manual')}
/>
<ModeButton
active={isSmartMode}
title="Smart"
description="Build a rule-based collection that automatically pulls matching artworks from your own published gallery."
description="Define rules and let the collection fill automatically from your published artworks. Great for keeping series fresh."
icon="fa-wand-magic-sparkles"
onClick={() => updateForm('mode', 'smart')}
/>
</div>
{/* === Essential fields — always visible === */}
<div className="grid gap-5 md:grid-cols-2">
<Field label="Title">
<input
@@ -1867,109 +1916,285 @@ export default function CollectionManage() {
maxLength={120}
/>
</Field>
<Field label="Slug" help="Used in the collection URL.">
<input
type="text"
value={form.slug}
onChange={(event) => {
setSlugTouched(true)
updateForm('slug', slugify(event.target.value))
}}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="dark-fantasy-series"
maxLength={140}
/>
<Field label="Visibility">
<select value={form.visibility} onChange={(event) => updateForm('visibility', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="public">Public visible to everyone</option>
<option value="unlisted">Unlisted accessible by link only</option>
<option value="private">Private only you can see it</option>
</select>
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Subtitle" help="Optional short line that sits under the title.">
<input
type="text"
value={form.subtitle}
onChange={(event) => updateForm('subtitle', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="A moody archive of midnight environments"
maxLength={160}
/>
</Field>
<Field label="Summary" help="Optional short summary for cards and meta previews.">
<input
type="text"
value={form.summary}
onChange={(event) => updateForm('summary', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="Best performing sci-fi wallpapers from the last year"
maxLength={320}
/>
</Field>
</div>
<Field label="Description">
<textarea
value={form.description}
onChange={(event) => updateForm('description', event.target.value)}
className="min-h-[128px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="Describe the mood, focus, or story behind this showcase."
maxLength={1000}
<Field label="URL Slug" help="Auto-generated from the title. Edit to customise the collection URL.">
<input
type="text"
value={form.slug}
onChange={(event) => {
setSlugTouched(true)
updateForm('slug', slugify(event.target.value))
}}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="dark-fantasy-series"
maxLength={140}
/>
</Field>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Collection Type">
<select value={form.type} onChange={(event) => updateForm('type', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="personal">Personal</option>
<option value="community">Community</option>
<option value="editorial">Editorial</option>
</select>
</Field>
<Field label="Collaboration">
<select value={form.collaboration_mode} onChange={(event) => updateForm('collaboration_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="closed">Closed</option>
<option value="invite_only">Invite Only</option>
<option value="open">Open Submissions</option>
</select>
</Field>
<Field label="Event Key" help="Internal campaign identifier for discovery and promotion logic.">
<input type="text" value={form.event_key} onChange={(event) => updateForm('event_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Event Label" help="Optional campaign or seasonal label.">
<input type="text" value={form.event_label} onChange={(event) => updateForm('event_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
</div>
<Field label="Summary" help="A short line shown on collection cards and in search results.">
<input
type="text"
value={form.summary}
onChange={(event) => updateForm('summary', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="Best performing sci-fi wallpapers from the last year"
maxLength={320}
/>
</Field>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Season Key" help="Optional seasonal key used for grouped landing surfaces.">
<input type="text" value={form.season_key} onChange={(event) => updateForm('season_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Badge Label" help="Short public badge for cards and headers.">
<input type="text" value={form.badge_label} onChange={(event) => updateForm('badge_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Spotlight Style" help="Choose how the public campaign banner should be framed.">
<select value={form.spotlight_style} onChange={(event) => updateForm('spotlight_style', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="default">Default</option>
<option value="editorial">Editorial</option>
<option value="seasonal">Seasonal</option>
<option value="challenge">Challenge</option>
<option value="community">Community</option>
</select>
</Field>
<Field label="Banner Text" help="Optional short line displayed as the collection spotlight banner.">
<input type="text" value={form.banner_text} onChange={(event) => updateForm('banner_text', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={200} />
</Field>
</div>
{/* === Advanced sections — collapsed on create, expanded on edit === */}
<div className="grid gap-5 md:grid-cols-2">
<Field label="Publish At" help="Leave empty to publish immediately. Future times keep the collection off public surfaces until it goes live.">
<input type="datetime-local" value={form.published_at} onChange={(event) => updateForm('published_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
<Field label="Unpublish At" help="Optional automatic sunset time for seasonal or editorial collections.">
<input type="datetime-local" value={form.unpublished_at} onChange={(event) => updateForm('unpublished_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
</div>
<AdvancedSection title="Description & Presentation" icon="fa-palette" defaultOpen={mode === 'edit'}>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Subtitle" help="Optional short line that sits under the title.">
<input
type="text"
value={form.subtitle}
onChange={(event) => updateForm('subtitle', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="A moody archive of midnight environments"
maxLength={160}
/>
</Field>
<Field label="Presentation Style">
<select value={form.presentation_style} onChange={(event) => updateForm('presentation_style', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="standard">Standard</option>
<option value="editorial_grid">Editorial Grid</option>
<option value="hero_grid">Hero Grid</option>
<option value="masonry">Masonry</option>
</select>
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Lifecycle State" help="Controls whether the collection should be treated as draft, scheduled, published, or retired.">
<Field label="Description" help="Describe the mood, focus, or story behind this showcase.">
<textarea
value={form.description}
onChange={(event) => updateForm('description', event.target.value)}
className="min-h-[128px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="A curated selection of pieces that share a common visual language…"
maxLength={1000}
/>
</Field>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Emphasis Mode">
<select value={form.emphasis_mode} onChange={(event) => updateForm('emphasis_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="cover_heavy">Cover Heavy</option>
<option value="balanced">Balanced</option>
<option value="artwork_first">Artwork First</option>
</select>
</Field>
<Field label="Theme">
<select value={form.theme_token} onChange={(event) => updateForm('theme_token', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="default">Default</option>
<option value="subtle-blue">Subtle Blue</option>
<option value="violet">Violet</option>
<option value="amber">Amber</option>
</select>
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2">
{!isSmartMode ? (
<Field label="Sort Order" help="Manual keeps the display order under your direct control.">
<select value={form.sort_mode} onChange={(event) => updateForm('sort_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="manual">Manual</option>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="popular">Most popular</option>
</select>
</Field>
) : (
<Field label="Match Mode" help="All rules must match, or any one rule is enough.">
<select
value={smartRules.match}
onChange={(event) => setSmartRules((current) => ({ ...current, match: event.target.value }))}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
<option value="all">All rules</option>
<option value="any">Any rule</option>
</select>
</Field>
)}
{!isSmartMode ? (
<Field label="Cover Artwork" help={attachedCoverOptions.length ? 'Choose a cover from artworks already attached to this collection.' : 'Attach artworks first to pick a manual cover.'}>
<select
value={form.cover_artwork_id}
onChange={(event) => updateForm('cover_artwork_id', event.target.value)}
disabled={!attachedCoverOptions.length}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 disabled:cursor-not-allowed disabled:text-slate-500"
>
<option value="">Automatic cover</option>
{attachedCoverOptions.map((artwork) => (
<option key={artwork.id} value={artwork.id}>{artwork.title}</option>
))}
</select>
</Field>
) : (
<Field label="Smart Sort" help="How matching artworks should be ordered in this collection.">
<select
value={smartRules.sort}
onChange={(event) => {
setSmartRules((current) => ({ ...current, sort: event.target.value }))
updateForm('sort_mode', event.target.value)
}}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
{(smartRuleOptions?.sort_options || []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field>
)}
</div>
</AdvancedSection>
<AdvancedSection title="Collaboration & Access" icon="fa-user-group" defaultOpen={mode === 'edit'}>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Collection Type">
<select value={form.type} onChange={(event) => updateForm('type', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="personal">Personal</option>
<option value="community">Community</option>
<option value="editorial">Editorial</option>
</select>
</Field>
<Field label="Collaboration Mode">
<select value={form.collaboration_mode} onChange={(event) => updateForm('collaboration_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="closed">Closed curated by you only</option>
<option value="invite_only">Invite only</option>
<option value="open">Open submissions</option>
</select>
</Field>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.allow_submissions} onChange={(event) => updateForm('allow_submissions', event.target.checked)} />
Allow submissions
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.allow_comments} onChange={(event) => updateForm('allow_comments', event.target.checked)} />
Allow comments
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.allow_saves} onChange={(event) => updateForm('allow_saves', event.target.checked)} />
Allow saves
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.commercial_eligibility} onChange={(event) => updateForm('commercial_eligibility', event.target.checked)} />
Commercially eligible
</label>
</div>
{form.type === 'editorial' ? (
<div className="grid gap-5 md:grid-cols-3">
<Field label="Editorial Owner" help="Choose whether this editorial lives under the current curator, another staff account, or the system identity.">
<select value={form.editorial_owner_mode} onChange={(event) => updateForm('editorial_owner_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="creator">Current curator</option>
<option value="staff_account">Staff account</option>
<option value="system">System editorial identity</option>
</select>
</Field>
{form.editorial_owner_mode === 'staff_account' ? (
<Field label="Staff Account Username" help="Must be an admin or moderator username.">
<input
type="text"
value={form.editorial_owner_username}
onChange={(event) => updateForm('editorial_owner_username', event.target.value.trimStart())}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="skinbase-editorial"
maxLength={60}
/>
</Field>
) : null}
{form.editorial_owner_mode === 'system' ? (
<Field label="System Owner Label" help="Public-facing label for system-owned editorials.">
<input
type="text"
value={form.editorial_owner_label}
onChange={(event) => updateForm('editorial_owner_label', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="Skinbase Editorial"
maxLength={120}
/>
</Field>
) : null}
</div>
) : null}
</AdvancedSection>
<AdvancedSection title="Campaign & Events" icon="fa-bullhorn" defaultOpen={mode === 'edit'}>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Event Key" help="Internal identifier used by discovery and promotion logic.">
<input type="text" value={form.event_key} onChange={(event) => updateForm('event_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Event Label">
<input type="text" value={form.event_label} onChange={(event) => updateForm('event_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
<Field label="Season Key" help="Groups related collections by season on landing surfaces.">
<input type="text" value={form.season_key} onChange={(event) => updateForm('season_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Badge Label" help="Short public badge on cards and headers.">
<input type="text" value={form.badge_label} onChange={(event) => updateForm('badge_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Campaign Key" help="Operational identifier for recommendation and placement logic.">
<input type="text" value={form.campaign_key} onChange={(event) => updateForm('campaign_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Campaign Label" help="Public-facing campaign or promotion label.">
<input type="text" value={form.campaign_label} onChange={(event) => updateForm('campaign_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
<Field label="Spotlight Style" help="Controls the visual frame for the public campaign banner.">
<select value={form.spotlight_style} onChange={(event) => updateForm('spotlight_style', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="default">Default</option>
<option value="editorial">Editorial</option>
<option value="seasonal">Seasonal</option>
<option value="challenge">Challenge</option>
<option value="community">Community</option>
</select>
</Field>
<Field label="Banner Text" help="Short line shown in the collection spotlight banner.">
<input type="text" value={form.banner_text} onChange={(event) => updateForm('banner_text', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={200} />
</Field>
</div>
</AdvancedSection>
<AdvancedSection title="Series" icon="fa-layer-group" defaultOpen={mode === 'edit'}>
<div className="grid gap-5 md:grid-cols-3">
<Field label="Series Key" help="Use the same key across all linked collections in a series.">
<input type="text" value={form.series_key} onChange={(event) => updateForm('series_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Series Title" help="Optional public heading shown for the whole series.">
<input type="text" value={form.series_title} onChange={(event) => updateForm('series_title', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={160} />
</Field>
<Field label="Series Order" help="Sequence position for public next & previous navigation.">
<input type="number" min="1" max="9999" value={form.series_order} onChange={(event) => updateForm('series_order', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
</div>
<Field label="Series Description" help="Optional public intro shown on series landing pages.">
<textarea
value={form.series_description}
onChange={(event) => updateForm('series_description', event.target.value)}
className="min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
maxLength={400}
/>
</Field>
</AdvancedSection>
<AdvancedSection title="Scheduling & Lifecycle" icon="fa-calendar-days" defaultOpen={mode === 'edit'}>
<Field label="Lifecycle State" help="Draft keeps it hidden. Published makes it live. Archived retires it from active surfaces.">
<select value={form.lifecycle_state} onChange={(event) => updateForm('lifecycle_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
@@ -1979,226 +2204,72 @@ export default function CollectionManage() {
<option value="expired">Expired</option>
</select>
</Field>
<Field label="Presentation Style">
<select value={form.presentation_style} onChange={(event) => updateForm('presentation_style', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="standard">Standard</option>
<option value="editorial_grid">Editorial Grid</option>
<option value="hero_grid">Hero Grid</option>
<option value="masonry">Masonry</option>
</select>
</Field>
<Field label="Emphasis Mode">
<select value={form.emphasis_mode} onChange={(event) => updateForm('emphasis_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="cover_heavy">Cover Heavy</option>
<option value="balanced">Balanced</option>
<option value="artwork_first">Artwork First</option>
</select>
</Field>
<Field label="Theme Token">
<select value={form.theme_token} onChange={(event) => updateForm('theme_token', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="default">Default</option>
<option value="subtle-blue">Subtle Blue</option>
<option value="violet">Violet</option>
<option value="amber">Amber</option>
</select>
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Series Key" help="Use the same key across linked collections in a series.">
<input type="text" value={form.series_key} onChange={(event) => updateForm('series_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Series Title" help="Optional public heading for the whole series.">
<input type="text" value={form.series_title} onChange={(event) => updateForm('series_title', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={160} />
</Field>
<Field label="Series Order" help="Sequence within the series for public next and previous navigation.">
<input type="number" min="1" max="9999" value={form.series_order} onChange={(event) => updateForm('series_order', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
<Field label="Campaign Key" help="Operational campaign identifier for discovery, placements, and recommendations.">
<input type="text" value={form.campaign_key} onChange={(event) => updateForm('campaign_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Campaign Label" help="Public-facing campaign or promotion label.">
<input type="text" value={form.campaign_label} onChange={(event) => updateForm('campaign_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Publish At" help="Leave empty to publish immediately. A future time keeps it off public surfaces until it goes live.">
<input type="datetime-local" value={form.published_at} onChange={(event) => updateForm('published_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
<Field label="Unpublish At" help="Optional automatic sunset time for seasonal or editorial collections.">
<input type="datetime-local" value={form.unpublished_at} onChange={(event) => updateForm('unpublished_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
</div>
<Field label="Series Description" help="Optional public intro shown on series landing pages and collection series callouts.">
<textarea
value={form.series_description}
onChange={(event) => updateForm('series_description', event.target.value)}
className="min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
maxLength={400}
/>
</Field>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Archive At" help="Optional timestamp for moving the collection to long-term archive workflows.">
<input type="datetime-local" value={form.archived_at} onChange={(event) => updateForm('archived_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
<Field label="Expire At" help="Optional hard expiry for promotional or seasonal collections.">
<input type="datetime-local" value={form.expired_at} onChange={(event) => updateForm('expired_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
</div>
</AdvancedSection>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Archive At" help="Optional timestamp for moving the collection into long-term archive workflows.">
<input type="datetime-local" value={form.archived_at} onChange={(event) => updateForm('archived_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
<Field label="Expire At" help="Optional hard expiry for promotional or seasonal collections.">
<input type="datetime-local" value={form.expired_at} onChange={(event) => updateForm('expired_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
<Field label="Promotion Tier">
<input type="text" value={form.promotion_tier} onChange={(event) => updateForm('promotion_tier', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={40} />
</Field>
<Field label="Monetization Status">
<input type="text" value={form.monetization_ready_status} onChange={(event) => updateForm('monetization_ready_status', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={40} />
</Field>
</div>
<AdvancedSection title="Commercial & Administration" icon="fa-briefcase" defaultOpen={mode === 'edit'}>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Promotion Tier">
<input type="text" value={form.promotion_tier} onChange={(event) => updateForm('promotion_tier', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={40} />
</Field>
<Field label="Monetization Status">
<input type="text" value={form.monetization_ready_status} onChange={(event) => updateForm('monetization_ready_status', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={40} />
</Field>
<Field label="Sponsorship Label">
<input type="text" value={form.sponsorship_label} onChange={(event) => updateForm('sponsorship_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
<Field label="Partner Label">
<input type="text" value={form.partner_label} onChange={(event) => updateForm('partner_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Sponsorship Label">
<input type="text" value={form.sponsorship_label} onChange={(event) => updateForm('sponsorship_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
<Field label="Partner Label">
<input type="text" value={form.partner_label} onChange={(event) => updateForm('partner_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
<Field label="Brand Safe Status">
<input type="text" value={form.brand_safe_status} onChange={(event) => updateForm('brand_safe_status', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={40} />
</Field>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.analytics_enabled} onChange={(event) => updateForm('analytics_enabled', event.target.checked)} />
Analytics enabled
</label>
</div>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Brand Safe Status">
<input type="text" value={form.brand_safe_status} onChange={(event) => updateForm('brand_safe_status', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={40} />
</Field>
<label className="flex items-center gap-3 self-end rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.analytics_enabled} onChange={(event) => updateForm('analytics_enabled', event.target.checked)} />
Analytics enabled
</label>
</div>
<Field label="Editorial Notes" help="Internal editorial context for campaign planning, curation rationale, and staff handoff.">
<textarea
value={form.editorial_notes}
onChange={(event) => updateForm('editorial_notes', event.target.value)}
className="min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
maxLength={2000}
/>
</Field>
{canModerate ? (
<Field label="Staff Commercial Notes" help="Internal admin-only notes for sponsorship readiness, partner handling, and commercial review.">
<Field label="Editorial Notes" help="Internal editorial context for campaign planning, curation rationale, and staff handoff.">
<textarea
value={form.staff_commercial_notes}
onChange={(event) => updateForm('staff_commercial_notes', event.target.value)}
className="min-h-[96px] w-full rounded-2xl border border-amber-300/15 bg-amber-400/10 px-4 py-3 text-white outline-none transition focus:border-amber-300/35 focus:bg-amber-400/15"
value={form.editorial_notes}
onChange={(event) => updateForm('editorial_notes', event.target.value)}
className="min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
maxLength={2000}
/>
</Field>
) : null}
{form.type === 'editorial' ? (
<div className="grid gap-5 md:grid-cols-3">
<Field label="Editorial Owner Mode" help="Choose whether this editorial lives under the current curator, another staff account, or the system editorial identity.">
<select value={form.editorial_owner_mode} onChange={(event) => updateForm('editorial_owner_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="creator">Current curator</option>
<option value="staff_account">Staff account</option>
<option value="system">System editorial identity</option>
</select>
{canModerate ? (
<Field label="Staff Commercial Notes" help="Admin-only notes for sponsorship readiness, partner handling, and commercial review.">
<textarea
value={form.staff_commercial_notes}
onChange={(event) => updateForm('staff_commercial_notes', event.target.value)}
className="min-h-[96px] w-full rounded-2xl border border-amber-300/15 bg-amber-400/10 px-4 py-3 text-white outline-none transition focus:border-amber-300/35 focus:bg-amber-400/15"
maxLength={2000}
/>
</Field>
{form.editorial_owner_mode === 'staff_account' ? (
<Field label="Staff Account Username" help="Must be an admin or moderator username.">
<input
type="text"
value={form.editorial_owner_username}
onChange={(event) => updateForm('editorial_owner_username', event.target.value.trimStart())}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="skinbase-editorial"
maxLength={60}
/>
</Field>
) : null}
{form.editorial_owner_mode === 'system' ? (
<Field label="System Owner Label" help="Public-facing label for system-owned editorials.">
<input
type="text"
value={form.editorial_owner_label}
onChange={(event) => updateForm('editorial_owner_label', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="Skinbase Editorial"
maxLength={120}
/>
</Field>
) : null}
</div>
) : null}
<div className="grid gap-3 md:grid-cols-3">
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.allow_submissions} onChange={(event) => updateForm('allow_submissions', event.target.checked)} />
Allow submissions
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.allow_comments} onChange={(event) => updateForm('allow_comments', event.target.checked)} />
Allow comments
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.allow_saves} onChange={(event) => updateForm('allow_saves', event.target.checked)} />
Allow saves
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.commercial_eligibility} onChange={(event) => updateForm('commercial_eligibility', event.target.checked)} />
Commercially eligible
</label>
</div>
<div className="grid gap-5 md:grid-cols-3">
<Field label="Visibility">
<select value={form.visibility} onChange={(event) => updateForm('visibility', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="private">Private</option>
</select>
</Field>
{!isSmartMode ? (
<Field label="Sort Mode" help="Manual keeps the display order under your control.">
<select value={form.sort_mode} onChange={(event) => updateForm('sort_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="manual">Manual</option>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="popular">Popular</option>
</select>
</Field>
) : (
<Field label="Match Mode" help="All means every rule must match. Any is broader.">
<select
value={smartRules.match}
onChange={(event) => setSmartRules((current) => ({ ...current, match: event.target.value }))}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
<option value="all">All rules</option>
<option value="any">Any rule</option>
</select>
</Field>
)}
{!isSmartMode ? (
<Field label="Cover Artwork" help={attachedCoverOptions.length ? 'Choose a cover from artworks already attached to this collection.' : 'Attach artworks first to pick a manual cover.'}>
<select
value={form.cover_artwork_id}
onChange={(event) => updateForm('cover_artwork_id', event.target.value)}
disabled={!attachedCoverOptions.length}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 disabled:cursor-not-allowed disabled:text-slate-500"
>
<option value="">Automatic cover</option>
{attachedCoverOptions.map((artwork) => (
<option key={artwork.id} value={artwork.id}>{artwork.title}</option>
))}
</select>
</Field>
) : (
<Field label="Smart Sort" help="How matching artworks should be ordered.">
<select
value={smartRules.sort}
onChange={(event) => {
setSmartRules((current) => ({ ...current, sort: event.target.value }))
updateForm('sort_mode', event.target.value)
}}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
{(smartRuleOptions?.sort_options || []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field>
)}
</div>
) : null}
</AdvancedSection>
<div className="flex flex-wrap items-center gap-3">
<button
@@ -2337,13 +2408,13 @@ export default function CollectionManage() {
) : null}
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">v4 Guidance</p>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Quick Start</p>
<div className="mt-4 space-y-3 text-sm leading-relaxed text-slate-300">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
Manual collections work best for hand-picked sequences, premium presentation modes, and campaign landing pages.
Start with just a title everything else can be filled in later. The advanced sections are there when you need them.
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
Smart collections are ideal for creator-first rulesets that keep series or editorial shelves fresh without cross-user leakage.
<span className="font-semibold text-white">Manual</span> is great for hand-picked storytelling. <span className="font-semibold text-white">Smart</span> keeps a collection up to date automatically using rules you define.
</div>
</div>
</section>

View File

@@ -44,14 +44,31 @@ function TypeBadge({ collection }) {
return <span className="inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{label}</span>
}
const COLLABORATOR_ROLE_COLORS = {
owner: 'border-amber-300/20 bg-amber-400/10 text-amber-200',
moderator: 'border-sky-300/20 bg-sky-400/10 text-sky-200',
contributor: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-200',
curator: 'border-violet-300/20 bg-violet-400/10 text-violet-200',
}
function CollaboratorCard({ member }) {
const roleColor = COLLABORATOR_ROLE_COLORS[String(member?.role || '').toLowerCase()] ?? 'border-white/10 bg-white/[0.05] text-slate-300'
return (
<a href={member?.user?.profile_url || '#'} className="flex items-center gap-3 rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 transition hover:bg-white/[0.07]">
<img src={member?.user?.avatar_url} alt={member?.user?.name || member?.user?.username} className="h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" />
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{member?.user?.name || member?.user?.username}</div>
<div className="truncate text-xs uppercase tracking-[0.16em] text-slate-400">{member?.role} {member?.status === 'pending' ? '• invited' : ''}</div>
<a href={member?.user?.profile_url || '#'} className="group flex items-center gap-4 rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 transition hover:border-white/20 hover:bg-white/[0.07]">
{member?.user?.avatar_url ? (
<img src={member.user.avatar_url} alt={member?.user?.name || member?.user?.username} className="h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10 transition group-hover:ring-sky-400/30" />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.05] text-slate-400">
<i className="fa-solid fa-user" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white group-hover:text-sky-100">{member?.user?.name || member?.user?.username}</div>
{member?.user?.username ? <div className="text-xs text-slate-500">@{member.user.username}</div> : null}
</div>
<span className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] ${roleColor}`}>
{member?.role}{member?.status === 'pending' ? ' · invited' : ''}
</span>
</a>
)
}
@@ -80,25 +97,43 @@ function SubmissionCard({ submission, onApprove, onReject, onWithdraw, onReport
)
}
const METAROW_TONES = {
'fa-images': { icon: 'text-sky-300', bg: 'bg-sky-400/10 border-sky-300/20', bar: 'from-sky-400/60' },
'fa-heart': { icon: 'text-rose-300', bg: 'bg-rose-400/10 border-rose-300/20', bar: 'from-rose-400/60' },
'fa-bell': { icon: 'text-emerald-300', bg: 'bg-emerald-400/10 border-emerald-300/20', bar: 'from-emerald-400/60' },
'fa-eye': { icon: 'text-violet-300', bg: 'bg-violet-400/10 border-violet-300/20', bar: 'from-violet-400/60' },
'fa-bookmark': { icon: 'text-amber-300', bg: 'bg-amber-400/10 border-amber-300/20', bar: 'from-amber-400/60' },
'fa-panorama': { icon: 'text-slate-300', bg: 'bg-white/[0.05] border-white/10', bar: 'from-slate-400/40' },
'fa-gauge-high': { icon: 'text-teal-300', bg: 'bg-teal-400/10 border-teal-300/20', bar: 'from-teal-400/60' },
'fa-ranking-star': { icon: 'text-amber-300', bg: 'bg-amber-400/10 border-amber-300/20', bar: 'from-amber-400/60' },
'fa-bullhorn': { icon: 'text-orange-300', bg: 'bg-orange-400/10 border-orange-300/20', bar: 'from-orange-400/60' },
}
function MetaRow({ icon, label, value, compact = false }) {
const title = `${label}: ${value}`
const tone = METAROW_TONES[icon] ?? { icon: 'text-slate-300', bg: 'bg-white/[0.05] border-white/10', bar: 'from-slate-400/40' }
if (compact) {
return (
<div
className="flex min-w-0 flex-col items-center rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-4 text-center"
className="relative overflow-hidden flex min-w-0 flex-col items-center rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-4 text-center transition-colors hover:bg-white/[0.07]"
title={title}
aria-label={title}
>
<i className={`fa-solid ${icon} text-base text-slate-300`} />
<div className="mt-3 text-xl font-semibold text-white">{value}</div>
<div className={`absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r ${tone.bar} via-transparent to-transparent`} />
<div className={`flex h-9 w-9 items-center justify-center rounded-xl border ${tone.bg}`}>
<i className={`fa-solid ${icon} text-sm ${tone.icon}`} />
</div>
<div className="mt-2 text-xl font-bold tabular-nums text-white">{value}</div>
<div className="mt-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</div>
</div>
)
}
return (
<div className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3" title={title}>
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 transition-colors hover:bg-white/[0.07]" title={title}>
<div className={`absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r ${tone.bar} via-transparent to-transparent`} />
<div className={`flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] ${tone.icon}`}>
<i className={`fa-solid ${icon} text-[10px]`} />
{label}
</div>
@@ -107,6 +142,143 @@ function MetaRow({ icon, label, value, compact = false }) {
)
}
const HERO_ACTION_TONES = {
neutral: {
idle: 'border-white/10 bg-white/[0.05] text-white hover:border-white/20 hover:bg-white/[0.09]',
active: 'border-white/15 bg-[linear-gradient(135deg,rgba(255,255,255,0.12),rgba(255,255,255,0.04))] text-white shadow-[0_18px_40px_rgba(2,6,23,0.18)]',
icon: 'border-white/10 bg-white/[0.08] text-slate-200',
iconActive: 'border-white/15 bg-white/[0.12] text-white',
},
rose: {
idle: 'border-white/10 bg-white/[0.05] text-white hover:border-rose-400/30 hover:bg-rose-400/[0.12] hover:text-rose-100',
active: 'border-rose-400/30 bg-[linear-gradient(135deg,rgba(244,63,94,0.18),rgba(255,255,255,0.06))] text-rose-50 shadow-[0_18px_40px_rgba(244,63,94,0.14)]',
icon: 'border-rose-300/15 bg-rose-400/[0.08] text-rose-200',
iconActive: 'border-rose-300/30 bg-rose-400/[0.16] text-rose-50',
},
emerald: {
idle: 'border-white/10 bg-white/[0.05] text-white hover:border-emerald-400/30 hover:bg-emerald-400/[0.12] hover:text-emerald-100',
active: 'border-emerald-400/30 bg-[linear-gradient(135deg,rgba(52,211,153,0.18),rgba(255,255,255,0.06))] text-emerald-50 shadow-[0_18px_40px_rgba(52,211,153,0.14)]',
icon: 'border-emerald-300/15 bg-emerald-400/[0.08] text-emerald-200',
iconActive: 'border-emerald-300/30 bg-emerald-400/[0.16] text-emerald-50',
},
violet: {
idle: 'border-white/10 bg-white/[0.05] text-white hover:border-violet-400/30 hover:bg-violet-400/[0.12] hover:text-violet-100',
active: 'border-violet-400/30 bg-[linear-gradient(135deg,rgba(167,139,250,0.18),rgba(255,255,255,0.06))] text-violet-50 shadow-[0_18px_40px_rgba(167,139,250,0.14)]',
icon: 'border-violet-300/15 bg-violet-400/[0.08] text-violet-200',
iconActive: 'border-violet-300/30 bg-violet-400/[0.16] text-violet-50',
},
sky: {
idle: 'border-white/10 bg-white/[0.05] text-white hover:border-sky-400/30 hover:bg-sky-400/[0.12] hover:text-sky-100',
active: 'border-sky-400/30 bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(255,255,255,0.06))] text-sky-50 shadow-[0_18px_40px_rgba(56,189,248,0.14)]',
icon: 'border-sky-300/15 bg-sky-400/[0.08] text-sky-200',
iconActive: 'border-sky-300/30 bg-sky-400/[0.16] text-sky-50',
},
amber: {
idle: 'border-white/10 bg-white/[0.05] text-white hover:border-amber-400/30 hover:bg-amber-400/[0.12] hover:text-amber-100',
active: 'border-amber-400/30 bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(255,255,255,0.06))] text-amber-50 shadow-[0_18px_40px_rgba(251,191,36,0.14)]',
icon: 'border-amber-300/15 bg-amber-400/[0.08] text-amber-200',
iconActive: 'border-amber-300/30 bg-amber-400/[0.16] text-amber-50',
},
}
function CollectionHeroAction({ href = null, onClick = null, icon, label, tone = 'neutral', active = false, disabled = false, compact = false }) {
const toneClasses = HERO_ACTION_TONES[tone] ?? HERO_ACTION_TONES.neutral
const Component = href ? 'a' : 'button'
const componentProps = href
? { href }
: { type: 'button', onClick, disabled }
return (
<Component
{...componentProps}
className={`group inline-flex items-center justify-center gap-3 rounded-[20px] border px-4 ${compact ? 'py-3' : 'py-3.5'} text-sm font-semibold tracking-[-0.01em] transition duration-200 ${active ? toneClasses.active : toneClasses.idle} ${disabled ? 'cursor-not-allowed opacity-60' : ''}`}
>
<span className={`flex h-9 w-9 items-center justify-center rounded-2xl border transition ${active ? toneClasses.iconActive : toneClasses.icon}`}>
<i className={`fa-solid ${icon} text-sm`} />
</span>
<span>{label}</span>
</Component>
)
}
const HERO_METRIC_TONES = {
sky: {
icon: 'text-sky-200',
chip: 'border-sky-300/20 bg-sky-400/[0.12]',
glow: 'from-sky-400/35',
orb: 'bg-sky-400/20',
},
rose: {
icon: 'text-rose-200',
chip: 'border-rose-300/20 bg-rose-400/[0.12]',
glow: 'from-rose-400/35',
orb: 'bg-rose-400/20',
},
emerald: {
icon: 'text-emerald-200',
chip: 'border-emerald-300/20 bg-emerald-400/[0.12]',
glow: 'from-emerald-400/35',
orb: 'bg-emerald-400/20',
},
violet: {
icon: 'text-violet-200',
chip: 'border-violet-300/20 bg-violet-400/[0.12]',
glow: 'from-violet-400/35',
orb: 'bg-violet-400/20',
},
amber: {
icon: 'text-amber-200',
chip: 'border-amber-300/20 bg-amber-400/[0.12]',
glow: 'from-amber-400/35',
orb: 'bg-amber-400/20',
},
slate: {
icon: 'text-slate-200',
chip: 'border-white/10 bg-white/[0.08]',
glow: 'from-white/20',
orb: 'bg-white/[0.12]',
},
}
function HeroMetricCard({ icon, label, value, helper = null, tone = 'slate' }) {
const style = HERO_METRIC_TONES[tone] ?? HERO_METRIC_TONES.slate
return (
<div className="relative overflow-hidden rounded-[26px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(15,23,42,0.32))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
<div className={`absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r ${style.glow} via-transparent to-transparent`} />
<div className={`absolute -right-5 top-3 h-14 w-14 rounded-full blur-2xl ${style.orb}`} />
<div className="relative z-10">
<div className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${style.chip}`}>
<i className={`fa-solid ${icon} text-base ${style.icon}`} />
</div>
<div className="mt-4 text-[2rem] font-black leading-none tracking-[-0.04em] text-white">{value}</div>
<div className="mt-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{label}</div>
{helper ? <div className="mt-1 text-xs text-slate-400">{helper}</div> : null}
</div>
</div>
)
}
function HeroSignalCard({ icon, label, value, description = null, tone = 'slate' }) {
const style = HERO_METRIC_TONES[tone] ?? HERO_METRIC_TONES.slate
return (
<div className="relative overflow-hidden rounded-[24px] border border-white/10 bg-[linear-gradient(135deg,rgba(255,255,255,0.06),rgba(15,23,42,0.32))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
<div className={`absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r ${style.glow} via-transparent to-transparent`} />
<div className="relative z-10 flex items-start gap-3">
<div className={`mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border ${style.chip}`}>
<i className={`fa-solid ${icon} text-base ${style.icon}`} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-black tracking-[-0.04em] text-white">{value}</div>
{description ? <p className="mt-2 text-sm leading-relaxed text-slate-400">{description}</p> : null}
</div>
</div>
</div>
)
}
function getSpotlightClasses(style) {
switch (style) {
case 'editorial':
@@ -146,36 +318,59 @@ function OwnerCard({ owner, collectionType }) {
: 'Curator'
const body = (
<>
{owner?.avatar_url ? (
<img src={owner.avatar_url} alt={owner?.name || owner?.username} className="h-14 w-14 rounded-2xl object-cover ring-1 ring-white/10" />
) : (
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-400">
<i className="fa-solid fa-user-astronaut" />
<div className="flex items-center gap-4">
<div className="relative shrink-0">
{owner?.avatar_url ? (
<img src={owner.avatar_url} alt={owner?.name || owner?.username} className="h-14 w-14 rounded-2xl object-cover ring-2 ring-white/10" />
) : (
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-slate-400">
<i className="fa-solid fa-user-astronaut text-xl" />
</div>
)}
<div className="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full border border-white/10 bg-sky-500 text-white">
<i className="fa-solid fa-pen-nib text-[8px]" />
</div>
)}
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
<div className="mt-1 text-lg font-semibold text-white">{owner?.name || owner?.username || 'Skinbase Curator'}</div>
</div>
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
<div className="mt-0.5 text-lg font-semibold text-white">{owner?.name || owner?.username || 'Skinbase Curator'}</div>
{owner?.username ? <div className="text-sm text-slate-400">@{owner.username}</div> : null}
</div>
</>
{owner?.profile_url ? <i className="fa-solid fa-arrow-up-right-from-square ml-auto shrink-0 text-slate-500 text-sm" /> : null}
</div>
)
if (owner?.profile_url) {
return <a href={owner.profile_url} className="mt-6 inline-flex items-center gap-4 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4 transition hover:bg-white/[0.07]">{body}</a>
return (
<div className="mt-7 overflow-hidden rounded-[26px] border border-white/10 bg-[linear-gradient(135deg,rgba(255,255,255,0.06),rgba(15,23,42,0.34))] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] transition hover:border-sky-400/25 hover:bg-[linear-gradient(135deg,rgba(255,255,255,0.08),rgba(15,23,42,0.4))]">
<div className="h-[2px] bg-gradient-to-r from-sky-400/55 via-sky-400/20 to-transparent" />
<a href={owner.profile_url} className="block px-5 py-4">{body}</a>
</div>
)
}
return <div className="mt-6 inline-flex items-center gap-4 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">{body}</div>
return (
<div className="mt-7 overflow-hidden rounded-[26px] border border-white/10 bg-[linear-gradient(135deg,rgba(255,255,255,0.06),rgba(15,23,42,0.34))] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
<div className="h-[2px] bg-gradient-to-r from-sky-400/55 via-sky-400/20 to-transparent" />
<div className="px-5 py-4">{body}</div>
</div>
)
}
function PageSection({ eyebrow, title, count, children }) {
function PageSection({ eyebrow, title, count, icon, children }) {
return (
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{eyebrow}</p>
<h2 className="mt-2 text-2xl font-semibold text-white">{title}</h2>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
{icon && (
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.05] text-sky-300">
<i className={`fa-solid ${icon} text-sm`} />
</div>
)}
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{eyebrow}</p>
<h2 className="mt-1 text-2xl font-semibold text-white">{title}</h2>
</div>
</div>
{count !== undefined ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{count}</span> : null}
</div>
@@ -268,14 +463,29 @@ function recommendationReasons(currentCollection, candidate) {
return reasons.slice(0, 3)
}
const CONTEXT_SIGNAL_TYPES = {
Campaign: { icon: 'fa-solid fa-bullhorn', accent: 'border-orange-300/20 from-orange-400/50', badge: 'border-orange-300/20 bg-orange-400/10 text-orange-200', kicker: 'text-orange-300/80' },
Event: { icon: 'fa-solid fa-calendar-star', accent: 'border-sky-300/20 from-sky-400/50', badge: 'border-sky-300/20 bg-sky-400/10 text-sky-200', kicker: 'text-sky-300/80' },
Program: { icon: 'fa-solid fa-layer-group', accent: 'border-violet-300/20 from-violet-400/50', badge: 'border-violet-300/20 bg-violet-400/10 text-violet-200', kicker: 'text-violet-300/80' },
Theme: { icon: 'fa-solid fa-palette', accent: 'border-teal-300/20 from-teal-400/50', badge: 'border-teal-300/20 bg-teal-400/10 text-teal-200', kicker: 'text-teal-300/80' },
'Quality Tier': { icon: 'fa-solid fa-gauge-high', accent: 'border-amber-300/20 from-amber-400/50', badge: 'border-amber-300/20 bg-amber-400/10 text-amber-200', kicker: 'text-amber-300/80' },
}
function ContextSignalCard({ item }) {
const wrapperClassName = 'flex h-full flex-col gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] p-5 transition hover:bg-white/[0.07]'
const typeStyle = CONTEXT_SIGNAL_TYPES[item.meta] ?? { icon: 'fa-solid fa-circle-info', accent: 'border-white/10 from-slate-400/30', badge: 'border-white/10 bg-white/[0.05] text-slate-300', kicker: 'text-sky-200/80' }
const wrapperClassName = `relative overflow-hidden flex h-full flex-col gap-3 rounded-[24px] border ${typeStyle.accent.split(' ')[0]} bg-white/[0.04] p-5 transition hover:bg-white/[0.07]`
const body = (
<>
<div className={`absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r ${typeStyle.accent.split(' ')[1]} via-transparent to-transparent`} />
<div className="flex items-center justify-between gap-3">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item.meta}</span>
{item.kicker ? <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/80">{item.kicker}</span> : null}
<div className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded-xl border ${typeStyle.badge}`}>
<i className={`${typeStyle.icon} text-[11px]`} />
</div>
<span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${typeStyle.badge}`}>{item.meta}</span>
</div>
{item.kicker ? <span className={`text-[11px] font-semibold uppercase tracking-[0.16em] ${typeStyle.kicker}`}>{item.kicker}</span> : null}
</div>
<div>
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
@@ -442,6 +652,73 @@ export default function CollectionShow() {
const key = `${item.meta}:${item.title}:${item.subtitle || ''}`
return items.findIndex((candidate) => `${candidate.meta}:${candidate.title}:${candidate.subtitle || ''}` === key) === index
})
const heroMetrics = [
{
icon: 'fa-images',
label: 'Artworks',
value: (collection?.artworks_count ?? 0).toLocaleString(),
helper: showArtworkAuthors && featuringCreatorsCount > 1 ? `${featuringCreatorsCount} creators featured` : (collection?.mode === 'smart' ? 'Matched works' : 'Published pieces'),
tone: 'sky',
},
{
icon: 'fa-heart',
label: 'Likes',
value: (collection?.likes_count ?? 0).toLocaleString(),
helper: 'Community response',
tone: 'rose',
},
{
icon: 'fa-bell',
label: 'Followers',
value: (collection?.followers_count ?? 0).toLocaleString(),
helper: 'Watching updates',
tone: 'emerald',
},
{
icon: 'fa-eye',
label: 'Views',
value: (collection?.views_count ?? 0).toLocaleString(),
helper: 'Detail visits',
tone: 'violet',
},
{
icon: 'fa-bookmark',
label: 'Saves',
value: (collection?.saves_count ?? 0).toLocaleString(),
helper: 'Pinned for later',
tone: 'amber',
},
]
const heroSignals = [
collection?.quality_score != null ? {
icon: 'fa-gauge-high',
label: 'Quality',
value: Number(collection.quality_score).toFixed(1),
description: collection?.trust_tier ? `${humanizeToken(collection.trust_tier)} placement tier` : 'Placement quality signal',
tone: 'emerald',
} : null,
collection?.ranking_score != null ? {
icon: 'fa-ranking-star',
label: 'Ranking',
value: Number(collection.ranking_score).toFixed(1),
description: 'Current discovery momentum score',
tone: 'amber',
} : null,
collection?.presentation_style && collection.presentation_style !== 'standard' ? {
icon: 'fa-panorama',
label: 'Presentation',
value: humanizeToken(collection.presentation_style),
description: 'Visual treatment for this collection surface',
tone: 'sky',
} : null,
collection?.campaign_key ? {
icon: 'fa-bullhorn',
label: 'Campaign',
value: collection.campaign_label || humanizeToken(collection.campaign_key),
description: 'Programmed into a campaign surface',
tone: 'rose',
} : null,
].filter(Boolean)
const { share } = useWebShare({
onFallback: async ({ url }) => {
@@ -609,7 +886,7 @@ export default function CollectionShow() {
if (!artworkItems.length) return null
return (
<PageSection eyebrow="Highlights" title="Featured artworks" count={Math.min(artworkItems.length, 3)}>
<PageSection icon="fa-star" eyebrow="Highlights" title="Featured artworks" count={Math.min(artworkItems.length, 3)}>
<div className="space-y-4">
<p className="text-sm leading-relaxed text-slate-300">
Start with the standout pieces from this collection before diving into the full sequence.
@@ -624,7 +901,7 @@ export default function CollectionShow() {
if (collection?.type !== 'editorial') return null
return (
<PageSection eyebrow="Editorial" title="Editorial context">
<PageSection icon="fa-pen-nib" eyebrow="Editorial" title="Editorial context">
<div className="space-y-3 text-sm leading-relaxed text-slate-300">
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">
{collection?.description || 'A staff-curated collection prepared for premium discovery placement.'}
@@ -659,7 +936,7 @@ export default function CollectionShow() {
if (!collection?.allow_comments) return null
return (
<PageSection eyebrow="Discussion" title="Collection comments" count={(collection?.comments_count ?? comments.length).toLocaleString()}>
<PageSection icon="fa-comments" eyebrow="Discussion" title="Collection comments" count={(collection?.comments_count ?? comments.length).toLocaleString()}>
{canComment ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4"><CommentForm onSubmit={handleCommentSubmit} placeholder="Talk about the curation, mood, or standout pieces…" submitLabel="Post comment" /></div> : null}
<div className={canComment ? 'mt-5' : ''}>
<CommentList comments={comments} canReply={false} onDelete={handleDeleteComment} onReport={(comment) => handleReport('collection_comment', comment.id)} emptyMessage="No comments yet." />
@@ -672,7 +949,7 @@ export default function CollectionShow() {
if (!Array.isArray(relatedCollections) || !relatedCollections.length) return null
return (
<PageSection eyebrow="More to Explore" title="Related collections">
<PageSection icon="fa-layer-group" eyebrow="More to Explore" title="Related collections">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{relatedCollections.map((item) => (
<div key={item.id} className="space-y-3">
@@ -693,7 +970,7 @@ export default function CollectionShow() {
if (module.key === 'collaborators') {
return (
<PageSection eyebrow="Contributors" title="Curation team">
<PageSection icon="fa-users" eyebrow="Contributors" title="Curation team">
<div className="space-y-3">
{members.length ? members.filter((member) => member?.status === 'active').map((member) => <CollaboratorCard key={member.id} member={member} />) : <p className="text-sm text-slate-400">This collection is curated by a single owner right now.</p>}
</div>
@@ -705,7 +982,7 @@ export default function CollectionShow() {
if (!collection?.allow_submissions) return null
return (
<PageSection eyebrow="Submissions" title="Submit to this collection">
<PageSection icon="fa-paper-plane" eyebrow="Submissions" title="Submit to this collection">
{canSubmit && submissionArtworkOptions?.length ? (
<div className="space-y-3">
<select value={selectedArtworkId} onChange={(event) => setSelectedArtworkId(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
@@ -761,15 +1038,24 @@ export default function CollectionShow() {
{isOwner && historyUrl ? <a href={historyUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white"><i className="fa-solid fa-timeline fa-fw text-[11px]" />History</a> : null}
</div>
<section className="mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm">
<div className="grid gap-6 p-5 md:p-7 xl:grid-cols-[minmax(0,1.2fr)_420px]">
<div className="relative overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/60">
<section className="mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] shadow-[0_30px_90px_rgba(2,6,23,0.32)] backdrop-blur-xl">
{/* Per-type accent top stripe */}
<div className={`h-[3px] bg-gradient-to-r ${
collection?.type === 'editorial' ? 'from-amber-400/80 via-amber-400/30 to-transparent' :
collection?.type === 'community' ? 'from-emerald-400/80 via-emerald-400/30 to-transparent' :
collection?.mode === 'smart' ? 'from-sky-400/80 via-sky-400/30 to-transparent' :
'from-violet-400/80 via-violet-400/30 to-transparent'
}`} />
<div className="grid items-start gap-6 p-5 md:p-7 xl:grid-cols-[minmax(0,1.2fr)_420px]">
<div className="relative self-start overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/60">
<CollectionCover collection={collection} />
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgba(2,6,23,0.8),rgba(2,6,23,0.08))]" />
</div>
<div className="flex flex-col justify-between">
<div>
<div className="relative overflow-hidden rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_28%),radial-gradient(circle_at_90%_8%,rgba(251,191,36,0.14),transparent_24%),linear-gradient(180deg,rgba(15,23,42,0.94),rgba(10,18,32,0.92))] px-5 py-6 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] md:px-6 md:py-7">
<div aria-hidden="true" className="pointer-events-none absolute -left-14 top-10 h-36 w-36 rounded-full bg-sky-400/10 blur-3xl" />
<div aria-hidden="true" className="pointer-events-none absolute -right-10 bottom-8 h-32 w-32 rounded-full bg-amber-300/10 blur-3xl" />
<div className="relative z-10 flex h-full flex-col justify-between">
{collection?.banner_text ? (
<div className={`mb-4 inline-flex max-w-full items-center gap-2 rounded-[22px] border px-4 py-3 text-sm font-medium shadow-[0_18px_40px_rgba(2,6,23,0.2)] ${spotlightClasses}`}>
<i className="fa-solid fa-sparkles text-[12px]" />
@@ -786,50 +1072,65 @@ export default function CollectionShow() {
{collection?.series_key ? <span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">Series {collection.series_order ? `#${collection.series_order}` : ''}</span> : null}
{isOwner ? <CollectionVisibilityBadge visibility={collection?.visibility} /> : null}
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white md:text-5xl">{collection?.title}</h1>
<h1 className="mt-4 max-w-3xl text-4xl font-black tracking-[-0.06em] text-white md:text-5xl xl:text-[4rem] xl:leading-[0.92]">{collection?.title}</h1>
{showIntroBlock ? (
<>
{collection?.subtitle ? <p className="mt-3 text-base text-slate-300">{collection.subtitle}</p> : null}
{collection?.description ? <p className="mt-4 max-w-2xl text-sm leading-relaxed text-slate-300 md:text-[15px]">{collection.description}</p> : <p className="mt-4 max-w-2xl text-sm leading-relaxed text-slate-400 md:text-[15px]">A curated selection from @{owner?.username}, assembled as a focused gallery rather than a simple archive.</p>}
{collection?.smart_summary ? <p className="mt-3 max-w-2xl text-sm leading-relaxed text-sky-100/90">{collection.smart_summary}</p> : null}
{collection?.subtitle ? <p className="mt-3 text-lg text-slate-300 md:text-xl">{collection.subtitle}</p> : null}
{collection?.summary || collection?.description ? <p className="mt-4 max-w-2xl text-sm leading-relaxed text-slate-300 md:text-[15px]">{collection?.summary || collection?.description}</p> : <p className="mt-4 max-w-2xl text-sm leading-relaxed text-slate-400 md:text-[15px]">A curated selection from @{owner?.username}, assembled as a focused gallery rather than a simple archive.</p>}
{collection?.smart_summary ? <div className="mt-4 max-w-2xl rounded-[22px] border border-sky-300/15 bg-sky-400/[0.07] px-4 py-3 text-sm leading-relaxed text-sky-100/90">{collection.smart_summary}</div> : null}
{featuringCreatorsCount > 1 ? <p className="mt-3 text-sm text-slate-300">Featuring artworks by {featuringCreatorsCount} creators.</p> : null}
</>
) : null}
<div className="mt-6 flex flex-wrap gap-3">
<button type="button" onClick={handleLike} disabled={state.busy || !engagement?.like_url} className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${state.liked ? 'border-rose-400/20 bg-rose-400/10 text-rose-100' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}><i className={`fa-solid ${state.liked ? 'fa-heart' : 'fa-heart-circle-plus'} fa-fw`} />{state.liked ? 'Liked' : 'Like Collection'}</button>
<button type="button" onClick={handleFollow} disabled={state.busy || !engagement?.follow_url} className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${state.following ? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-100' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}><i className={`fa-solid ${state.following ? 'fa-bell' : 'fa-bell-concierge'} fa-fw`} />{state.following ? 'Following' : 'Follow Collection'}</button>
<button type="button" onClick={handleSave} disabled={state.busy || (!engagement?.save_url && !engagement?.unsave_url)} className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${state.saved ? 'border-violet-300/20 bg-violet-400/10 text-violet-100' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}><i className={`fa-solid ${state.saved ? 'fa-bookmark' : 'fa-bookmark-circle'} fa-fw`} />{state.saved ? 'Saved' : 'Save Collection'}</button>
<button type="button" onClick={handleShare} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-share-nodes fa-fw" />Share</button>
{reportEndpoint && !isOwner ? <button type="button" onClick={() => handleReport('collection', collection?.id)} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-flag fa-fw" />Report</button> : null}
{featuredCollectionsUrl ? <a href={featuredCollectionsUrl} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-compass fa-fw" />Featured Collections</a> : null}
<div className="mt-7 space-y-3">
<div className="flex flex-wrap gap-3">
<CollectionHeroAction onClick={handleLike} disabled={state.busy || !engagement?.like_url} icon="fa-heart" label={state.liked ? 'Liked' : 'Like'} tone="rose" active={state.liked} />
<CollectionHeroAction onClick={handleFollow} disabled={state.busy || !engagement?.follow_url} icon="fa-bell" label={state.following ? 'Following' : 'Follow'} tone="emerald" active={state.following} />
<CollectionHeroAction onClick={handleSave} disabled={state.busy || (!engagement?.save_url && !engagement?.unsave_url)} icon="fa-bookmark" label={state.saved ? 'Saved' : 'Save'} tone="violet" active={state.saved} />
</div>
<div className="flex flex-wrap gap-3">
<CollectionHeroAction onClick={handleShare} icon="fa-share-nodes" label="Share" tone="neutral" compact />
{featuredCollectionsUrl ? <CollectionHeroAction href={featuredCollectionsUrl} icon="fa-compass" label="Explore" tone="sky" compact /> : null}
{reportEndpoint && !isOwner ? <CollectionHeroAction onClick={() => handleReport('collection', collection?.id)} icon="fa-flag" label="Report" tone="amber" compact /> : null}
</div>
</div>
{state.notice ? <p className="mt-3 text-sm text-sky-100">{state.notice}</p> : null}
<div className="mt-6 grid gap-3 sm:grid-cols-3 xl:grid-cols-5">
<MetaRow compact icon="fa-images" label="Artworks" value={(collection?.artworks_count ?? 0).toLocaleString()} />
<MetaRow compact icon="fa-heart" label="Likes" value={(collection?.likes_count ?? 0).toLocaleString()} />
<MetaRow compact icon="fa-bell" label="Followers" value={(collection?.followers_count ?? 0).toLocaleString()} />
<MetaRow compact icon="fa-eye" label="Views" value={(collection?.views_count ?? 0).toLocaleString()} />
<MetaRow compact icon="fa-bookmark" label="Saves" value={(collection?.saves_count ?? 0).toLocaleString()} />
</div>
{(collection?.presentation_style && collection.presentation_style !== 'standard') || collection?.quality_score != null || collection?.ranking_score != null || collection?.campaign_key ? (
<div className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{collection?.presentation_style && collection.presentation_style !== 'standard' ? <MetaRow icon="fa-panorama" label="Presentation" value={String(collection.presentation_style).replace(/_/g, ' ')} /> : null}
{collection?.quality_score != null ? <MetaRow icon="fa-gauge-high" label="Quality" value={Number(collection.quality_score).toFixed(1)} /> : null}
{collection?.ranking_score != null ? <MetaRow icon="fa-ranking-star" label="Ranking" value={Number(collection.ranking_score).toFixed(1)} /> : null}
{collection?.campaign_key ? <MetaRow icon="fa-bullhorn" label="Campaign" value={collection.campaign_label || collection.campaign_key} /> : null}
</div>
) : null}
<OwnerCard owner={owner} collectionType={collection?.type} />
</div>
<OwnerCard owner={owner} collectionType={collection?.type} />
</div>
</div>
</section>
{(heroMetrics.length || heroSignals.length) ? (
<section className="mt-6 rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_20px_70px_rgba(2,6,23,0.22)] backdrop-blur-xl md:p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collection Snapshot</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Stats and placement signals</h2>
</div>
<p className="max-w-xl text-sm leading-relaxed text-slate-400">The engagement counters and ranking signals now live outside the hero so the header can stay focused on the artwork, title, and actions.</p>
</div>
<div className="mt-6 grid gap-4 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,0.95fr)]">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
{heroMetrics.map((item) => (
<HeroMetricCard key={item.label} icon={item.icon} label={item.label} value={item.value} helper={item.helper} tone={item.tone} />
))}
</div>
{heroSignals.length ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-1">
{heroSignals.map((item) => (
<HeroSignalCard key={item.label} icon={item.icon} label={item.label} value={item.value} description={item.description} tone={item.tone} />
))}
</div>
) : null}
</div>
</section>
) : null}
{(seriesContext?.url || seriesContext?.previous || seriesContext?.next || (Array.isArray(seriesContext?.siblings) && seriesContext.siblings.length)) ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -880,9 +1181,14 @@ export default function CollectionShow() {
{contextSignals.length ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Related Context</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Campaign, event, and quality context</h2>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-amber-300/15 bg-amber-400/10 text-amber-300">
<i className="fa-solid fa-diagram-project text-sm" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Related Context</p>
<h2 className="mt-1 text-2xl font-semibold text-white">Campaign, event, and quality context</h2>
</div>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{contextSignals.length}</span>
</div>
@@ -898,9 +1204,14 @@ export default function CollectionShow() {
{storyLinks.length ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Stories</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Stories and editorial references linked to this collection</h2>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-lime-300/15 bg-lime-400/10 text-lime-300">
<i className="fa-solid fa-book-open text-sm" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Stories</p>
<h2 className="mt-1 text-2xl font-semibold text-white">Stories and editorial references linked to this collection</h2>
</div>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{storyLinks.length}</span>
</div>
@@ -916,9 +1227,14 @@ export default function CollectionShow() {
{taxonomyLinks.length ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-violet-200/80">Browse The Theme</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Categories and tags that anchor this collection</h2>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-violet-300/15 bg-violet-400/10 text-violet-300">
<i className="fa-solid fa-tags text-sm" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-violet-200/80">Browse The Theme</p>
<h2 className="mt-1 text-2xl font-semibold text-white">Categories and tags that anchor this collection</h2>
</div>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{taxonomyLinks.length}</span>
</div>
@@ -934,9 +1250,14 @@ export default function CollectionShow() {
{contributorLinks.length ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Connected Creators</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Creators and artworks that give the set its shape</h2>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-300/15 bg-sky-400/10 text-sky-300">
<i className="fa-solid fa-user-group text-sm" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Connected Creators</p>
<h2 className="mt-1 text-2xl font-semibold text-white">Creators and artworks that give the set its shape</h2>
</div>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{contributorLinks.length}</span>
</div>

View File

@@ -7,6 +7,22 @@ function normalizeText(value) {
return String(value || '').trim().toLowerCase()
}
const MEMBER_ROLE_COLORS = {
owner: { badge: 'border-amber-300/25 bg-amber-400/10 text-amber-100', icon: 'fa-crown', iconColor: 'text-amber-300' },
admin: { badge: 'border-sky-300/25 bg-sky-400/10 text-sky-100', icon: 'fa-shield-halved', iconColor: 'text-sky-300' },
editor: { badge: 'border-violet-300/25 bg-violet-400/10 text-violet-100', icon: 'fa-pen-nib', iconColor: 'text-violet-300' },
contributor: { badge: 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100', icon: 'fa-star', iconColor: 'text-emerald-300' },
}
const POST_TYPE_ICONS = {
announcement: { icon: 'fa-bullhorn', bar: 'from-sky-400/80 to-sky-300/30', bg: 'bg-sky-400/10', border: 'border-sky-300/20', text: 'text-sky-200' },
update: { icon: 'fa-rotate', bar: 'from-emerald-400/80 to-emerald-300/30', bg: 'bg-emerald-400/10', border: 'border-emerald-300/20', text: 'text-emerald-200' },
event: { icon: 'fa-calendar-days', bar: 'from-violet-400/80 to-violet-300/30', bg: 'bg-violet-400/10', border: 'border-violet-300/20', text: 'text-violet-200' },
news: { icon: 'fa-newspaper', bar: 'from-amber-400/80 to-amber-300/30', bg: 'bg-amber-400/10', border: 'border-amber-300/20', text: 'text-amber-200' },
discussion: { icon: 'fa-comments', bar: 'from-rose-400/80 to-rose-300/30', bg: 'bg-rose-400/10', border: 'border-rose-300/20', text: 'text-rose-200' },
tutorial: { icon: 'fa-graduation-cap', bar: 'from-teal-400/80 to-teal-300/30', bg: 'bg-teal-400/10', border: 'border-teal-300/20', text: 'text-teal-200' },
}
function formatCompactNumber(value) {
return Number(value ?? 0).toLocaleString()
}
@@ -323,17 +339,37 @@ function GroupHero({
function ArtworkGrid({ artworks, emptyLabel = 'No artworks yet.' }) {
if (!Array.isArray(artworks) || artworks.length === 0) {
return <p className="mt-5 text-sm text-slate-400">{emptyLabel}</p>
return (
<div className="mt-5 flex flex-col items-center gap-3 rounded-[24px] border border-white/8 bg-white/[0.02] py-10 text-center">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500">
<i className="fa-solid fa-images text-2xl" />
</span>
<p className="text-sm text-slate-400">{emptyLabel}</p>
</div>
)
}
return (
<div className="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{artworks.map((artwork) => (
<a key={artwork.id} href={artwork.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 transition hover:border-white/20">
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="aspect-[4/3] w-full object-cover" /> : null}
<a key={artwork.id} href={artwork.url} className="group relative overflow-hidden rounded-[24px] border border-white/10 bg-black/20 transition hover:border-sky-300/30 hover:shadow-[0_8px_32px_rgba(56,189,248,0.08)]">
{artwork.thumb ? (
<div className="relative overflow-hidden aspect-[4/3]">
<img src={artwork.thumb} alt={artwork.title} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" />
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 transition group-hover:opacity-100" />
</div>
) : (
<div className="flex aspect-[4/3] items-center justify-center bg-white/[0.03] text-slate-500">
<i className="fa-solid fa-image text-3xl" />
</div>
)}
<div className="p-4">
<h3 className="text-base font-semibold text-white">{artwork.title}</h3>
<p className="mt-1 text-sm text-slate-400">{artwork.author}</p>
{artwork.author ? (
<span className="mt-2 inline-flex items-center gap-1.5 rounded-full border border-white/8 bg-white/[0.04] px-2.5 py-1 text-[11px] font-medium text-slate-300">
<i className="fa-solid fa-user-pen fa-fw text-slate-500" />{artwork.author}
</span>
) : null}
</div>
</a>
))}
@@ -343,19 +379,37 @@ function ArtworkGrid({ artworks, emptyLabel = 'No artworks yet.' }) {
function CollectionGrid({ collections, emptyLabel = 'No collections yet.' }) {
if (!Array.isArray(collections) || collections.length === 0) {
return <p className="mt-5 text-sm text-slate-400">{emptyLabel}</p>
return (
<div className="mt-5 flex flex-col items-center gap-3 rounded-[24px] border border-white/8 bg-white/[0.02] py-10 text-center">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500">
<i className="fa-solid fa-layer-group text-2xl" />
</span>
<p className="text-sm text-slate-400">{emptyLabel}</p>
</div>
)
}
return (
<div className="mt-5 grid gap-4 md:grid-cols-2">
{collections.map((collection) => (
<a key={collection.id} href={collection.url} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-white">{collection.title}</h3>
<p className="mt-2 text-sm text-slate-300">{collection.summary || collection.description_excerpt || 'Collection'}</p>
<a key={collection.id} href={collection.url} className="group relative overflow-hidden rounded-[24px] border border-white/10 bg-black/20 p-5 transition hover:border-sky-300/25 hover:shadow-[0_6px_24px_rgba(56,189,248,0.07)]">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[24px] bg-gradient-to-r from-sky-400/70 via-cyan-300/50 to-transparent" />
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[14px] border border-sky-300/20 bg-sky-400/10 text-sky-200">
<i className="fa-solid fa-layer-group fa-fw text-sm" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-semibold text-white">{collection.title}</h3>
{collection.is_featured ? (
<span className="shrink-0 rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">Featured</span>
) : null}
</div>
<p className="mt-1.5 text-sm leading-6 text-slate-300">{collection.summary || collection.description_excerpt || 'Open to explore this collection.'}</p>
</div>
{collection.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">Featured</span> : null}
</div>
<div className="mt-2 flex justify-end">
<span className="text-xs font-semibold text-sky-300 opacity-0 transition group-hover:opacity-100">Browse <i className="fa-solid fa-arrow-right ml-0.5" /></span>
</div>
</a>
))}
@@ -365,18 +419,31 @@ function CollectionGrid({ collections, emptyLabel = 'No collections yet.' }) {
function CompactCardGrid({ items, emptyLabel, badgeKey = 'status' }) {
if (!Array.isArray(items) || items.length === 0) {
return <p className="mt-5 text-sm text-slate-400">{emptyLabel}</p>
return (
<div className="mt-5 flex flex-col items-center gap-3 rounded-[24px] border border-white/8 bg-white/[0.02] py-10 text-center">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500">
<i className="fa-solid fa-folder-open text-2xl" />
</span>
<p className="text-sm text-slate-400">{emptyLabel}</p>
</div>
)
}
return (
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<a key={item.id} href={item.url} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<a key={item.id} href={item.url} className="group relative overflow-hidden rounded-[24px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:shadow-[0_6px_24px_rgba(2,6,23,0.4)]">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[24px] bg-gradient-to-r from-violet-400/60 via-sky-300/40 to-transparent" />
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-white">{item.title}</h3>
{item[badgeKey] ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item[badgeKey]}</span> : null}
{item[badgeKey] ? (
<span className="shrink-0 rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item[badgeKey]}</span>
) : null}
</div>
<p className="mt-2 text-sm leading-6 text-slate-300">{item.summary || 'Open for more details.'}</p>
<div className="mt-3 flex justify-end">
<span className="text-xs font-semibold text-sky-300 opacity-0 transition group-hover:opacity-100">Open <i className="fa-solid fa-arrow-right ml-0.5" /></span>
</div>
</a>
))}
</div>
@@ -385,22 +452,46 @@ function CompactCardGrid({ items, emptyLabel, badgeKey = 'status' }) {
function ReleaseGrid({ releases, emptyLabel = 'No public releases yet.' }) {
if (!Array.isArray(releases) || releases.length === 0) {
return <p className="mt-5 text-sm text-slate-400">{emptyLabel}</p>
return (
<div className="mt-5 flex flex-col items-center gap-3 rounded-[24px] border border-white/8 bg-white/[0.02] py-10 text-center">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500">
<i className="fa-solid fa-rocket text-2xl" />
</span>
<p className="text-sm text-slate-400">{emptyLabel}</p>
</div>
)
}
return (
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="mt-5 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{releases.map((release) => (
<a key={release.id} href={release.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 transition hover:border-white/20">
{release.cover_url ? <img src={release.cover_url} alt={release.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-white/[0.03] text-slate-500"><i className="fa-solid fa-rocket text-2xl" /></div>}
<div className="p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.status}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.current_stage}</span>
<a key={release.id} href={release.url} className="group relative overflow-hidden rounded-[24px] border border-white/10 bg-black/20 transition hover:border-sky-300/25 hover:shadow-[0_8px_32px_rgba(56,189,248,0.08)]">
{release.cover_url ? (
<div className="relative overflow-hidden aspect-[4/3]">
<img src={release.cover_url} alt={release.title} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-3 left-3 flex flex-wrap gap-1.5">
{release.status ? <span className="rounded-full border border-white/15 bg-black/50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white backdrop-blur-sm">{release.status}</span> : null}
{release.current_stage ? <span className="rounded-full border border-sky-300/25 bg-sky-400/20 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100 backdrop-blur-sm">{release.current_stage}</span> : null}
</div>
</div>
) : (
<div className="relative flex aspect-[4/3] items-center justify-center bg-gradient-to-br from-sky-900/30 to-slate-900/40">
<i className="fa-solid fa-rocket text-3xl text-slate-400" />
<div className="absolute bottom-3 left-3 flex flex-wrap gap-1.5">
{release.status ? <span className="rounded-full border border-white/15 bg-black/50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white backdrop-blur-sm">{release.status}</span> : null}
{release.current_stage ? <span className="rounded-full border border-sky-300/25 bg-sky-400/20 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100 backdrop-blur-sm">{release.current_stage}</span> : null}
</div>
</div>
)}
<div className="p-5">
<h3 className="text-base font-semibold text-white">{release.title}</h3>
<p className="mt-1.5 text-sm leading-6 text-slate-300">{release.summary || 'Release overview and linked artworks.'}</p>
<div className="mt-3 flex items-center gap-4 text-xs text-slate-500">
<span><i className="fa-solid fa-images mr-1.5" />{release.counts?.artworks || 0}</span>
<span><i className="fa-solid fa-users mr-1.5" />{release.counts?.contributors || 0}</span>
<span><i className="fa-solid fa-flag-checkered mr-1.5" />{release.counts?.milestones || 0}</span>
</div>
<h3 className="mt-3 text-base font-semibold text-white">{release.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">{release.summary || 'Release overview and linked artworks.'}</p>
<div className="mt-3 text-xs text-slate-500">{release.counts?.artworks || 0} artworks {release.counts?.contributors || 0} contributors {release.counts?.milestones || 0} milestones</div>
</div>
</a>
))}
@@ -454,23 +545,37 @@ function LeadershipPreview({ leadership }) {
}
return (
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between gap-3">
<section className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-amber-400/70 via-yellow-300/40 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-amber-300/20 bg-amber-400/10 text-amber-200">
<i className="fa-solid fa-crown fa-fw" />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Leadership</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Owner and admins</h2>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/70">Leadership</p>
<h2 className="text-xl font-semibold text-white">Owner and admins</h2>
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
{leadership.map((member) => (
<a key={member.id} href={member.profile_url || '#'} className="flex items-center gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 transition hover:border-white/20">
{member.avatar_url ? <img src={member.avatar_url} alt={member.name || member.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
<div className="min-w-0">
<div className="truncate font-semibold text-white">{member.name || member.username}</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{member.role_label || member.role}</div>
</div>
</a>
))}
{leadership.map((member) => {
const roleKey = String(member.role || '').toLowerCase()
const roleStyle = MEMBER_ROLE_COLORS[roleKey] || MEMBER_ROLE_COLORS.contributor
return (
<a key={member.id} href={member.profile_url || '#'} className="group flex items-center gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 transition hover:border-white/20 hover:bg-white/[0.04]">
{member.avatar_url
? <img src={member.avatar_url} alt={member.name || member.username} className="h-12 w-12 shrink-0 rounded-2xl object-cover ring-1 ring-white/10 transition group-hover:ring-white/20" />
: <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>
}
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-white">{member.name || member.username}</div>
<div className={`mt-1 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${roleStyle.badge}`}>
<i className={`fa-solid ${roleStyle.icon} fa-fw text-[9px]`} />
{member.role_label || member.role}
</div>
</div>
</a>
)
})}
</div>
</section>
)
@@ -499,22 +604,46 @@ function TrustSignalPanel({ signals }) {
return null
}
const toneClasses = {
sky: 'border-sky-300/20 bg-sky-300/10 text-sky-100',
emerald: 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100',
amber: 'border-amber-300/20 bg-amber-300/10 text-amber-100',
violet: 'border-violet-300/20 bg-violet-300/10 text-violet-100',
const TONE_STYLES = {
sky: { badge: 'border-sky-300/20 bg-sky-300/10 text-sky-100', dot: 'bg-sky-400', bar: 'from-sky-400/70 to-transparent' },
emerald: { badge: 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100', dot: 'bg-emerald-400', bar: 'from-emerald-400/70 to-transparent' },
amber: { badge: 'border-amber-300/20 bg-amber-300/10 text-amber-100', dot: 'bg-amber-400', bar: 'from-amber-400/70 to-transparent' },
violet: { badge: 'border-violet-300/20 bg-violet-300/10 text-violet-100', dot: 'bg-violet-400', bar: 'from-violet-400/70 to-transparent' },
}
return (
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Trust signals</p>
<h2 className="mt-2 text-2xl font-semibold text-white">How this group shows up</h2>
<section className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-sky-400/70 via-cyan-300/40 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-sky-300/20 bg-sky-400/10 text-sky-200">
<i className="fa-solid fa-shield-check fa-fw" />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Trust signals</p>
<h2 className="text-xl font-semibold text-white">How this group shows up</h2>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{signals.map((signal) => <span key={signal.key} className={`rounded-full border px-3 py-2 text-sm font-semibold ${toneClasses[signal.tone] || 'border-white/10 bg-white/[0.04] text-white'}`}>{signal.label}</span>)}
{signals.map((signal) => {
const ts = TONE_STYLES[signal.tone] || { badge: 'border-white/10 bg-white/[0.04] text-white', dot: 'bg-slate-400' }
return (
<span key={signal.key} className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[11px] font-semibold ${ts.badge}`}>
<span className={`h-1.5 w-1.5 rounded-full ${ts.dot}`} />{signal.label}
</span>
)
})}
</div>
<div className="mt-5 space-y-3">
{signals.map((signal) => <div key={`${signal.key}-reason`} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="font-semibold text-white">{signal.label}</div><p className="mt-2 text-sm leading-6 text-slate-400">{signal.reason}</p></div>)}
{signals.map((signal) => {
const ts = TONE_STYLES[signal.tone] || { badge: 'border-white/10 bg-white/[0.02] text-white', bar: 'from-slate-400/40 to-transparent' }
return (
<div key={`${signal.key}-reason`} className="relative overflow-hidden rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
<div className={`absolute inset-x-0 top-0 h-[2px] rounded-t-2xl bg-gradient-to-r ${ts.bar}`} />
<div className={`text-sm font-semibold ${ts.badge.includes('text-') ? ts.badge.split(' ').find((c) => c.startsWith('text-')) : 'text-white'}`}>{signal.label}</div>
<p className="mt-1.5 text-sm leading-6 text-slate-400">{signal.reason}</p>
</div>
)
})}
</div>
</section>
)
@@ -526,17 +655,29 @@ function BadgeShowcase({ badges }) {
}
return (
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Badges</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Earned group signals</h2>
<section className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-amber-400/70 via-yellow-300/40 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-amber-300/20 bg-amber-400/10 text-amber-200">
<i className="fa-solid fa-medal fa-fw" />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Badges</p>
<h2 className="text-xl font-semibold text-white">Earned group signals</h2>
</div>
</div>
<div className="mt-5 grid gap-3">
{badges.map((badge) => (
<div key={badge.key} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
<div key={badge.key} className="relative overflow-hidden rounded-2xl border border-amber-300/10 bg-amber-400/5 px-4 py-4">
<div className="absolute inset-y-0 left-0 w-[3px] rounded-l-2xl bg-gradient-to-b from-amber-400/70 to-amber-300/20" />
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-white">{badge.label}</div>
<div className="flex items-center gap-2">
<i className="fa-solid fa-certificate text-amber-300/70" />
<div className="font-semibold text-white">{badge.label}</div>
</div>
{badge.awarded_at ? <div className="text-xs text-slate-500">{new Date(badge.awarded_at).toLocaleDateString()}</div> : null}
</div>
<p className="mt-2 text-sm leading-6 text-slate-400">{badge.reason}</p>
<p className="mt-1.5 text-sm leading-6 text-slate-400">{badge.reason}</p>
</div>
))}
</div>
@@ -550,21 +691,46 @@ function ContributorHighlights({ contributors }) {
}
return (
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Contributors</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Trusted collaborators</h2>
<section className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-emerald-400/70 via-teal-300/40 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-emerald-300/20 bg-emerald-400/10 text-emerald-200">
<i className="fa-solid fa-user-star fa-fw" />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200/70">Contributors</p>
<h2 className="text-xl font-semibold text-white">Trusted collaborators</h2>
</div>
</div>
<div className="mt-5 space-y-3">
{contributors.map((entry) => (
<a key={entry.user?.id} href={entry.user?.profile_url || '#'} className="flex gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 transition hover:border-white/20">
{entry.user?.avatar_url ? <img src={entry.user.avatar_url} alt={entry.user?.name || entry.user?.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
<a key={entry.user?.id} href={entry.user?.profile_url || '#'} className="group flex gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 transition hover:border-emerald-300/20 hover:bg-white/[0.04]">
{entry.user?.avatar_url
? <img src={entry.user.avatar_url} alt={entry.user?.name || entry.user?.username} className="h-12 w-12 shrink-0 rounded-2xl object-cover ring-1 ring-white/10 transition group-hover:ring-emerald-300/20" />
: <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>
}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate font-semibold text-white">{entry.user?.name || entry.user?.username}</div>
{entry.trusted_indicator ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Trusted</span> : null}
{entry.trusted_indicator ? (
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">
<i className="fa-solid fa-circle-check text-[9px]" />Trusted
</span>
) : null}
</div>
{entry.summary ? <p className="mt-1 text-sm text-slate-400">{entry.summary}</p> : null}
<div className="mt-2 text-xs text-slate-500">{entry.counts?.releases || 0} releases {entry.counts?.credited_artworks || 0} artworks {entry.counts?.projects || 0} projects</div>
{Array.isArray(entry.badges) && entry.badges.length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{entry.badges.slice(0, 3).map((badge) => <span key={`${entry.user?.id}-${badge.key}`} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{badge.label}</span>)}</div> : null}
<div className="mt-2 flex items-center gap-3 text-xs text-slate-500">
<span><i className="fa-solid fa-rocket mr-1" />{entry.counts?.releases || 0}</span>
<span><i className="fa-solid fa-images mr-1" />{entry.counts?.credited_artworks || 0}</span>
<span><i className="fa-solid fa-diagram-project mr-1" />{entry.counts?.projects || 0}</span>
</div>
{Array.isArray(entry.badges) && entry.badges.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{entry.badges.slice(0, 3).map((badge) => (
<span key={`${entry.user?.id}-${badge.key}`} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{badge.label}</span>
))}
</div>
) : null}
</div>
</a>
))}
@@ -744,58 +910,88 @@ export default function GroupShow() {
{section === 'overview' ? (
<div className="mt-8 grid gap-8">
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<section className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-amber-400/70 via-yellow-300/40 to-transparent" />
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Highlights</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Featured artworks</h2>
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-amber-300/20 bg-amber-400/10 text-amber-200">
<i className="fa-solid fa-star fa-fw" />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/70">Highlights</p>
<h2 className="text-xl font-semibold text-white">Featured artworks</h2>
</div>
</div>
<a href={`${group.urls?.public}/artworks`} className="text-sm font-semibold text-sky-200">Browse all</a>
<a href={`${group.urls?.public}/artworks`} className="text-sm font-semibold text-sky-200 transition hover:text-sky-100">Browse all</a>
</div>
<ArtworkGrid artworks={featuredArtworks} emptyLabel="No featured artworks yet." />
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<section className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-sky-400/70 via-cyan-300/40 to-transparent" />
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Latest work</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Latest artworks</h2>
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-sky-300/20 bg-sky-400/10 text-sky-200">
<i className="fa-solid fa-images fa-fw" />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Latest work</p>
<h2 className="text-xl font-semibold text-white">Latest artworks</h2>
</div>
</div>
<a href={`${group.urls?.public}/artworks`} className="text-sm font-semibold text-sky-200">View archive</a>
<a href={`${group.urls?.public}/artworks`} className="text-sm font-semibold text-sky-200 transition hover:text-sky-100">View archive</a>
</div>
<ArtworkGrid artworks={artworks.slice(0, 6)} emptyLabel="No published artworks yet." />
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<section className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-violet-400/70 via-sky-300/40 to-transparent" />
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Pipeline</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Recent releases</h2>
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-violet-300/20 bg-violet-400/10 text-violet-200">
<i className="fa-solid fa-rocket fa-fw" />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-violet-200/70">Pipeline</p>
<h2 className="text-xl font-semibold text-white">Recent releases</h2>
</div>
</div>
<a href={`${group.urls?.public}/releases`} className="text-sm font-semibold text-sky-200">View releases</a>
<a href={`${group.urls?.public}/releases`} className="text-sm font-semibold text-sky-200 transition hover:text-sky-100">View releases</a>
</div>
<ReleaseGrid releases={releases.slice(0, 3)} emptyLabel="No public releases yet." />
</section>
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<section className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-sky-400/70 via-cyan-300/40 to-transparent" />
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Curated</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Featured collections</h2>
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-sky-300/20 bg-sky-400/10 text-sky-200">
<i className="fa-solid fa-layer-group fa-fw" />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Curated</p>
<h2 className="text-xl font-semibold text-white">Featured collections</h2>
</div>
</div>
<a href={`${group.urls?.public}/collections`} className="text-sm font-semibold text-sky-200">View collections</a>
<a href={`${group.urls?.public}/collections`} className="text-sm font-semibold text-sky-200 transition hover:text-sky-100">View collections</a>
</div>
<CollectionGrid collections={featuredCollections.length > 0 ? featuredCollections : collections.slice(0, 2)} emptyLabel="No featured collections yet." />
</section>
<div className="grid gap-8">
{group.pinned_post ? (
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Pinned post</p>
<h2 className="mt-2 text-2xl font-semibold text-white">{group.pinned_post.title}</h2>
<p className="mt-4 text-sm leading-7 text-slate-300">{group.pinned_post.excerpt || 'Read the latest pinned update from this group.'}</p>
<a href={group.pinned_post.url} className="mt-4 inline-flex rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Read post</a>
<section className="relative overflow-hidden rounded-[30px] border border-amber-300/15 bg-amber-400/5 p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-amber-400/80 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-[14px] border border-amber-300/20 bg-amber-400/10 text-amber-200">
<i className="fa-solid fa-thumbtack fa-fw text-sm" />
</span>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Pinned post</p>
</div>
<h2 className="mt-3 text-xl font-semibold text-white">{group.pinned_post.title}</h2>
<p className="mt-3 text-sm leading-7 text-amber-50/80">{group.pinned_post.excerpt || 'Read the latest pinned update from this group.'}</p>
<a href={group.pinned_post.url} className="mt-4 inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15"><i className="fa-solid fa-book-open fa-fw" />Read post</a>
</section>
) : null}
@@ -817,26 +1013,44 @@ export default function GroupShow() {
</section>
) : null}
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Resources</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Shared downloads</h2>
<section className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-teal-400/70 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-[14px] border border-teal-300/20 bg-teal-400/10 text-teal-200">
<i className="fa-solid fa-download fa-fw text-sm" />
</span>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-teal-200/70">Resources</p>
</div>
<h2 className="mt-2 text-xl font-semibold text-white">Shared downloads</h2>
<AssetGrid assets={assets.slice(0, 3)} />
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Public feed</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Recent activity</h2>
<section className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-sky-400/70 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-[14px] border border-sky-300/20 bg-sky-400/10 text-sky-200">
<i className="fa-solid fa-bolt fa-fw text-sm" />
</span>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Public feed</p>
</div>
<h2 className="mt-2 text-xl font-semibold text-white">Recent activity</h2>
<ActivityFeed items={activity.slice(0, 4)} />
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">About</p>
<h2 className="mt-2 text-2xl font-semibold text-white">About {group.name}</h2>
<p className="mt-5 text-sm leading-7 text-slate-300">{group.bio || 'No long-form description yet.'}</p>
<div className="mt-5 flex flex-wrap gap-3 text-xs text-slate-400">
{group.founded_at ? <span>Founded {new Date(group.founded_at).toLocaleDateString()}</span> : null}
{group.type ? <span>{group.type}</span> : null}
{group.website_url ? <a href={group.website_url} className="text-sky-200 underline underline-offset-4">Website</a> : null}
<section className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-slate-400/50 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-[14px] border border-white/10 bg-white/[0.05] text-slate-300">
<i className="fa-solid fa-id-card fa-fw text-sm" />
</span>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400/80">About</p>
</div>
<h2 className="mt-2 text-xl font-semibold text-white">About {group.name}</h2>
<p className="mt-4 text-sm leading-7 text-slate-300">{group.bio || 'No long-form description yet.'}</p>
<div className="mt-4 flex flex-wrap gap-3 text-xs text-slate-400">
{group.founded_at ? <span className="inline-flex items-center gap-1.5"><i className="fa-solid fa-calendar-days text-slate-500" />Founded {new Date(group.founded_at).toLocaleDateString()}</span> : null}
{group.type ? <span className="inline-flex items-center gap-1.5"><i className="fa-solid fa-tag text-slate-500" />{group.type}</span> : null}
{group.website_url ? <a href={group.website_url} className="inline-flex items-center gap-1.5 text-sky-200 underline underline-offset-4 transition hover:text-sky-100"><i className="fa-solid fa-link" />Website</a> : null}
</div>
</section>
</div>
@@ -845,11 +1059,17 @@ export default function GroupShow() {
) : null}
{section === 'artworks' ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<section className="mt-8 relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-sky-400/70 via-cyan-300/40 to-transparent" />
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-2xl font-semibold text-white">Artworks</h2>
<p className="mt-2 text-sm text-slate-400">Filter the group archive by title or contributor credit label, then change the sort order.</p>
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-[14px] border border-sky-300/20 bg-sky-400/10 text-sky-200">
<i className="fa-solid fa-images fa-fw" />
</span>
<div>
<h2 className="text-2xl font-semibold text-white">Artworks</h2>
<p className="mt-1 text-sm text-slate-400">Filter the group archive by title or contributor credit label, then change the sort order.</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
@@ -871,113 +1091,234 @@ export default function GroupShow() {
) : null}
{section === 'collections' ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-2xl font-semibold text-white">Collections</h2>
<section className="mt-8 relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-sky-400/70 via-cyan-300/40 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-sky-300/20 bg-sky-400/10 text-sky-200">
<i className="fa-solid fa-layer-group fa-fw" />
</span>
<h2 className="text-2xl font-semibold text-white">Collections</h2>
</div>
<CollectionGrid collections={collections} emptyLabel="No collections yet." />
</section>
) : null}
{section === 'posts' ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-2xl font-semibold text-white">Posts</h2>
<div className="mt-5 grid gap-4 md:grid-cols-2">
{posts.length > 0 ? posts.map((post) => (
<a key={post.id} href={post.url} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{post.type}</div>
{post.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">Pinned</span> : null}
</div>
<h3 className="mt-2 text-lg font-semibold text-white">{post.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">{post.excerpt || 'Open the post to read more.'}</p>
</a>
)) : <p className="text-sm text-slate-400">No posts published yet.</p>}
<div className="mt-8">
<div className="mb-6 flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-sky-300/20 bg-sky-400/10 text-sky-200">
<i className="fa-solid fa-newspaper fa-fw" />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Group updates</p>
<h2 className="text-2xl font-semibold text-white">Posts</h2>
</div>
</div>
</section>
{posts.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{posts.map((post) => {
const typeKey = String(post.type || '').toLowerCase()
const typeStyle = POST_TYPE_ICONS[typeKey] || POST_TYPE_ICONS.announcement
return (
<a key={post.id} href={post.url} className="group relative overflow-hidden rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:shadow-[0_8px_24px_rgba(2,6,23,0.4)]">
<div className={`absolute inset-x-0 top-0 h-[3px] rounded-t-[28px] bg-gradient-to-r ${typeStyle.bar}`} />
<div className="flex items-start gap-4">
<span className={`shrink-0 inline-flex h-10 w-10 items-center justify-center rounded-[14px] border ${typeStyle.border} ${typeStyle.bg} ${typeStyle.text}`}>
<i className={`fa-solid ${typeStyle.icon} fa-fw`} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={`text-[10px] font-semibold uppercase tracking-[0.18em] ${typeStyle.text}`}>{post.type || 'post'}</span>
{post.is_pinned ? (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300/20 bg-amber-400/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">
<i className="fa-solid fa-thumbtack text-[9px]" />Pinned
</span>
) : null}
</div>
<h3 className="mt-1.5 text-base font-semibold text-white">{post.title}</h3>
<p className="mt-1.5 text-sm leading-6 text-slate-300">{post.excerpt || 'Open the post to read more.'}</p>
</div>
</div>
<div className="mt-3 flex justify-end">
<span className="text-xs font-semibold text-sky-300 opacity-0 transition group-hover:opacity-100">Read post <i className="fa-solid fa-arrow-right ml-0.5" /></span>
</div>
</a>
)
})}
</div>
) : (
<div className="flex flex-col items-center gap-4 rounded-[32px] border border-white/8 bg-white/[0.02] py-16 text-center">
<span className="inline-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-newspaper text-3xl" />
</span>
<div>
<p className="text-sm font-semibold text-slate-300">No posts published yet.</p>
<p className="mt-1 text-xs text-slate-500">Check back later for group updates and announcements.</p>
</div>
</div>
)}
</div>
) : null}
{section === 'projects' ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-2xl font-semibold text-white">Projects</h2>
<p className="mt-2 text-sm text-slate-400">Structured releases, collaboration hubs, and production pages published by this group.</p>
<section className="mt-8 relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-violet-400/70 via-sky-300/40 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-violet-300/20 bg-violet-400/10 text-violet-200">
<i className="fa-solid fa-diagram-project fa-fw" />
</span>
<div>
<h2 className="text-2xl font-semibold text-white">Projects</h2>
<p className="mt-1 text-sm text-slate-400">Structured releases, collaboration hubs, and production pages published by this group.</p>
</div>
</div>
<CompactCardGrid items={projects} emptyLabel="No public projects yet." />
</section>
) : null}
{section === 'releases' ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-2xl font-semibold text-white">Releases</h2>
<p className="mt-2 text-sm text-slate-400">Published drops, milestone pipelines, and linked showcases from this group.</p>
<section className="mt-8 relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-sky-400/70 via-cyan-300/40 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-sky-300/20 bg-sky-400/10 text-sky-200">
<i className="fa-solid fa-rocket fa-fw" />
</span>
<div>
<h2 className="text-2xl font-semibold text-white">Releases</h2>
<p className="mt-1 text-sm text-slate-400">Published drops, milestone pipelines, and linked showcases from this group.</p>
</div>
</div>
<ReleaseGrid releases={releases} emptyLabel="No public releases yet." />
</section>
) : null}
{section === 'challenges' ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-2xl font-semibold text-white">Challenges</h2>
<p className="mt-2 text-sm text-slate-400">Current and past prompts, internal sprints, and public-facing challenge runs.</p>
<section className="mt-8 relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-amber-400/70 via-yellow-300/40 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-amber-300/20 bg-amber-400/10 text-amber-200">
<i className="fa-solid fa-trophy fa-fw" />
</span>
<div>
<h2 className="text-2xl font-semibold text-white">Challenges</h2>
<p className="mt-1 text-sm text-slate-400">Current and past prompts, internal sprints, and public-facing challenge runs.</p>
</div>
</div>
<CompactCardGrid items={challenges} emptyLabel="No public challenges yet." />
</section>
) : null}
{section === 'events' ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-2xl font-semibold text-white">Events</h2>
<p className="mt-2 text-sm text-slate-400">Launches, milestones, streams, and other moments on the group timeline.</p>
<section className="mt-8 relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-violet-400/70 via-sky-300/40 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-violet-300/20 bg-violet-400/10 text-violet-200">
<i className="fa-solid fa-calendar-days fa-fw" />
</span>
<div>
<h2 className="text-2xl font-semibold text-white">Events</h2>
<p className="mt-1 text-sm text-slate-400">Launches, milestones, streams, and other moments on the group timeline.</p>
</div>
</div>
<CompactCardGrid items={events} emptyLabel="No public events yet." badgeKey="event_type" />
</section>
) : null}
{section === 'activity' ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-2xl font-semibold text-white">Activity</h2>
<p className="mt-2 text-sm text-slate-400">Public milestones from posts, releases, events, member changes, and challenge highlights.</p>
<section className="mt-8 relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-sky-400/70 via-cyan-300/40 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-sky-300/20 bg-sky-400/10 text-sky-200">
<i className="fa-solid fa-bolt fa-fw" />
</span>
<div>
<h2 className="text-2xl font-semibold text-white">Activity</h2>
<p className="mt-1 text-sm text-slate-400">Public milestones from posts, releases, events, member changes, and challenge highlights.</p>
</div>
</div>
<ActivityFeed items={activity} />
</section>
) : null}
{section === 'members' ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-2xl font-semibold text-white">Members</h2>
<div className="mt-6 grid gap-8">
<div className="mt-8">
<div className="mb-6 flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-sky-300/20 bg-sky-400/10 text-sky-200">
<i className="fa-solid fa-users fa-fw" />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Community</p>
<h2 className="text-2xl font-semibold text-white">Members</h2>
</div>
</div>
<div className="grid gap-6">
{[
['Owner', groupedMembers.owner],
['Admins', groupedMembers.admins],
['Editors', groupedMembers.editors],
['Contributors', groupedMembers.contributors],
].map(([label, bucket]) => (
bucket.length > 0 ? (
<section key={label}>
<div className="flex items-center justify-between gap-3">
['Owner', 'owner', groupedMembers.owner],
['Admins', 'admin', groupedMembers.admins],
['Editors', 'editor', groupedMembers.editors],
['Contributors', 'contributor', groupedMembers.contributors],
].map(([label, roleKey, bucket]) => {
if (bucket.length === 0) return null
const roleStyle = MEMBER_ROLE_COLORS[roleKey] || MEMBER_ROLE_COLORS.contributor
return (
<section key={label} className="relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className={`absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r ${roleKey === 'owner' ? 'from-amber-400/70 to-transparent' : roleKey === 'admin' ? 'from-sky-400/70 to-transparent' : roleKey === 'editor' ? 'from-violet-400/70 to-transparent' : 'from-emerald-400/70 to-transparent'}`} />
<div className="flex items-center gap-3">
<span className={`inline-flex h-8 w-8 items-center justify-center rounded-xl border ${roleStyle.badge}`}>
<i className={`fa-solid ${roleStyle.icon} fa-fw text-sm ${roleStyle.iconColor}`} />
</span>
<h3 className="text-lg font-semibold text-white">{label}</h3>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{bucket.length}</span>
<span className="ml-auto rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{bucket.length}</span>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{bucket.map((member) => (
<a key={member.id} href={member.user?.profile_url || '#'} className="flex items-center gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 transition hover:border-white/20">
{member.user?.avatar_url ? <img src={member.user.avatar_url} alt={member.user.name || member.user.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
<a key={member.id} href={member.user?.profile_url || '#'} className="group flex items-center gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 transition hover:border-white/20 hover:bg-white/[0.04]">
{member.user?.avatar_url
? <img src={member.user.avatar_url} alt={member.user.name || member.user.username} className="h-12 w-12 shrink-0 rounded-2xl object-cover ring-1 ring-white/10 transition group-hover:ring-white/20" />
: <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>
}
<div className="min-w-0">
<div className="truncate font-semibold text-white">{member.user?.name || member.user?.username}</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{member.role_label || member.role}</div>
<div className={`mt-1 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${roleStyle.badge}`}>
<i className={`fa-solid ${roleStyle.icon} fa-fw text-[9px]`} />
{member.role_label || member.role}
</div>
</div>
</a>
))}
</div>
</section>
) : null
))}
)
})}
</div>
</section>
</div>
) : null}
{section === 'about' ? (
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-2xl font-semibold text-white">About</h2>
<section className="mt-8 relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-slate-400/50 to-transparent" />
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-white/10 bg-white/[0.05] text-slate-300">
<i className="fa-solid fa-id-card fa-fw" />
</span>
<h2 className="text-2xl font-semibold text-white">About</h2>
</div>
<div className="mt-5 space-y-4 text-sm leading-7 text-slate-300">
<p>{group.bio || 'No long-form description yet.'}</p>
{group.website_url ? <p><a href={group.website_url} className="text-sky-200 underline underline-offset-4">{group.website_url}</a></p> : null}
{Array.isArray(group.links) && group.links.length > 0 ? <div className="flex flex-wrap gap-3">{group.links.map((link) => <a key={`${link.label}-${link.url}`} href={link.url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">{link.label}</a>)}</div> : null}
{group.founded_at ? <p>Founded: {new Date(group.founded_at).toLocaleDateString()}</p> : null}
{group.type ? <p>Type: {group.type}</p> : null}
{group.website_url ? <p><a href={group.website_url} className="inline-flex items-center gap-1.5 text-sky-200 underline underline-offset-4 transition hover:text-sky-100"><i className="fa-solid fa-link" />{group.website_url}</a></p> : null}
{Array.isArray(group.links) && group.links.length > 0 ? (
<div className="flex flex-wrap gap-3">
{group.links.map((link) => (
<a key={`${link.label}-${link.url}`} href={link.url} className="inline-flex items-center gap-2 rounded-[14px] border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i className="fa-solid fa-arrow-up-right-from-square text-slate-400" />{link.label}
</a>
))}
</div>
) : null}
<div className="mt-4 flex flex-wrap gap-4 border-t border-white/8 pt-4 text-xs text-slate-400">
{group.founded_at ? <span className="inline-flex items-center gap-2"><i className="fa-solid fa-calendar-days text-slate-500" />Founded {new Date(group.founded_at).toLocaleDateString()}</span> : null}
{group.type ? <span className="inline-flex items-center gap-2"><i className="fa-solid fa-tag text-slate-500" />{group.type}</span> : null}
</div>
</div>
</section>
) : null}

View File

@@ -147,6 +147,7 @@ export default function HelpCenterPage() {
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<a href={links.studio_help} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Studio help</a>
<a href={links.upload_help} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Upload help</a>
<a href={links.help_worlds} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Worlds help</a>
<a href={links.help_cards} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Cards help</a>
<a href={links.help_profile} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Profile help</a>
<a href={links.help_auth} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Signup / Login help</a>

View File

@@ -0,0 +1,363 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import DocsCallout from '../../components/docs/DocsCallout'
import DocsComparisonTable from '../../components/docs/DocsComparisonTable'
import DocsFaqAccordion from '../../components/docs/DocsFaqAccordion'
import DocsSection from '../../components/docs/DocsSection'
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
import DocsStepList from '../../components/docs/DocsStepList'
import QuickstartNextSteps from '../../components/docs/QuickstartNextSteps'
import SeoHead from '../../components/seo/SeoHead'
import {
ATTACHMENT_WORKFLOW_ITEMS,
BEST_PRACTICES,
BUILD_WORLD_STEPS,
COMMON_MISTAKES,
COMPARISON_COLUMNS,
COMPARISON_ROWS,
FAQ_ITEMS,
HERO_METRICS,
LIFECYCLE_ITEMS,
MEDIA_AND_SEO_GUIDANCE,
RECURRENCE_GUIDANCE,
RELATED_HELP_ITEMS,
RELATION_TYPE_ITEMS,
SECTION_ITEMS,
SECTION_ITEMS_DETAIL,
TROUBLESHOOTING_ITEMS,
WHAT_WORLDS_ARE_ITEMS,
} from './worldsHelpContent'
function HeroMetric({ label, value, note }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
<div className="mt-2 text-lg font-semibold text-white">{value}</div>
<p className="mt-2 text-sm leading-6 text-slate-400">{note}</p>
</div>
)
}
function InsightCard({ item }) {
return (
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5">
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
</article>
)
}
function BulletGrid({ items, tone = 'sky' }) {
const dotColor = tone === 'amber' ? 'bg-amber-300' : tone === 'emerald' ? 'bg-emerald-300' : 'bg-sky-300'
return (
<div className="grid gap-3 md:grid-cols-2">
{items.map((item) => (
<div key={item} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex gap-3 text-sm leading-6 text-slate-300">
<span className={`mt-2 h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
<span>{item}</span>
</div>
</div>
))}
</div>
)
}
function TroubleCard({ item, links }) {
return (
<a href={links[item.linkKey]} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
<div className="mt-4 flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-sky-200">{item.linkLabel}</span>
<span className="text-slate-500">&rarr;</span>
</div>
</a>
)
}
export default function WorldsHelpPage() {
const { props } = usePage()
const links = props.links || {}
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Worlds Help',
description: props.description,
url: props.seo?.canonical,
author: {
'@type': 'Organization',
name: 'Skinbase',
},
about: ['Worlds', 'Seasonal campaigns', 'Editorial curation', 'Studio workflows', 'Homepage promotion'],
},
{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: FAQ_ITEMS.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
},
]
const relatedHelpItems = RELATED_HELP_ITEMS.map((item) => ({
...item,
href: links[item.linkKey],
}))
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_23%),radial-gradient(circle_at_bottom_right,_rgba(249,115,22,0.16),_transparent_22%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={jsonLd} />
<div className="mx-auto max-w-[1500px]">
<section id="introduction" className="rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.16),transparent_28%)] p-6 shadow-[0_30px_100px_rgba(2,6,23,0.35)] md:p-8 lg:p-10">
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Worlds help</p>
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Worlds are where Skinbase Nova turns curated content into a live campaign surface.</h1>
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This guide explains what Worlds are, how attached content works, how section visibility and order shape the result, and how to preview, publish, promote, and reuse Worlds for recurring campaigns.</p>
<div className="mt-6 flex flex-wrap gap-3">
<a href={links.create_world} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Create a World</a>
<a href={links.studio_worlds} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open Worlds workspace</a>
<a href={links.worlds_index} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Browse public Worlds</a>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
{HERO_METRICS.map((metric) => (
<HeroMetric key={metric.label} label={metric.label} value={metric.value} note={metric.note} />
))}
</div>
</div>
</section>
<div className="mt-8 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
<DocsSidebarNav sections={SECTION_ITEMS} ariaLabel="Worlds help sections" selectLabel="Jump to Worlds help section" />
<div className="space-y-6">
<DocsSection
id="what-worlds-are"
eyebrow="Foundations"
title="What Worlds are"
summary="Worlds are premium editorial destinations for campaign moments, not just another listing surface. They combine identity, structure, and curation into one public result."
>
<div className="grid gap-4 xl:grid-cols-3">
{WHAT_WORLDS_ARE_ITEMS.map((item) => (
<InsightCard key={item.title} item={item} />
))}
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
{LIFECYCLE_ITEMS.map((item) => (
<InsightCard key={item.title} item={item} />
))}
</div>
<div className="mt-6">
<DocsCallout tone="note" title="The shortest useful definition">
A World is one hero, one short intro, one clear CTA, and a controlled set of attached modules arranged like an editorial campaign rather than a generic content pile.
</DocsCallout>
</div>
</DocsSection>
<DocsSection
id="worlds-vs-other-surfaces"
eyebrow="Format choice"
title="Worlds vs other public surfaces"
summary="Use a World when the real need is a campaign hub. If the job is simpler, another surface may be a better fit."
>
<DocsComparisonTable columns={COMPARISON_COLUMNS} rows={COMPARISON_ROWS} caption="Comparison between Worlds, collections, and Group pages" />
<div className="mt-6 grid gap-4 md:grid-cols-2">
<DocsCallout tone="tip" title="Choose a World when curation needs structure">
If the page needs multiple content modules, campaign identity, timing, and possible homepage promotion, a World is usually the right answer.
</DocsCallout>
<DocsCallout tone="warning" title="Do not use Worlds when a simpler surface is enough">
If the content only needs a list, collection, or shared identity page, using a World can make the workflow heavier than necessary.
</DocsCallout>
</div>
</DocsSection>
<DocsSection
id="building-a-world"
eyebrow="Workflow"
title="Building a World"
summary="The editorial workflow is deliberate: define the campaign identity, attach the right content, check structure, preview the result, then publish only when the page reads clearly."
>
<DocsStepList items={BUILD_WORLD_STEPS} />
</DocsSection>
<DocsSection
id="attached-content-and-sections"
eyebrow="Composition"
title="Attached content and sections"
summary="Attachments are the heart of the World system. They let editors compose a campaign hub out of explicit, curated relations instead of relying on vague automation."
>
<div className="grid gap-4 md:grid-cols-3">
{ATTACHMENT_WORKFLOW_ITEMS.map((item) => (
<InsightCard key={item.title} item={item} />
))}
</div>
<div className="mt-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Supported attached entity types</p>
<div className="mt-4">
<BulletGrid items={RELATION_TYPE_ITEMS} tone="emerald" />
</div>
</div>
<div className="mt-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Built-in World sections</p>
<div className="mt-4 grid gap-4 xl:grid-cols-3">
{SECTION_ITEMS_DETAIL.map((item) => (
<InsightCard key={item.title} item={item} />
))}
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<DocsCallout tone="practice" title="Section order and section presence are different controls">
Reordering changes where a module appears. Visibility decides whether that module appears at all. Strong Worlds use both intentionally.
</DocsCallout>
<DocsCallout tone="note" title="Attached content can stay private to the editor layout">
A section can remain disabled on the public page even if attached items exist for it. This is useful when editors are still shaping the campaign or intentionally trimming the public result.
</DocsCallout>
</div>
</DocsSection>
<DocsSection
id="preview-publishing-and-promotion"
eyebrow="Confidence"
title="Preview, publishing, and promotion"
summary="Worlds are meant for real campaign operations, so lifecycle and promotion state need to be visible and understandable before anything goes public."
>
<div className="grid gap-4 md:grid-cols-3">
{LIFECYCLE_ITEMS.map((item) => (
<InsightCard key={item.title} item={item} />
))}
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<DocsCallout tone="tip" title="Use preview as a safety check">
Check the hero, cover, CTA, badge, section order, and attached content hierarchy before moving a World into a public or promoted lifecycle state.
</DocsCallout>
<DocsCallout tone="warning" title="Featured promotion is stronger than just publish">
A World can be publicly visible at its own URL without also being ready for homepage or spotlight promotion. Treat those decisions separately.
</DocsCallout>
</div>
</DocsSection>
<DocsSection
id="recurrence-and-new-editions"
eyebrow="Reuse"
title="Recurrence and new editions"
summary="Recurring campaigns should be easy to understand operationally. Worlds include recurrence fields and duplication actions so campaign families can evolve cleanly over time."
>
<BulletGrid items={RECURRENCE_GUIDANCE} tone="sky" />
<div className="mt-6">
<DocsCallout tone="practice" title="Think in campaign families">
Halloween 2026 and Halloween 2027 should feel like different editions of the same family, not like unrelated Worlds with manually repeated structure.
</DocsCallout>
</div>
</DocsSection>
<DocsSection
id="media-seo-and-theme-presets"
eyebrow="Presentation"
title="Media, SEO, and theme presets"
summary="Worlds are visual and promotional. Theme presets accelerate setup, while media and SEO fields shape how the page reads inside the platform and when shared outward."
>
<BulletGrid items={MEDIA_AND_SEO_GUIDANCE} tone="amber" />
<div className="mt-6 grid gap-4 md:grid-cols-2">
<DocsCallout tone="note" title="Current media workflow">
Worlds currently use absolute URLs or CDN storage paths for cover and OG assets. The editor includes asset previews and is already prepared for a future shared media picker.
</DocsCallout>
<DocsCallout tone="tip" title="Use presets to move faster, not to stop thinking">
Theme presets should give you a strong start. Editors should still adjust the result when the campaign needs a sharper, more specific identity.
</DocsCallout>
</div>
</DocsSection>
<DocsSection
id="best-practices"
eyebrow="Quality habits"
title="Best practices"
summary="Strong Worlds feel editorially intentional. Every section, asset, and attached item should earn its place."
>
<BulletGrid items={BEST_PRACTICES} tone="emerald" />
</DocsSection>
<DocsSection
id="common-mistakes"
eyebrow="Avoid this"
title="Common mistakes"
summary="Most Worlds problems are not technical. They come from weak curation, unclear promotion decisions, or using the wrong format for the job."
>
<BulletGrid items={COMMON_MISTAKES} tone="amber" />
</DocsSection>
<DocsSection
id="faq"
eyebrow="FAQ"
title="Worlds FAQ"
summary="These answers cover the most common editorial and workflow questions about creating, attaching, and promoting Worlds."
>
<DocsFaqAccordion items={FAQ_ITEMS} />
</DocsSection>
<DocsSection
id="troubleshooting"
eyebrow="Troubleshooting"
title="Troubleshooting"
summary="Use these routes when the problem is access, preview quality, format choice, publishing readiness, or an editor workflow issue."
>
<div className="grid gap-4 xl:grid-cols-2">
{TROUBLESHOOTING_ITEMS.map((item) => (
<TroubleCard key={item.title} item={item} links={links} />
))}
</div>
</DocsSection>
<DocsSection
id="related-help"
eyebrow="Next steps"
title="Related help"
summary="Use these links when Worlds are understood and the next question is about adjacent workflows such as Studio, uploads, cards, or Group collaboration."
>
<QuickstartNextSteps items={relatedHelpItems} />
</DocsSection>
</div>
<aside className="hidden xl:block xl:sticky xl:top-24 xl:self-start">
<div className="space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Quick route map</p>
<div className="mt-4 space-y-2">
<a href={links.create_world} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Create a World</a>
<a href={links.studio_worlds} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open Worlds workspace</a>
<a href={links.worlds_index} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Browse public Worlds</a>
<a href={links.studio_help} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read Studio help</a>
</div>
</div>
<div className="rounded-[24px] border border-sky-300/20 bg-sky-400/10 p-4 text-sky-50">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80">Fast reminder</div>
<p className="mt-2 text-sm leading-6 text-sky-50/85">A World should feel like an editorial decision, not a container. If the page feels cluttered, the usual fix is stronger curation, fewer modules, and clearer promotion intent.</p>
</div>
</div>
</aside>
</div>
</div>
</main>
)
}

View File

@@ -10,7 +10,11 @@ export const SEARCH_SUGGESTIONS = [
'troubleshooting',
'forgot password',
'create card',
'create world',
'cards help',
'worlds help',
'seasonal world',
'attached relations',
'profile help',
'profile bio',
'publish artwork',
@@ -54,6 +58,18 @@ export const HIGHLIGHTED_GUIDES = [
secondaryLabel: 'Create a card',
tags: ['cards', 'design', 'publishing'],
},
{
eyebrow: 'Live now',
title: 'Worlds help',
description: 'A complete guide to building editorial Worlds, attaching curated content, controlling sections, previewing the result, and publishing with confidence.',
status: 'Guide',
tone: 'sky',
primaryLinkKey: 'help_worlds',
primaryLabel: 'Read Worlds help',
secondaryLinkKey: 'create_world',
secondaryLabel: 'Create a world',
tags: ['worlds', 'campaigns', 'editorial', 'attached relations'],
},
{
eyebrow: 'Live now',
title: 'Profile help',
@@ -173,6 +189,20 @@ export const FEATURED_GUIDES = [
highlights: ['Now live as the dedicated Cards format guide', 'Explains format choice, ownership, and creator-friendly best practices'],
tags: ['cards', 'design', 'editorial'],
},
{
eyebrow: 'Priority topic',
title: 'Worlds',
description: 'Learn how to build seasonal, event, and campaign destinations with curated attachments, section control, preview workflows, and homepage promotion readiness.',
status: 'Live now',
tone: 'sky',
plannedPath: '/help/worlds',
primaryLinkKey: 'help_worlds',
primaryLabel: 'Read Worlds help',
secondaryLinkKey: 'worlds_index',
secondaryLabel: 'Browse public Worlds',
highlights: ['Now live as the dedicated Worlds editorial workflow guide', 'Explains attached content, recurrence, preview, and promotion clearly'],
tags: ['worlds', 'campaigns', 'seasonal'],
},
{
eyebrow: 'Priority topic',
title: 'Profile',
@@ -288,6 +318,18 @@ export const HELP_CATEGORIES = [
secondaryLabel: 'Open upload',
tags: ['upload', 'artwork', 'publish'],
},
{
eyebrow: 'Portfolio',
title: 'Worlds',
description: 'Learn how to create and manage editorial Worlds with curated attachments, section visibility, preview, recurrence, and promotion workflows.',
status: 'Live',
plannedPath: '/help/worlds',
primaryLinkKey: 'help_worlds',
primaryLabel: 'Read Worlds help',
secondaryLinkKey: 'studio_worlds',
secondaryLabel: 'Open Worlds workspace',
tags: ['worlds', 'editorial', 'campaigns'],
},
{
eyebrow: 'Portfolio',
title: 'Artworks',
@@ -615,6 +657,12 @@ export const POPULAR_HELP_TOPICS = [
linkKey: 'help_cards',
tags: ['cards', 'guide', 'design'],
},
{
title: 'How Worlds work',
description: 'Read the Worlds guide to understand editorial purpose, attached content, preview, section control, recurrence, and homepage promotion.',
linkKey: 'help_worlds',
tags: ['worlds', 'campaigns', 'editorial'],
},
{
title: 'How profiles work',
description: 'Read the Profile guide to understand setup, identity clarity, presentation, and how personal presence fits beside Group activity.',

View File

@@ -0,0 +1,310 @@
export const SECTION_ITEMS = [
{ id: 'what-worlds-are', label: 'What Worlds are' },
{ id: 'worlds-vs-other-surfaces', label: 'Worlds vs other surfaces' },
{ id: 'building-a-world', label: 'Building a World' },
{ id: 'attached-content-and-sections', label: 'Attached content and sections' },
{ id: 'preview-publishing-and-promotion', label: 'Preview, publishing, and promotion' },
{ id: 'recurrence-and-new-editions', label: 'Recurrence and new editions' },
{ id: 'media-seo-and-theme-presets', label: 'Media, SEO, and theme presets' },
{ id: 'best-practices', label: 'Best practices' },
{ id: 'common-mistakes', label: 'Common mistakes' },
{ id: 'faq', label: 'FAQ' },
{ id: 'troubleshooting', label: 'Troubleshooting' },
{ id: 'related-help', label: 'Related help' },
]
export const HERO_METRICS = [
{
label: 'Core purpose',
value: 'Editorial campaign hub',
note: 'A World is a curated destination for seasonal, event, tribute, or campaign storytelling across multiple content types.',
},
{
label: 'Operational strength',
value: 'Attached content with structure',
note: 'Worlds are built from explicit attachments, section order, section visibility, and preview-first editorial review.',
},
{
label: 'Golden rule',
value: 'Curate, then publish',
note: 'A World should feel intentional before it goes live. Treat preview, section checks, and promotion scope as part of the workflow.',
},
]
export const WHAT_WORLDS_ARE_ITEMS = [
{
title: 'Worlds are not filters',
body: 'A World is not just a search result, category page, or tag landing page. It is an editorial surface shaped around a theme, moment, or campaign.',
},
{
title: 'Worlds bundle multiple content types',
body: 'A single World can attach artworks, collections, creators, groups, news, challenges, events, releases, and cards into one public-facing story.',
},
{
title: 'Worlds are designed for reuse',
body: 'Recurring campaigns such as Halloween, Pixel Week, or tribute weeks can be rolled forward into new editions instead of rebuilt from scratch.',
},
]
export const COMPARISON_COLUMNS = [
{ key: 'topic', label: 'Topic' },
{ key: 'world', label: 'World' },
{ key: 'collection', label: 'Collection' },
{ key: 'group', label: 'Group page' },
]
export const COMPARISON_ROWS = [
{
id: 'purpose',
topic: 'Primary purpose',
world: 'Run a themed editorial or seasonal campaign with curated modules and promotion logic.',
collection: 'Organize a set of items into a more focused list or presentation set.',
group: 'Represent a shared identity, members, and collaboration activity under one collective surface.',
},
{
id: 'composition',
topic: 'How content is assembled',
world: 'Explicit attachments are placed into named sections such as featured artworks, creators, events, and news.',
collection: 'Items are gathered into a collection-first structure rather than a multi-module campaign layout.',
group: 'Content is shaped by ownership, members, and shared publishing rather than editorial campaign modules.',
},
{
id: 'public-output',
topic: 'Public output',
world: 'One hero, one intro, optional CTA and badge, then a controlled set of editorial modules.',
collection: 'A more focused item grouping surface.',
group: 'A public identity and activity surface for the collective itself.',
},
{
id: 'promotion',
topic: 'Promotion role',
world: 'Can stand alone at its own URL or be promoted on Worlds surfaces and homepage spotlight areas.',
collection: 'Usually promoted as a collection destination rather than a campaign system.',
group: 'Promotion is about the Group and its output, not a dedicated campaign moment.',
},
{
id: 'reuse',
topic: 'Operational reuse',
world: 'Supports duplication and next-edition workflows for recurring campaigns.',
collection: 'Usually reused by copying or curating a new collection manually.',
group: 'Persistent long-term identity, not a short-term edition workflow.',
},
]
export const BUILD_WORLD_STEPS = [
{
title: 'Choose the World type and title',
description: 'Pick the clearest campaign framing first: seasonal, event, campaign, or tribute. Use a title that still reads well in preview, homepage spotlight, and archive contexts.',
},
{
title: 'Select a theme preset',
description: 'Theme presets can prefill accent colors, background motif, icon, related tags, and suggested badge or CTA labels, while still allowing manual overrides.',
},
{
title: 'Write the intro and define the identity',
description: 'Add tagline, summary, description, badge, CTA, and the visual identity needed to make the public page feel coherent.',
},
{
title: 'Attach curated content',
description: 'Use the relation picker to search supported entity types, assign them to sections, mark featured items, and add context labels when needed.',
},
{
title: 'Adjust section order and visibility',
description: 'Control both where modules appear and whether they appear at all. A World should show only the sections that help the campaign land cleanly.',
},
{
title: 'Set lifecycle and recurrence',
description: 'Define status, publish timing, campaign window, featured promotion state, and recurring edition metadata before the page goes live.',
},
{
title: 'Preview before publish',
description: 'Use the mini preview for in-editor confidence and the full preview page for a public-surface check before publishing or featuring the World.',
},
]
export const ATTACHMENT_WORKFLOW_ITEMS = [
{
title: 'The relation picker is the composition tool',
body: 'Add relation opens a structured picker with entity type, search, result previews, section assignment, featured state, and optional context labels.',
},
{
title: 'Attachments belong to specific sections',
body: 'Each attached item is placed intentionally into a section such as featured artworks, curated collections, related events, or themed cards.',
},
{
title: 'Attached rows stay editable',
body: 'After attachment, relation cards support edit, remove, and reorder actions so the final World reads like deliberate editorial composition rather than a hidden data list.',
},
]
export const SECTION_ITEMS_DETAIL = [
{ title: 'Featured artworks', body: 'Standout pieces curated for the World.' },
{ title: 'Curated collections', body: 'Collections that deepen the theme and reward longer exploration.' },
{ title: 'Featured creators', body: 'Creators shaping the atmosphere of the moment.' },
{ title: 'Featured groups', body: 'Collectives, scenes, and crews connected to the campaign.' },
{ title: 'Related news', body: 'Editorial context, announcements, and stories tied to the World.' },
{ title: 'Challenge spotlight', body: 'Challenges attached to the campaign or recent participation around it.' },
{ title: 'Related events', body: 'Upcoming or recent sessions, launches, and live moments.' },
{ title: 'Release spotlights', body: 'Projects and releases that belong in the campaign space.' },
{ title: 'Themed cards', body: 'Nova Cards that extend the World identity into designed communication surfaces.' },
]
export const RELATION_TYPE_ITEMS = [
'Artworks',
'Collections',
'Creators',
'Groups',
'News articles',
'Challenges',
'Events',
'Releases',
'Cards',
]
export const LIFECYCLE_ITEMS = [
{
title: 'Draft, scheduled, live, and archived thinking',
body: 'Worlds move through an editorial lifecycle. Drafts are private workspaces, published Worlds can be scheduled or already live, and archive state keeps past editions visible when the campaign has ended.',
},
{
title: 'Featured promotion goes beyond the page itself',
body: 'When a World is marked for homepage and Worlds spotlight treatment, it moves from a standalone public page into platform-level campaign promotion readiness.',
},
{
title: 'The summary card is operational, not decorative',
body: 'Use the editor summary to verify lifecycle state, campaign window, theme, recurrence, relation count, enabled sections, and current promotion scope at a glance.',
},
]
export const RECURRENCE_GUIDANCE = [
'Turn on recurring mode when the World belongs to a repeatable campaign family such as halloween, retro-month, or pixel-week.',
'The recurrence key identifies the campaign family. The edition year identifies the current edition within that family.',
'Duplicate plus new-edition actions exist so editors can roll successful Worlds forward into the next campaign cycle more safely.',
'Recurring Worlds should not reuse the same recurrence key and edition year pair. Each edition needs its own distinct year value.',
]
export const MEDIA_AND_SEO_GUIDANCE = [
'Theme presets are acceleration tools, not constraints. Editors can override autofilled colors, motif, icon, tags, badge label, and CTA label at any time.',
'Cover and OG assets currently use absolute URLs or CDN storage paths. The editor already includes preview support and is prepared for a future shared media picker.',
'CTA label and URL should represent the real campaign action: explore, join, enter, submit, discover, or a similar world-specific path.',
'SEO title, SEO description, and OG image should describe the World as a public destination, not as an internal draft.',
]
export const BEST_PRACTICES = [
'Lead with one strong hero idea instead of trying to make the World cover every possible related surface.',
'Attach only the content that sharpens the campaign. More relations do not automatically create a better World.',
'Use section visibility deliberately so empty or weak modules do not dilute the page.',
'Preview the World before publish, especially when promotion scope or homepage spotlight is involved.',
'Use recurrence metadata and new-edition workflows for annual campaigns instead of rebuilding the structure manually each year.',
'Keep CTA, badge, summary, and theme signals aligned so the World feels like one coherent editorial destination.',
]
export const COMMON_MISTAKES = [
'Treating a World like a generic dump of related content instead of an intentional editorial surface.',
'Leaving sections enabled just because attachments exist, even when those modules do not strengthen the public story.',
'Marking a World as featured without checking whether the campaign is truly ready for homepage or spotlight promotion.',
'Ignoring recurrence fields until the next edition arrives and the campaign family becomes harder to manage.',
'Using weak asset paths or unreviewed OG values that make the preview feel unfinished.',
'Confusing Worlds with collections, group pages, or challenges when the real need is a different public surface.',
]
export const FAQ_ITEMS = [
{
question: 'What is a World on Skinbase Nova?',
answer: 'A World is a curated editorial destination for a seasonal moment, event, tribute, or campaign. It combines one strong hero with a controlled set of attached modules and optional promotion across public surfaces.',
},
{
question: 'What can I attach to a World?',
answer: 'Worlds can attach artworks, collections, creators, groups, news, challenges, events, releases, and cards. Each item is assigned to a specific section so the page stays structured.',
},
{
question: 'Can I preview a World before publishing?',
answer: 'Yes. The editor includes a mini preview for live confidence and a dedicated full preview page for saved Worlds so editors can check the public result before it goes live.',
},
{
question: 'How is a World different from a collection?',
answer: 'A collection groups content. A World is a larger editorial campaign surface with multiple module types, lifecycle controls, promotion readiness, and recurrence support.',
},
{
question: 'What does featured promotion mean?',
answer: 'Featured promotion means the World is eligible for promoted Worlds surfaces and homepage spotlight treatment once it is in the right lifecycle state.',
},
{
question: 'How do recurring Worlds work?',
answer: 'Recurring Worlds use a recurrence key to identify the campaign family and an edition year to identify the specific edition. Duplicate and new-edition actions help you roll the structure forward.',
},
{
question: 'Can I hide sections even if attachments exist?',
answer: 'Yes. Section visibility is explicit. A module can remain disabled on the public page even if attached relations exist for that section.',
},
]
export const TROUBLESHOOTING_ITEMS = [
{
title: 'I cant find the Worlds workspace',
body: 'Start from Studio and open the Worlds index. If you still do not have access, the issue may be permission-related rather than route-related.',
linkKey: 'studio_worlds',
linkLabel: 'Open Worlds workspace',
},
{
title: 'My World does not look right in preview',
body: 'Review hero copy, attached relations, section visibility, theme overrides, and cover or OG asset paths before publishing.',
linkKey: 'create_world',
linkLabel: 'Open create World flow',
},
{
title: 'Im not sure whether this should be a World',
body: 'If the need is a multi-module campaign hub with editorial curation and promotion, use a World. If not, the better answer may be a collection, group surface, or another publishing format.',
linkKey: 'help_cards',
linkLabel: 'Compare with other creative formats',
},
{
title: 'I cant publish or feature the World',
body: 'Check the lifecycle state, campaign dates, and whether the attached content and section controls are ready for a public or promoted surface.',
linkKey: 'studio_help',
linkLabel: 'Read Studio workflow help',
},
{
title: 'The attached content feels messy',
body: 'Trim the relation list, move items into clearer sections, and disable weak modules. Worlds work best when the page is curated, not crowded.',
linkKey: 'worlds_index',
linkLabel: 'Browse live Worlds for reference',
},
{
title: 'A route or editor action feels broken',
body: 'Use support or bug reporting when the problem is not editorial uncertainty but an actual platform issue with the workflow.',
linkKey: 'report_issue',
linkLabel: 'Report a problem',
},
]
export const RELATED_HELP_ITEMS = [
{
eyebrow: 'Live help',
title: 'Studio help',
body: 'Use the Studio guide when the real question is about workspace context, drafts, publishing behavior, or creator operations beyond Worlds.',
linkKey: 'studio_help',
tone: 'sky',
},
{
eyebrow: 'Live help',
title: 'Upload help',
body: 'Use Upload help when the blocker is asset preparation, artwork metadata, or the publishing workflow feeding into a World.',
linkKey: 'upload_help',
tone: 'amber',
},
{
eyebrow: 'Live help',
title: 'Cards help',
body: 'Use Cards help when the question is really about designed communication surfaces that may later be attached to a World.',
linkKey: 'help_cards',
tone: 'white',
},
{
eyebrow: 'Live help',
title: 'Groups help',
body: 'Use Groups help when the campaign work is tied to shared identity, contributors, collaboration, or group-owned publishing.',
linkKey: 'groups_help',
tone: 'white',
},
]

View File

@@ -12,6 +12,7 @@ const HomeMedalHighlights = lazy(() => import('./HomeMedalHighlights'))
const HomeRising = lazy(() => import('./HomeRising'))
const HomeFresh = lazy(() => import('./HomeFresh'))
const HomeCollections = lazy(() => import('./HomeCollections'))
const HomeWorldSpotlight = lazy(() => import('./HomeWorldSpotlight'))
const HomeGroups = lazy(() => import('./HomeGroups'))
const HomeCategories = lazy(() => import('./HomeCategories'))
const HomeTags = lazy(() => import('./HomeTags'))
@@ -102,7 +103,7 @@ function SectionFallback({ variant = 'gallery' }) {
}
function GuestHomePage(props) {
const { rising, trending, community_favorites, hall_of_fame, fresh, tags, creators, news, collections_featured, collections_trending, collections_editorial, collections_community, groups } = props
const { rising, trending, community_favorites, hall_of_fame, fresh, tags, creators, news, collections_featured, collections_trending, collections_editorial, collections_community, groups, world_spotlight } = props
return (
<>
@@ -143,6 +144,10 @@ function GuestHomePage(props) {
/>
</Suspense>
<Suspense fallback={<SectionFallback variant="collections" />}>
<HomeWorldSpotlight world={world_spotlight} />
</Suspense>
<Suspense fallback={<SectionFallback variant="groups" />}>
<HomeGroups groups={groups} />
</Suspense>
@@ -190,6 +195,7 @@ function AuthHomePage(props) {
collections_trending,
collections_editorial,
collections_community,
world_spotlight,
groups,
by_categories,
suggested_creators,
@@ -263,6 +269,10 @@ function AuthHomePage(props) {
/>
</Suspense>
<Suspense fallback={<SectionFallback variant="collections" />}>
<HomeWorldSpotlight world={world_spotlight} />
</Suspense>
<Suspense fallback={<SectionFallback variant="groups" />}>
<HomeGroups groups={groups} />
</Suspense>

View File

@@ -0,0 +1,49 @@
import React from 'react'
export default function HomeWorldSpotlight({ world }) {
if (!world) {
return null
}
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<a
href={world.public_url}
className="group relative block overflow-hidden rounded-[32px] border border-white/10 bg-slate-950/70"
style={{
'--world-accent': world.theme?.accent_color || '#f97316',
'--world-accent-secondary': world.theme?.accent_color_secondary || '#0f172a',
}}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_color-mix(in_srgb,var(--world-accent)_30%,transparent),_transparent_36%),linear-gradient(135deg,_color-mix(in_srgb,var(--world-accent-secondary)_92%,black),_rgba(2,6,23,0.98))]" />
{world.cover_url ? <img src={world.cover_url} alt={world.title} className="absolute inset-0 h-full w-full object-cover opacity-20 transition duration-500 group-hover:scale-[1.03]" /> : null}
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/82 to-slate-950/35" />
<div className="relative grid gap-8 px-6 py-7 sm:px-8 lg:grid-cols-[minmax(0,1.2fr)_18rem] lg:items-end lg:px-10">
<div>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/70">
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">Homepage spotlight</span>
{world.badge_label ? <span className="rounded-full border border-white/15 bg-black/25 px-3 py-1">{world.badge_label}</span> : null}
</div>
<h2 className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white sm:text-4xl">{world.title}</h2>
{world.tagline ? <p className="mt-2 text-sm uppercase tracking-[0.18em] text-white/55">{world.tagline}</p> : null}
{world.summary ? <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-200/88">{world.summary}</p> : null}
<div className="mt-6 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2.5 text-sm font-semibold text-slate-950 transition group-hover:bg-sky-100">
{world.cta_label || 'Explore world'}
<i className="fa-solid fa-arrow-right" />
</div>
</div>
<div className="rounded-[28px] border border-white/12 bg-black/25 p-5 text-white backdrop-blur-sm">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">World Theme</div>
<div className="mt-2 flex items-center gap-3 text-lg font-semibold">
<i className={world.icon_name || 'fa-solid fa-globe'} />
<span>{world.theme?.label || 'Editorial world'}</span>
</div>
{world.timeframe_label ? <div className="mt-4 text-sm text-slate-300">{world.timeframe_label}</div> : null}
</div>
</div>
</a>
</section>
)
}

View File

@@ -9,6 +9,7 @@ const TYPE_TABS = [
{ value: 'artwork', label: 'Artworks' },
{ value: 'group', label: 'Groups' },
{ value: 'story', label: 'Stories' },
{ value: 'world', label: 'Worlds' },
]
const PERIOD_TABS = [
@@ -23,6 +24,7 @@ const API_BY_TYPE = {
artwork: '/api/leaderboard/artworks',
group: '/api/leaderboard/groups',
story: '/api/leaderboard/stories',
world: '/api/leaderboard/worlds',
}
export default function LeaderboardPage() {
@@ -73,17 +75,17 @@ export default function LeaderboardPage() {
return (
<>
<SeoHead seo={seo} title={seo?.title || 'Leaderboard — Skinbase'} description={seo?.description || 'Top creators, groups, artworks, and stories on Skinbase.'} />
<SeoHead seo={seo} title={seo?.title || 'Leaderboard — Skinbase'} description={seo?.description || 'Top creators, groups, artworks, stories, and Worlds on Skinbase.'} />
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.14),transparent_34%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] pb-16 text-slate-100">
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<header className="rounded-[2rem] border border-white/10 bg-slate-950/70 px-6 py-8 shadow-[0_35px_120px_rgba(2,6,23,0.75)] backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-300">Skinbase Competition Board</p>
<h1 className="mt-4 max-w-3xl text-4xl font-black tracking-tight text-white sm:text-5xl">
Top creators, groups, standout artworks, and stories with momentum.
Top creators, groups, standout artworks, stories, and Worlds with momentum.
</h1>
<p className="mt-4 max-w-2xl text-sm leading-6 text-slate-300 sm:text-base">
Switch between creators, groups, artworks, and stories, then filter by daily, weekly, monthly, or all-time performance.
Switch between creators, groups, artworks, stories, and Worlds, then filter by daily, weekly, monthly, or all-time performance.
</p>
</header>

View File

@@ -0,0 +1,381 @@
import React from 'react'
import { Head, Link, router, usePage } from '@inertiajs/react'
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
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 || 'Request failed.')
}
return payload
}
function formatDateTime(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date)
}
function Badge({ children, tone = 'slate' }) {
const tones = {
slate: 'border-white/10 bg-white/[0.06] text-slate-200',
sky: 'border-sky-300/20 bg-sky-400/12 text-sky-100',
emerald: 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100',
amber: 'border-amber-300/20 bg-amber-400/12 text-amber-100',
rose: 'border-rose-300/20 bg-rose-400/12 text-rose-100',
}
return <span className={`inline-flex items-center rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tones[tone] || tones.slate}`}>{children}</span>
}
function StatCard({ label, value, tone = 'sky' }) {
const tones = {
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100',
slate: 'border-white/10 bg-white/10 text-slate-100',
}
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
<div className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tones[tone] || tones.sky}`}>{label}</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
function selectTone(record) {
if (record.last_error_code || record.status === 'failed') return 'rose'
if (record.needs_review) return 'amber'
if (record.is_user_edited) return 'sky'
if (record.status === 'approved') return 'emerald'
return 'slate'
}
function labelForStatus(value) {
if (!value) return 'Unknown'
return String(value).replaceAll('_', ' ')
}
export default function AiBiographyAdmin() {
const { props } = usePage()
const records = props.records || { data: [] }
const stats = props.stats || {}
const endpoints = props.endpoints || {}
const filterOptions = props.filterOptions || {}
const [filters, setFilters] = React.useState(props.filters || {})
const [busyKey, setBusyKey] = React.useState('')
const [notice, setNotice] = React.useState('')
const [error, setError] = React.useState('')
React.useEffect(() => {
setFilters(props.filters || {})
}, [props.filters])
function updateFilter(key, value) {
setFilters((current) => ({ ...current, [key]: value }))
}
function applyFilters(event) {
event.preventDefault()
setError('')
setNotice('')
router.get(endpoints.index, filters, {
preserveState: true,
replace: true,
preserveScroll: true,
})
}
function resetFilters() {
setError('')
setNotice('')
router.get(endpoints.index, {
q: '',
status: 'all',
scope: 'all',
tier: 'all',
visibility: 'all',
review: 'all',
}, {
preserveState: true,
replace: true,
preserveScroll: true,
})
}
async function performAction(actionKey, url) {
setBusyKey(actionKey)
setError('')
try {
const payload = await requestJson(url)
setNotice(payload.message || 'Action completed.')
router.reload({
only: ['records', 'stats', 'filters'],
preserveScroll: true,
})
} catch (requestError) {
setError(requestError.message || 'Action failed.')
} finally {
setBusyKey('')
}
}
return (
<div className="mx-auto max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-8">
<Head title="AI Biography Review" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.2),transparent_32%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.9))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<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.28em] text-sky-200/80">Moderator surface</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">AI biography review</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Browse active biographies and historical generations, inspect review flags and failures, and rebuild a creator biography directly from cPad.</p>
</div>
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {records.current_page || 1} / {records.last_page || 1}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(records.total || 0).toLocaleString()} records</span>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<StatCard label="Total records" value={stats.total_records} tone="sky" />
<StatCard label="Active" value={stats.active_records} tone="emerald" />
<StatCard label="Needs review" value={stats.needs_review} tone="amber" />
<StatCard label="Hidden active" value={stats.hidden_active} tone="slate" />
<StatCard label="Failed" value={stats.failed} tone="rose" />
<StatCard label="User edited" value={stats.user_edited_active} tone="sky" />
</div>
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_repeat(5,minmax(0,1fr))]">
<label className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Search creator</div>
<input
value={filters.q || ''}
onChange={(event) => updateFilter('q', event.target.value)}
placeholder="username, name, or email"
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none"
/>
</label>
{['status', 'scope', 'tier', 'visibility', 'review'].map((key) => (
<label key={key} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{key.replace('_', ' ')}</div>
<select
value={filters[key] || 'all'}
onChange={(event) => updateFilter(key, event.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none"
>
{(filterOptions[key] || []).map((option) => (
<option key={`${key}-${option.value}`} value={option.value}>{option.label}</option>
))}
</select>
</label>
))}
<div className="lg:col-span-full flex flex-wrap gap-3">
<button type="submit" className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-400/12 px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/18">
<i className="fa-solid fa-filter text-[10px]" />
Apply filters
</button>
<button type="button" onClick={resetFilters} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
<i className="fa-solid fa-rotate-left text-[10px]" />
Reset
</button>
</div>
</form>
</section>
{notice ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{notice}</div> : null}
{error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
<div className="mt-8 space-y-4">
{(records.data || []).length === 0 ? (
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300">No AI biography records matched the current filters.</div>
) : (records.data || []).map((record) => {
const rebuildKey = `rebuild-${record.user_id}`
const approveKey = `approve-${record.id}`
const flagKey = `flag-${record.id}`
const visibilityKey = `${record.is_hidden ? 'show' : 'hide'}-${record.id}`
return (
<article key={record.id} className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<div className="flex flex-wrap gap-2">
<Badge tone={selectTone(record)}>{labelForStatus(record.status)}</Badge>
<Badge tone={record.is_active ? 'emerald' : 'slate'}>{record.is_active ? 'active' : 'inactive'}</Badge>
<Badge tone={record.is_hidden ? 'amber' : 'sky'}>{record.is_hidden ? 'hidden' : 'visible'}</Badge>
{record.needs_review ? <Badge tone="amber">needs review</Badge> : null}
{record.is_user_edited ? <Badge tone="sky">user edited</Badge> : null}
{record.is_stale ? <Badge tone="rose">stale</Badge> : null}
{record.input_quality_tier ? <Badge tone="slate">tier: {record.input_quality_tier}</Badge> : null}
</div>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{record.user?.display_name || 'Unknown creator'}</h2>
<p className="mt-2 text-sm text-slate-300">
@{record.user?.username || 'unknown'}
{record.user?.email ? `${record.user.email}` : ''}
{record.generation_reason ? ` • reason: ${labelForStatus(record.generation_reason)}` : ''}
</p>
</div>
<div className="flex flex-wrap gap-2">
{record.user?.profile_url ? (
<a href={record.user.profile_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
<i className="fa-solid fa-user text-[10px]" />
Open profile
</a>
) : null}
{record.user?.gallery_url ? (
<a href={record.user.gallery_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
<i className="fa-solid fa-images text-[10px]" />
Open gallery
</a>
) : null}
<button
type="button"
disabled={busyKey === rebuildKey}
onClick={() => performAction(rebuildKey, String(endpoints.rebuildPattern || '').replace('__USER__', String(record.user_id)))}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/18 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-rotate text-[10px]" />
{busyKey === rebuildKey ? 'Rebuilding…' : 'Rebuild'}
</button>
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Prompt</div>
<div className="mt-2 text-sm text-slate-200">{record.prompt_version || '—'}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Model</div>
<div className="mt-2 text-sm text-slate-200">{record.model || '—'}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Generated</div>
<div className="mt-2 text-sm text-slate-200">{formatDateTime(record.generated_at)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Last attempted</div>
<div className="mt-2 text-sm text-slate-200">{formatDateTime(record.last_attempted_at)}</div>
</div>
</div>
{record.last_error_code || record.last_error_reason ? (
<div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm leading-relaxed text-rose-100">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-rose-100/80">Last error</div>
<div className="mt-2">{record.last_error_code || 'generation_failed'}{record.last_error_reason ? `${record.last_error_reason}` : ''}</div>
</div>
) : null}
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Biography text</div>
<div className="mt-3 max-h-[320px] overflow-y-auto whitespace-pre-wrap rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-4 text-sm leading-relaxed text-slate-100">
{record.text || 'No biography text stored for this record.'}
</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Review actions</div>
<div className="mt-4 grid gap-3">
<button
type="button"
disabled={busyKey === approveKey}
onClick={() => performAction(approveKey, String(endpoints.approvePattern || '').replace('__BIOGRAPHY__', String(record.id)))}
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-emerald-300/20 bg-emerald-400/12 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-50 transition hover:bg-emerald-400/18 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-check text-[10px]" />
{busyKey === approveKey ? 'Saving…' : 'Mark reviewed'}
</button>
<button
type="button"
disabled={busyKey === flagKey}
onClick={() => performAction(flagKey, String(endpoints.flagPattern || '').replace('__BIOGRAPHY__', String(record.id)))}
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/12 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-amber-50 transition hover:bg-amber-400/18 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-flag text-[10px]" />
{busyKey === flagKey ? 'Saving…' : 'Flag for review'}
</button>
<button
type="button"
disabled={!record.is_active || busyKey === visibilityKey}
onClick={() => performAction(visibilityKey, String((record.is_hidden ? endpoints.showPattern : endpoints.hidePattern) || '').replace('__BIOGRAPHY__', String(record.id)))}
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:cursor-not-allowed disabled:opacity-50"
>
<i className={`fa-solid ${record.is_hidden ? 'fa-eye' : 'fa-eye-slash'} text-[10px]`} />
{busyKey === visibilityKey ? 'Saving…' : record.is_hidden ? 'Show publicly' : 'Hide publicly'}
</button>
</div>
<div className="mt-4 space-y-2 text-xs leading-relaxed text-slate-300">
<div><span className="font-semibold text-slate-100">Approved:</span> {formatDateTime(record.approved_at)}</div>
<div><span className="font-semibold text-slate-100">Created:</span> {formatDateTime(record.created_at)}</div>
<div><span className="font-semibold text-slate-100">Updated:</span> {formatDateTime(record.updated_at)}</div>
<div><span className="font-semibold text-slate-100">Source hash:</span> {record.source_hash || '—'}</div>
</div>
</div>
</div>
</article>
)
})}
</div>
{(records.prev_page_url || records.next_page_url) ? (
<div className="mt-8 flex items-center justify-between gap-3">
<div>
{records.prev_page_url ? (
<Link href={records.prev_page_url} preserveScroll className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
<i className="fa-solid fa-arrow-left text-[10px]" />
Previous
</Link>
) : null}
</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">Showing page {records.current_page || 1} of {records.last_page || 1}</div>
<div>
{records.next_page_url ? (
<Link href={records.next_page_url} preserveScroll className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
Next
<i className="fa-solid fa-arrow-right text-[10px]" />
</Link>
) : null}
</div>
</div>
) : null}
</div>
)
}

View File

@@ -123,7 +123,7 @@ export default function ProfileShow() {
const contentShellClassName = activeTab === 'artworks'
? 'w-full px-4 md:px-6'
: activeTab === 'posts'
: activeTab === 'posts' || activeTab === 'about'
? 'mx-auto max-w-7xl px-4 md:px-6'
: 'max-w-6xl mx-auto px-4'

View File

@@ -168,6 +168,8 @@ export default function ProfileEdit() {
usernameCooldownDays = 30,
usernameCooldownRemainingDays = 0,
usernameCooldownActive = false,
emailLoginUpgradeRequired: initialEmailLoginUpgradeRequired = false,
forcedSection = null,
captcha: initialCaptcha = {},
flash = {},
} = props
@@ -178,7 +180,8 @@ export default function ProfileEdit() {
birthYear ? String(birthYear) : '',
)
const [activeSection, setActiveSection] = useState('profile')
const [emailLoginUpgradeRequired, setEmailLoginUpgradeRequired] = useState(!!initialEmailLoginUpgradeRequired)
const [activeSection, setActiveSection] = useState(forcedSection || 'profile')
const [profileForm, setProfileForm] = useState({
display_name: user?.name || '',
@@ -271,6 +274,14 @@ export default function ProfileEdit() {
const [deleteError, setDeleteError] = useState('')
const [deleting, setDeleting] = useState(false)
const settingsSections = useMemo(() => {
if (!emailLoginUpgradeRequired) {
return SETTINGS_SECTIONS
}
return SETTINGS_SECTIONS.filter((section) => section.key === 'account')
}, [emailLoginUpgradeRequired])
const initialRef = useRef({
profileForm,
accountForm,
@@ -316,12 +327,25 @@ export default function ProfileEdit() {
return () => window.removeEventListener('beforeunload', beforeUnload)
}, [hasUnsavedChanges])
useEffect(() => {
if (!emailLoginUpgradeRequired) {
return
}
setActiveSection('account')
}, [emailLoginUpgradeRequired])
useEffect(() => {
if (usernameCooldownActive) {
setUsernameAvailability({ status: 'idle', message: '' })
return
}
if (emailLoginUpgradeRequired) {
setUsernameAvailability({ status: 'idle', message: '' })
return
}
const candidate = String(accountForm.username || '').trim().toLowerCase()
const current = String(initialRef.current.accountForm.username || '').trim().toLowerCase()
@@ -370,7 +394,7 @@ export default function ProfileEdit() {
controller.abort()
window.clearTimeout(timeout)
}
}, [accountForm.username, usernameCooldownActive])
}, [accountForm.username, usernameCooldownActive, emailLoginUpgradeRequired])
const openEmailChangeModal = () => {
setShowEmailChangeModal(true)
@@ -503,6 +527,10 @@ export default function ProfileEdit() {
}
const switchSection = (nextSection) => {
if (emailLoginUpgradeRequired && nextSection !== 'account') {
return
}
if (activeSection === nextSection) return
if (dirtyMap[activeSection]) {
const shouldContinue = window.confirm('You have unsaved changes in this section. Leave without saving?')
@@ -593,6 +621,10 @@ export default function ProfileEdit() {
const saveAccountSection = async (event) => {
event.preventDefault()
if (emailLoginUpgradeRequired) {
return
}
if (usernameCooldownActive && accountForm.username !== initialRef.current.accountForm.username) {
updateSectionErrors('account', {
username: [`Username can be changed again in ${usernameCooldownRemainingDays} days.`],
@@ -701,6 +733,7 @@ export default function ProfileEdit() {
const nextEmail = payload.email || emailChangeForm.new_email
setAccountForm((prev) => ({ ...prev, email: nextEmail }))
initialRef.current.accountForm = { ...initialRef.current.accountForm, email: nextEmail }
setEmailLoginUpgradeRequired(false)
setShowEmailChangeModal(false)
setEmailChangeStep('request')
setEmailChangeForm({ new_email: '', code: '' })
@@ -910,7 +943,7 @@ export default function ProfileEdit() {
return (
<SettingsLayout
title="Settings"
sections={SETTINGS_SECTIONS}
sections={settingsSections}
activeSection={activeSection}
onSectionChange={switchSection}
dirtyMap={dirtyMap}
@@ -1103,10 +1136,10 @@ export default function ProfileEdit() {
{activeSection === 'account' ? (
<form className="space-y-4" onSubmit={saveAccountSection}>
<SectionCard
title="Account"
title={emailLoginUpgradeRequired ? 'Finish Email Upgrade' : 'Account'}
icon="fa-solid fa-id-badge"
description="Update your core account identity details."
actionSlot={
description={emailLoginUpgradeRequired ? 'Add and verify a real email address. Once completed, email becomes your only login identifier.' : 'Update your core account identity details.'}
actionSlot={!emailLoginUpgradeRequired ? (
<Button
type="submit"
variant="accent"
@@ -1116,11 +1149,25 @@ export default function ProfileEdit() {
>
Save Username
</Button>
}
) : null}
>
<ErrorMessage text={errorsBySection.account._general?.[0]} className="mb-4" />
<SuccessMessage text={sectionSaved} className="mb-4" />
{emailLoginUpgradeRequired ? (
<div className="mb-4 rounded-2xl border border-amber-400/30 bg-amber-500/10 p-4 text-sm text-amber-100">
<div className="flex items-start gap-3">
<i className="fa-solid fa-envelope-circle-check mt-0.5 shrink-0 text-amber-300" />
<div>
<p className="font-semibold text-amber-200">Email upgrade required</p>
<p className="mt-1 text-amber-100/90">
This account was migrated without a real email address. Add a valid email, enter the verification code, and future sign-ins will use email only.
</p>
</div>
</div>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<TextInput
label="Username"
@@ -1129,23 +1176,26 @@ export default function ProfileEdit() {
setAccountForm((prev) => ({ ...prev, username: e.target.value }))
clearSectionStatus('account')
}}
disabled={usernameCooldownActive}
disabled={emailLoginUpgradeRequired || usernameCooldownActive}
error={errorsBySection.account.username?.[0]}
hint={usernameCooldownActive ? `Username can be changed again in ${usernameCooldownRemainingDays} days.` : 'Allowed: letters, numbers, underscores (3-20).'}
hint={emailLoginUpgradeRequired ? 'Username login is temporary. Complete the email upgrade to switch this account to email-only sign-in.' : usernameCooldownActive ? `Username can be changed again in ${usernameCooldownRemainingDays} days.` : 'Allowed: letters, numbers, underscores (3-20).'}
required
/>
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-xs uppercase tracking-wide text-slate-400">Current Email</p>
<p className="text-xs uppercase tracking-wide text-slate-400">{emailLoginUpgradeRequired ? 'Primary Login Email' : 'Current Email'}</p>
<p className="mt-1 text-sm font-medium text-white">{accountForm.email || 'No email set'}</p>
{emailLoginUpgradeRequired ? (
<p className="mt-2 text-xs text-slate-400">The current value is temporary. Replace it with a real email address to finish the upgrade.</p>
) : null}
<div className="mt-3">
<Button type="button" variant="secondary" size="sm" onClick={openEmailChangeModal}>
Change Email
<Button type="button" variant={emailLoginUpgradeRequired ? 'accent' : 'secondary'} size="sm" onClick={openEmailChangeModal}>
{emailLoginUpgradeRequired ? 'Add Primary Email' : 'Change Email'}
</Button>
</div>
</div>
</div>
{usernameAvailability.status !== 'idle' ? (
{!emailLoginUpgradeRequired && usernameAvailability.status !== 'idle' ? (
<p
className={`mt-4 flex items-center gap-2 rounded-xl border px-3 py-2 text-xs ${
usernameAvailability.status === 'available'
@@ -1164,10 +1214,12 @@ export default function ProfileEdit() {
</p>
) : null}
<p className="mt-4 flex items-center gap-2 rounded-xl border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-xs text-slate-400">
<i className="fa-solid fa-clock shrink-0 text-slate-500" />
You can change your username once every {usernameCooldownDays} days.
</p>
{!emailLoginUpgradeRequired ? (
<p className="mt-4 flex items-center gap-2 rounded-xl border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-xs text-slate-400">
<i className="fa-solid fa-clock shrink-0 text-slate-500" />
You can change your username once every {usernameCooldownDays} days.
</p>
) : null}
{renderCaptchaChallenge('account')}
</SectionCard>
@@ -1518,13 +1570,16 @@ export default function ProfileEdit() {
<Modal
open={showEmailChangeModal}
onClose={closeEmailChangeModal}
title="Change Email"
title={emailLoginUpgradeRequired ? 'Finish Email Upgrade' : 'Change Email'}
size="sm"
closeOnBackdrop={!emailLoginUpgradeRequired}
footer={
<div className="ml-auto flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={closeEmailChangeModal} disabled={emailChangeLoading}>
Cancel
</Button>
{!emailLoginUpgradeRequired ? (
<Button type="button" variant="ghost" size="sm" onClick={closeEmailChangeModal} disabled={emailChangeLoading}>
Cancel
</Button>
) : null}
{emailChangeStep === 'request' ? (
<Button
type="button"
@@ -1550,6 +1605,12 @@ export default function ProfileEdit() {
}
>
<div className="space-y-4">
{emailLoginUpgradeRequired ? (
<div className="rounded-lg border border-sky-400/30 bg-sky-500/10 px-3 py-2 text-sm text-sky-200">
Add the email address you want to use for future sign-ins, then enter the verification code we send there.
</div>
) : null}
{emailChangeError ? (
<div className="rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{emailChangeError}

View File

@@ -11,6 +11,7 @@ import NovaSelect from '../../components/ui/NovaSelect'
import TagPicker from '../../components/tags/TagPicker'
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'
import ArtworkEvolutionSearchPicker from '../../components/artwork/ArtworkEvolutionSearchPicker'
import WorldSubmissionSelector from '../../components/worlds/WorldSubmissionSelector'
const EDIT_SECTIONS = [
{ id: 'taxonomy', label: 'Category', hint: 'Content type and category path' },
@@ -18,6 +19,7 @@ const EDIT_SECTIONS = [
{ id: 'evolution', label: 'Evolution', hint: 'Link an older original artwork' },
{ id: 'ai-assist', label: 'AI Assist', hint: 'Suggestions and similar matches' },
{ id: 'tags', label: 'Tags', hint: 'Search, add, and refine keywords' },
{ id: 'worlds', label: 'Worlds', hint: 'Community submissions and review state' },
{ id: 'visibility', label: 'Visibility', hint: 'Publishing state' },
]
@@ -26,6 +28,7 @@ const TABS = [
{ id: 'media', label: 'Media', icon: 'fa-solid fa-photo-film' },
{ id: 'evolution', label: 'Evolution', icon: 'fa-solid fa-code-branch' },
{ id: 'tags', label: 'Tags', icon: 'fa-solid fa-tags' },
{ id: 'worlds', label: 'Worlds', icon: 'fa-solid fa-globe' },
{ id: 'taxonomy', label: 'Category', icon: 'fa-solid fa-palette' },
{ id: 'visibility', label: 'Visibility', icon: 'fa-solid fa-eye' },
{ id: 'ai', label: 'AI Assist', icon: 'fa-solid fa-wand-magic-sparkles' },
@@ -218,6 +221,14 @@ function mapContributorCredits(contributorCredits = []) {
}, {})
}
function normalizeWorldSubmissionOptions(options = []) {
return (Array.isArray(options) ? options : []).map((world) => ({
...world,
selected: Boolean(world?.selected),
note: typeof world?.note === 'string' ? world.note : '',
}))
}
// ─── Sub-components ──────────────────────────────────────────────────────────
/** Glass-morphism section card (Nova theme) */
@@ -282,6 +293,7 @@ export default function StudioArtworkEdit() {
const contributorOptionsByGroup = props.contributorOptionsByGroup && typeof props.contributorOptionsByGroup === 'object'
? props.contributorOptionsByGroup
: {}
const initialWorldSubmissionOptions = Array.isArray(props.worldSubmissionOptions) ? props.worldSubmissionOptions : []
const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes])
@@ -299,6 +311,7 @@ export default function StudioArtworkEdit() {
const [primaryAuthorUserId, setPrimaryAuthorUserId] = useState(artwork?.primary_author_user_id || null)
const [contributorUserIds, setContributorUserIds] = useState(() => (Array.isArray(artwork?.contributor_user_ids) ? artwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : []))
const [contributorCredits, setContributorCredits] = useState(() => normalizeContributorCredits(artwork?.contributor_user_ids || [], mapContributorCredits(artwork?.contributor_credits || [])))
const [worldSubmissionOptions, setWorldSubmissionOptions] = useState(() => normalizeWorldSubmissionOptions(initialWorldSubmissionOptions))
const [titleSource, setTitleSource] = useState(artwork?.title_source || 'manual')
const [descriptionSource, setDescriptionSource] = useState(artwork?.description_source || 'manual')
const [tagsSource, setTagsSource] = useState(artwork?.tags_source || 'manual')
@@ -314,8 +327,6 @@ export default function StudioArtworkEdit() {
const [aiAction, setAiAction] = useState('')
const [aiDirect, setAiDirect] = useState(false)
const [isAiPanelOpen, setIsAiPanelOpen] = useState(true)
const [isAiDebugOpen, setIsAiDebugOpen] = useState(false)
const [lastAiRequest, setLastAiRequest] = useState(null)
const [selectedAiTags, setSelectedAiTags] = useState([])
const [activeTab, setActiveTab] = useState('details')
const [isCategoryChooserOpen, setIsCategoryChooserOpen] = useState(() => !artwork?.parent_category_id)
@@ -543,12 +554,6 @@ export default function StudioArtworkEdit() {
const direct = typeof options.direct === 'boolean' ? options.direct : aiDirect
const intent = options.intent || 'analyze'
const requestBody = { direct, intent }
setLastAiRequest({
endpoint: `/api/studio/artworks/${artwork.id}/ai/${action}`,
method: 'POST',
body: requestBody,
at: new Date().toISOString(),
})
const res = await fetch(`/api/studio/artworks/${artwork.id}/ai/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
@@ -574,12 +579,6 @@ export default function StudioArtworkEdit() {
if (!artwork?.id) return
setAiAction('apply')
try {
setLastAiRequest({
endpoint: `/api/studio/artworks/${artwork.id}/ai/apply`,
method: 'POST',
body: payload,
at: new Date().toISOString(),
})
const res = await fetch(`/api/studio/artworks/${artwork.id}/ai/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
@@ -685,11 +684,6 @@ export default function StudioArtworkEdit() {
})
}, [aiData, aiStatus, aiSuggestedTags, persistAiAction, trackAiEvent, triggerAi])
const aiDebugPayload = useMemo(() => ({
last_editor_request: lastAiRequest,
stored_debug: aiData?.debug || null,
}), [aiData?.debug, lastAiRequest])
const requestAiIntent = useCallback((intent, action = null) => {
const nextAction = action || (aiStatus === 'ready' ? 'regenerate' : 'analyze')
trackAiEvent('intent_requested', { intent, action: nextAction })
@@ -792,6 +786,13 @@ export default function StudioArtworkEdit() {
description_source: descriptionSource,
tags_source: tagsSource,
category_source: categorySource,
world_submissions: worldSubmissionOptions
.filter((world) => Boolean(world?.selected))
.map((world) => ({
world_id: Number(world.id),
note: typeof world.note === 'string' ? world.note : '',
}))
.filter((entry) => Number.isFinite(entry.world_id) && entry.world_id > 0),
evolution_target_artwork_id: evolutionTarget?.id || null,
evolution_relation_type: evolutionTarget ? evolutionRelationType : null,
evolution_note: evolutionTarget ? evolutionNote : null,
@@ -815,6 +816,9 @@ export default function StudioArtworkEdit() {
setContributorUserIds(Array.isArray(updatedArtwork.contributor_user_ids) ? updatedArtwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : [])
setContributorCredits(normalizeContributorCredits(updatedArtwork.contributor_user_ids || [], mapContributorCredits(updatedArtwork.contributor_credits || [])))
}
if (Array.isArray(data?.world_submission_options)) {
setWorldSubmissionOptions(normalizeWorldSubmissionOptions(data.world_submission_options))
}
setEvolutionTarget(updatedEvolutionRelation?.target_artwork || null)
setEvolutionRelationType(updatedEvolutionRelation?.relation_type || evolutionRelationTypes[0]?.value || 'remake_of')
setEvolutionNote(updatedEvolutionRelation?.note || '')
@@ -829,7 +833,7 @@ export default function StudioArtworkEdit() {
} finally {
setSaving(false)
}
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, evolutionTarget, evolutionRelationType, evolutionNote, artwork?.id, evolutionRelationTypes])
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, worldSubmissionOptions, evolutionTarget, evolutionRelationType, evolutionNote, artwork?.id, evolutionRelationTypes])
const handleFileReplace = async (file) => {
if (!file) return
@@ -2254,9 +2258,6 @@ export default function StudioArtworkEdit() {
<Button variant="ghost" size="xs" onClick={() => requestAiIntent('analyze', 'regenerate')} loading={aiAction === 'regenerate'}>
Refresh suggestions
</Button>
<Button variant="ghost" size="xs" onClick={() => setIsAiDebugOpen((current) => !current)}>
{isAiDebugOpen ? 'Hide debug' : 'Show debug'}
</Button>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
@@ -2284,34 +2285,6 @@ export default function StudioArtworkEdit() {
</div>
)}
{isAiDebugOpen && (
<div className="rounded-2xl border border-amber-400/20 bg-amber-400/[0.06] p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-white">AI debug</h4>
<p className="mt-1 text-xs text-slate-400">Inspect the editor request, the outbound vision POST payload, and the raw analysis returned to the suggestion builder.</p>
</div>
<button type="button" onClick={() => copyText(JSON.stringify(aiDebugPayload, null, 2))} className="text-xs text-slate-300 transition hover:text-white">Copy JSON</button>
</div>
<div className="grid gap-3 xl:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Editor request</div>
<pre className="mt-3 overflow-x-auto text-xs leading-6 text-slate-300 whitespace-pre-wrap">{JSON.stringify(lastAiRequest, null, 2)}</pre>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Vision request + response</div>
<pre className="mt-3 overflow-x-auto text-xs leading-6 text-slate-300 whitespace-pre-wrap">{JSON.stringify(aiData?.debug?.vision_debug || null, null, 2)}</pre>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Raw analysis used for suggestions</div>
<pre className="mt-3 overflow-x-auto text-xs leading-6 text-slate-300 whitespace-pre-wrap">{JSON.stringify(aiData?.debug?.analysis || null, null, 2)}</pre>
</div>
</div>
)}
<div className="grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
@@ -2487,6 +2460,23 @@ export default function StudioArtworkEdit() {
</Section>
)}
{activeTab === 'worlds' && (
<WorldSubmissionSelector
title="Add to Worlds"
description="Attach this artwork to active worlds for creator participation. These remain separate from moderator-curated world relations and keep their own review state."
options={worldSubmissionOptions}
emptyMessage="No worlds are currently open for creator participation, and this artwork has no existing world history yet."
onToggle={(worldId) => setWorldSubmissionOptions((current) => current.map((world) => (
Number(world.id) === Number(worldId) && !world.selection_locked
? { ...world, selected: !world.selected }
: world
)))}
onNoteChange={(worldId, note) => setWorldSubmissionOptions((current) => current.map((world) => (
Number(world.id) === Number(worldId) ? { ...world, note } : world
)))}
/>
)}
{/* ── Visibility tab ── */}
{activeTab === 'visibility' && (
<Section id="visibility" className="space-y-5">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioWorldsIndex() {
const { props } = usePage()
const listing = props.listing || {}
const items = Array.isArray(listing.items) ? listing.items : []
const filters = listing.filters || {}
const updateFilter = (name, value) => {
router.get('/studio/worlds', { ...filters, [name]: value }, { preserveState: true, replace: true })
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_12rem_12rem_auto] lg:items-end">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
<input value={filters.q || ''} onChange={(event) => updateFilter('q', event.target.value)} placeholder="Search title, slug, or summary" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</span>
<select value={filters.status || ''} onChange={(event) => updateFilter('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">All statuses</option>
{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select value={filters.type || ''} onChange={(event) => updateFilter('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">All types</option>
{(props.typeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<a href={props.createUrl} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-sky-300/20 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" />New world</a>
</div>
</section>
<section className="grid gap-4 xl:grid-cols-2">
{items.length > 0 ? items.map((world) => (
<a key={world.id} href={world.edit_url} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 transition hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1">{world.status}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1">{world.type}</span>
{world.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-amber-100">Featured</span> : null}
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.03em] text-white">{world.title}</h2>
<div className="mt-2 text-sm text-slate-500">/{world.slug}</div>
{world.summary ? <p className="mt-4 text-sm leading-6 text-slate-300">{world.summary}</p> : null}
<div className="mt-5 flex flex-wrap gap-4 text-sm text-slate-400">
{world.timeframe_label ? <span>{world.timeframe_label}</span> : null}
<span>{world.relation_count} relations</span>
{world.theme_key ? <span>{world.theme_key}</span> : null}
</div>
<div className="mt-5 flex flex-wrap gap-3 text-sm font-semibold">
<span className="text-sky-100">Edit</span>
<span className="text-slate-500">Preview</span>
{world.public_url ? <span className="text-slate-500">Public</span> : null}
</div>
</a>
)) : (
<div className="rounded-[28px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No worlds match this filter yet.</div>
)}
</section>
</div>
</StudioLayout>
)
}

View File

@@ -652,6 +652,7 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
chunkRequestTimeoutMs={chunkRequestTimeoutMs}
contentTypes={Array.isArray(props?.content_types) ? props.content_types : []}
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
eligibleWorlds={Array.isArray(props?.eligible_worlds) ? props.eligible_worlds : []}
groupOptions={Array.isArray(props?.group_options) ? props.group_options : []}
contributorOptionsByGroup={props?.contributor_options_by_group && typeof props.contributor_options_by_group === 'object' ? props.contributor_options_by_group : {}}
initialGroupSlug={props?.initial_group || ''}

View File

@@ -0,0 +1,64 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import SeoHead from '../../components/seo/SeoHead'
import WorldCard from '../../components/worlds/WorldCard'
function WorldRail({ title, description, items }) {
if (!Array.isArray(items) || items.length === 0) {
return null
}
return (
<section className="mt-10">
<div className="mb-5 flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
{description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{description}</p> : null}
</div>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{items.length} worlds</div>
</div>
<div className="grid gap-4 xl:grid-cols-3">
{items.map((world) => <WorldCard key={world.id} world={world} compact />)}
</div>
</section>
)
}
export default function WorldIndex() {
const { props } = usePage()
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(249,115,22,0.12),_transparent_28%),radial-gradient(circle_at_top_right,_rgba(56,189,248,0.12),_transparent_32%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
<SeoHead title={props.seo?.title || 'Worlds - Skinbase Nova'} description={props.seo?.description || props.description} image={props.seo?.image} />
<div className="mx-auto max-w-7xl">
<section className="rounded-[36px] border border-white/10 bg-white/[0.03] p-6 sm:p-8">
<div className="max-w-4xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Skinbase Nova Worlds</p>
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl">Curated spaces for seasonal culture, scene moments, and editorial campaigns.</h1>
<p className="mt-5 max-w-3xl text-base leading-7 text-slate-300">Worlds bundle together artworks, collections, creators, groups, cards, releases, events, challenges, and newsroom context into a single themed destination. They are not filters. They are editorial environments.</p>
</div>
</section>
{props.featuredWorld ? <section className="mt-8"><WorldCard world={props.featuredWorld} /></section> : null}
<WorldRail
title="Active Worlds"
description="Campaigns and seasonal surfaces currently live across the platform."
items={props.activeWorlds}
/>
<WorldRail
title="Upcoming Worlds"
description="Scheduled worlds in the pipeline, ready to anchor the next publishing moment."
items={props.upcomingWorlds}
/>
<WorldRail
title="Archive Editions"
description="Past worlds stay available as browsable records of recurring culture and editorial programming."
items={props.archivedWorlds}
/>
</div>
</main>
)
}

View File

@@ -0,0 +1,76 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import SeoHead from '../../components/seo/SeoHead'
import WorldHero from '../../components/worlds/WorldHero'
import WorldCommunitySubmissionsSection from '../../components/worlds/WorldCommunitySubmissionsSection'
import WorldSection from '../../components/worlds/WorldSection'
import WorldCard from '../../components/worlds/WorldCard'
function SupportingRail({ title, description, items }) {
if (!Array.isArray(items) || items.length === 0) {
return null
}
return (
<section className="mt-10">
<div className="mb-5 flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
{description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{description}</p> : null}
</div>
</div>
<div className="grid gap-4 xl:grid-cols-3">
{items.map((item) => <WorldCard key={item.id} world={item} compact />)}
</div>
</section>
)
}
export default function WorldShow() {
const { props } = usePage()
const world = props.world
const sections = Array.isArray(props.sections) ? props.sections : []
const communitySubmissions = props.communitySubmissions || null
const previewMode = Boolean(props.previewMode)
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.12),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
<SeoHead title={props.seo?.title || `${world?.title || 'World'} - Skinbase Nova`} description={props.seo?.description || world?.summary} image={props.seo?.image} />
<div className="mx-auto max-w-7xl">
{previewMode ? (
<section className="mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 px-5 py-4 text-sm text-amber-50">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Studio preview</div>
<div className="mt-1 font-semibold text-white">You are viewing the editorial preview version of this world before or alongside public release.</div>
</div>
{world?.public_url ? <a href={world.public_url} className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white">Open canonical page <i className="fa-solid fa-up-right-from-square" /></a> : null}
</div>
</section>
) : null}
<WorldHero world={world} previewMode={previewMode} />
{sections.length > 0 ? sections.map((section) => <WorldSection key={section.key} section={section} />) : (
<section className="mt-10 rounded-[28px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">
This world has been themed and published, but no curated sections have been attached yet.
</section>
)}
<WorldCommunitySubmissionsSection section={communitySubmissions} />
<SupportingRail
title="Archive Editions"
description="Past iterations remain accessible so recurring worlds can build continuity over time."
items={props.archiveEditions}
/>
<SupportingRail
title="Related Worlds"
description="Other worlds sharing the same recurrence, theme, or editorial lineage."
items={props.relatedWorlds}
/>
</div>
</main>
)
}

View File

@@ -218,7 +218,9 @@ export default function SearchBar({ placeholder = 'Search artworks, groups, arti
// ── navigation helpers ───────────────────────────────────────────────────
function navigate(item) {
if (item.type === 'artwork') window.location.href = item.urls?.web ?? `/${item.slug ?? ''}`
if (item.type === 'artwork') {
window.location.href = item.urls?.direct ?? `/art/${item.id ?? ''}/${item.slug ?? ''}`
}
else if (item.type === 'group') window.location.href = item.urls?.public ?? item.profile_url ?? `/groups/${item.slug ?? ''}`
else if (item.type === 'user') window.location.href = item.profile_url ?? `/@${item.username}`
else window.location.href = `/tags/${item.slug ?? item.name}`

View File

@@ -18,6 +18,11 @@ const pages = {
'!./Pages/Group/**/__tests__/**',
'!./Pages/Group/**/*.test.jsx',
]),
...import.meta.glob([
'./Pages/World/**/*.jsx',
'!./Pages/World/**/__tests__/**',
'!./Pages/World/**/*.test.jsx',
]),
}
function resolvePage(name) {

View File

@@ -21,6 +21,7 @@ export default function Topbar({ user = null }) {
</div>
<div className="flex items-center gap-3 sm:gap-4">
<a href="/worlds" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Worlds</a>
<a href="/groups" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Groups</a>
<a href="/community/activity" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Community</a>
<a href="/forum" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Forum</a>

View File

@@ -42,6 +42,15 @@ function DownloadArrowIcon() {
)
}
function ChartIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v18h18" />
<path strokeLinecap="round" strokeLinejoin="round" d="M7 15.5 10.5 12l3 2.5 4.5-6" />
</svg>
)
}
/* ShareIcon removed — now provided by ArtworkShareButton */
function FlagIcon() {
@@ -204,6 +213,8 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
}, [artwork?.id, artwork?.stats?.bookmarks, stats?.bookmarks])
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
const analyticsUrl = artwork?.management?.analytics_url
|| (artwork?.viewer?.is_owner ? `/studio/artworks/${artwork.id}/analytics` : null)
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
@@ -337,6 +348,16 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
{/* Share pill */}
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="default" isLoggedIn={isLoggedIn} />
{analyticsUrl ? (
<a
href={analyticsUrl}
className="inline-flex items-center gap-2 rounded-full border border-sky-400/30 bg-sky-400/12 px-5 py-2.5 text-sm font-medium text-sky-100 transition-all duration-200 hover:border-sky-300/45 hover:bg-sky-400/18 hover:text-white"
>
<ChartIcon />
Statistics
</a>
) : null}
{/* Report pill */}
<button
type="button"
@@ -403,6 +424,17 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
{/* Share */}
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="small" isLoggedIn={isLoggedIn} />
{analyticsUrl ? (
<a
href={analyticsUrl}
aria-label="Open artwork statistics"
title="Statistics"
className="inline-flex items-center gap-1.5 rounded-full border border-sky-400/30 bg-sky-400/12 px-3.5 py-2 text-xs font-medium text-sky-100 transition-all hover:border-sky-300/45 hover:bg-sky-400/18 hover:text-white"
>
<ChartIcon />
</a>
) : null}
{/* Report */}
<button
type="button"

View File

@@ -493,11 +493,15 @@ export default function ArtworkCard({
|| item.content_type_slug
|| ''
)
const category = decodeHtml(item.category || item.category_name || '')
const category = decodeHtml(
(typeof item.category === 'string' ? item.category : item.category?.name)
|| item.category_name
|| ''
)
const width = Number(item.width ?? 0)
const height = Number(item.height ?? 0)
const resolution = decodeHtml(item.resolution || ((width > 0 && height > 0) ? `${width}x${height}` : ''))
const href = item.url || item.href || (item.id ? `/art/${item.id}/${slugify(title)}` : '#')
const href = item.canonical_url || item.urls?.canonical || item.urls?.direct || item.url || item.href || (item.id ? `/art/${item.id}/${slugify(title)}` : '#')
const downloadHref = item.download_url || item.downloadHref || (item.id ? `/download/artwork/${item.id}` : href)
const cardLabel = `${title} by ${author}`
const aspectClass = compact ? 'aspect-square' : 'aspect-[4/5]'

View File

@@ -1,5 +1,6 @@
import React, { useMemo } from 'react'
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
import ArtworkFormatBadges from './ArtworkFormatBadges'
function formatCount(value) {
const number = Number(value || 0)
@@ -60,9 +61,10 @@ export default function ArtworkDetailsDrawer({ isOpen, onClose, artwork, stats }
</div>
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5 sm:col-span-2">
<dt className="text-soft">Resolution</dt>
<dd className="mt-1 font-medium text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
<ArtworkFormatBadges width={width} height={height} className="mt-2" />
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
<dt className="text-soft">Upload date</dt>

View File

@@ -1,4 +1,5 @@
import React from 'react'
import ArtworkFormatBadges from './ArtworkFormatBadges'
function formatCount(value) {
const n = Number(value || 0)
@@ -76,7 +77,15 @@ export default function ArtworkDetailsPanel({ artwork, stats }) {
{/* Info rows */}
<div className="mt-4 divide-y divide-white/[0.05]">
{resolution && <InfoRow label="Resolution" value={resolution} />}
{resolution ? (
<div className="py-2">
<div className="flex items-center justify-between gap-4">
<span className="text-xs uppercase tracking-wider text-white/35">Resolution</span>
<span className="text-sm font-medium text-white/80">{resolution}</span>
</div>
<ArtworkFormatBadges width={width} height={height} className="mt-2" />
</div>
) : null}
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at)} />
</div>
</section>

View File

@@ -0,0 +1,267 @@
import React from 'react'
const RESOLUTION_TIERS = [
{ label: '8K', width: 7680, height: 4320, tone: 'amber' },
{ label: '5K', width: 5120, height: 2880, tone: 'violet' },
{ label: '4K', width: 3840, height: 2160, tone: 'sky' },
{ label: 'QHD', width: 2560, height: 1440, tone: 'emerald' },
{ label: 'Full HD', width: 1920, height: 1080, tone: 'cyan' },
{ label: 'HD', width: 1280, height: 720, tone: 'slate' },
]
const ASPECT_RATIOS = [
{ label: '21:9', ratio: 21 / 9 },
{ label: '16:10', ratio: 16 / 10 },
{ label: '16:9', ratio: 16 / 9 },
{ label: '3:2', ratio: 3 / 2 },
{ label: '4:3', ratio: 4 / 3 },
{ label: '1:1', ratio: 1 },
{ label: '4:5', ratio: 4 / 5 },
{ label: '3:4', ratio: 3 / 4 },
{ label: '9:16', ratio: 9 / 16 },
]
const TONE_CLASSES = {
amber: 'border-amber-400/25 bg-amber-400/10 text-amber-100',
violet: 'border-violet-400/25 bg-violet-400/10 text-violet-100',
sky: 'border-sky-400/25 bg-sky-400/10 text-sky-100',
emerald: 'border-emerald-400/25 bg-emerald-400/10 text-emerald-100',
cyan: 'border-cyan-400/25 bg-cyan-400/10 text-cyan-100',
slate: 'border-white/10 bg-white/[0.04] text-white/80',
}
function ScreenIcon({ className }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25h6m-3 0v2.25m-7.5-15h15A1.5 1.5 0 0 1 21 6v9A1.5 1.5 0 0 1 19.5 16.5h-15A1.5 1.5 0 0 1 3 15V6A1.5 1.5 0 0 1 4.5 4.5Z" />
</svg>
)
}
function RatioIcon({ className }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 8.25V6A1.5 1.5 0 0 1 6 4.5h2.25m7.5 0H18A1.5 1.5 0 0 1 19.5 6v2.25m0 7.5V18A1.5 1.5 0 0 1 18 19.5h-2.25m-7.5 0H6A1.5 1.5 0 0 1 4.5 18v-2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 12h7.5" />
</svg>
)
}
function FormatIcon({ className, variant }) {
if (variant === 'ultrawide') {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<rect x="3.75" y="7.5" width="16.5" height="9" rx="2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 12h9" />
</svg>
)
}
if (variant === 'vertical') {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<rect x="7.25" y="3.75" width="9.5" height="16.5" rx="2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 7.5v9" />
</svg>
)
}
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 18c2.5-5.5 5-8.25 7.5-8.25S17 12.5 19.5 18" />
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 6.75h9" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9 4.5h6v4.5H9z" />
</svg>
)
}
function OrientationIcon({ className, orientation }) {
if (orientation === 'square') {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<rect x="5.5" y="5.5" width="13" height="13" rx="2.25" />
</svg>
)
}
if (orientation === 'portrait') {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<rect x="7.25" y="4.5" width="9.5" height="15" rx="2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8.25v7.5" />
</svg>
)
}
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<rect x="4.5" y="7.25" width="15" height="9.5" rx="2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 12h7.5" />
</svg>
)
}
function Badge({ label, tone, icon }) {
return (
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] ${TONE_CLASSES[tone] || TONE_CLASSES.slate}`}>
<span className="text-current/90">{icon}</span>
<span>{label}</span>
</span>
)
}
function pickResolutionTier(width, height) {
const longSide = Math.max(width, height)
const shortSide = Math.min(width, height)
for (const tier of RESOLUTION_TIERS) {
if (longSide >= tier.width && shortSide >= tier.height) {
return tier
}
}
return null
}
function pickOrientation(width, height) {
if (width === height) {
return {
key: 'orientation-square',
label: 'Square',
tone: 'amber',
icon: <OrientationIcon className="h-3.5 w-3.5" orientation="square" />,
isSquare: true,
}
}
if (width > height) {
return {
key: 'orientation-landscape',
label: 'Landscape',
tone: 'emerald',
icon: <OrientationIcon className="h-3.5 w-3.5" orientation="landscape" />,
isSquare: false,
}
}
return {
key: 'orientation-portrait',
label: 'Portrait',
tone: 'violet',
icon: <OrientationIcon className="h-3.5 w-3.5" orientation="portrait" />,
isSquare: false,
}
}
function pickAspectRatio(width, height) {
const ratio = width / height
let best = null
for (const candidate of ASPECT_RATIOS) {
const delta = Math.abs(ratio - candidate.ratio) / candidate.ratio
if (delta > 0.03) {
continue
}
if (best === null || delta < best.delta) {
best = { ...candidate, delta }
}
}
return best
}
function pickSemanticFormat(width, height, aspectRatio, orientation) {
if (!orientation || orientation.isSquare) {
return null
}
const ratio = width / height
if (ratio >= 2.1) {
return {
key: 'semantic-ultrawide',
label: 'Ultrawide',
tone: 'sky',
icon: <FormatIcon className="h-3.5 w-3.5" variant="ultrawide" />,
}
}
if (ratio <= 0.75) {
return {
key: 'semantic-vertical',
label: 'Vertical',
tone: 'violet',
icon: <FormatIcon className="h-3.5 w-3.5" variant="vertical" />,
}
}
if (aspectRatio && ['4:3', '3:2', '16:10'].includes(aspectRatio.label)) {
return {
key: 'semantic-classic',
label: 'Classic',
tone: 'amber',
icon: <FormatIcon className="h-3.5 w-3.5" variant="classic" />,
}
}
return null
}
export function getArtworkFormatBadges(width, height) {
if (!(width > 0 && height > 0)) {
return []
}
const badges = []
const orientation = pickOrientation(width, height)
const resolutionTier = pickResolutionTier(width, height)
if (resolutionTier) {
badges.push({
key: `resolution-${resolutionTier.label}`,
label: resolutionTier.label,
tone: resolutionTier.tone,
icon: <ScreenIcon className="h-3.5 w-3.5" />,
})
}
if (orientation) {
badges.push(orientation)
}
const aspectRatio = pickAspectRatio(width, height)
const semanticFormat = pickSemanticFormat(width, height, aspectRatio, orientation)
if (semanticFormat) {
badges.push(semanticFormat)
}
if (aspectRatio && !orientation?.isSquare) {
badges.push({
key: `ratio-${aspectRatio.label}`,
label: aspectRatio.label,
tone: 'slate',
icon: <RatioIcon className="h-3.5 w-3.5" />,
})
}
return badges
}
export default function ArtworkFormatBadges({ width, height, className = '' }) {
const badges = getArtworkFormatBadges(width, height)
if (badges.length === 0) {
return null
}
return (
<div className={`flex flex-wrap gap-2 ${className}`.trim()}>
{badges.map((badge) => (
<Badge key={badge.key} label={badge.label} tone={badge.tone} icon={badge.icon} />
))}
</div>
)
}

View File

@@ -1,11 +1,12 @@
import React from 'react'
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
import WorldParticipationBadge from './WorldParticipationBadge'
export default function ArtworkMeta({ artwork }) {
const publisher = artwork?.publisher || null
const credits = artwork?.credits || {}
const primaryAuthor = credits?.primary_author || artwork?.user || null
const contributors = Array.isArray(credits?.contributors) ? credits.contributors : []
const worldParticipation = Array.isArray(artwork?.world_participation) ? artwork.world_participation : []
return (
<div>
@@ -17,12 +18,6 @@ export default function ArtworkMeta({ artwork }) {
<span className="font-semibold">{publisher.name}</span>
</a>
) : null}
{primaryAuthor ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Primary author</span>
{primaryAuthor.profile_url ? <a href={primaryAuthor.profile_url} className="font-semibold text-white hover:text-sky-200">{primaryAuthor.name || primaryAuthor.username}</a> : <span className="font-semibold text-white">{primaryAuthor.name || primaryAuthor.username}</span>}
</span>
) : null}
{contributors.length > 0 ? (
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white">
@@ -45,6 +40,7 @@ export default function ArtworkMeta({ artwork }) {
<div className="mt-3">
<ArtworkBreadcrumbs artwork={artwork} />
</div>
<WorldParticipationBadge items={worldParticipation} />
</div>
)
}

View File

@@ -1,4 +1,5 @@
import React from 'react'
import ArtworkFormatBadges from './ArtworkFormatBadges'
function formatCount(value) {
const number = Number(value || 0)
@@ -35,6 +36,7 @@ export default function ArtworkStats({ artwork, stats: statsProp }) {
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:col-span-2 sm:block">
<dt className="text-soft">Resolution</dt>
<dd className="mt-1 font-medium text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
<ArtworkFormatBadges width={width} height={height} className="mt-2" />
</div>
</dl>
</section>

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
function galleryUrlFor(author) {
if (!author?.username) return null
return `/@${author.username}/gallery`
}
export default function AuthorBioPopover({ author }) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [bio, setBio] = useState(undefined)
const [error, setError] = useState('')
const username = author?.username || ''
const profileUrl = author?.profile_url || (username ? `/@${username}` : null)
const galleryUrl = galleryUrlFor(author)
useEffect(() => {
if (!open) return undefined
function onKeyDown(event) {
if (event.key === 'Escape') {
setOpen(false)
}
}
document.addEventListener('keydown', onKeyDown)
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
}
}, [open])
async function loadBio() {
if (!username || loading || bio !== undefined) {
return
}
setLoading(true)
setError('')
try {
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/ai-biography`, {
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
})
if (!response.ok) {
throw new Error(`Failed to load biography (${response.status})`)
}
const payload = await response.json()
setBio(payload?.data?.text || null)
} catch {
setError('Biography is unavailable right now.')
setBio(null)
} finally {
setLoading(false)
}
}
if (!username || !profileUrl) {
return null
}
const dialog = open ? createPortal(
<div className="fixed inset-0 z-[220] overflow-y-auto">
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-md" aria-hidden="true" />
<div className="flex min-h-screen items-center justify-center p-4 sm:p-6 lg:p-8">
<div
role="dialog"
aria-modal="true"
aria-label={`About ${author?.name || author?.username || 'author'}`}
className="relative z-[221] flex max-h-[min(88vh,52rem)] w-full max-w-2xl flex-col overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/96 p-5 shadow-[0_36px_100px_rgba(2,6,23,0.75)] backdrop-blur-xl sm:p-6 lg:p-7"
>
<button
type="button"
aria-label="Close author biography overlay"
onClick={() => setOpen(false)}
className="absolute inset-0"
/>
<div className="relative z-10 flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/70">About the author</p>
<p className="mt-1 text-xl font-semibold text-white sm:text-2xl">{author?.name || author?.username}</p>
<p className="text-sm text-white/40 sm:text-base">@{username}</p>
</div>
<button
type="button"
aria-label="Close author biography"
onClick={() => setOpen(false)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-white/60 transition hover:bg-white/[0.08] hover:text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="relative z-10 mt-5 min-h-0 flex-1 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03]">
<div className="max-h-full overflow-y-auto p-4 text-[15px] leading-8 text-white/85 sm:p-5 sm:text-base lg:text-[17px] lg:leading-8">
{loading ? <p className="text-white/60">Loading biography...</p> : null}
{!loading && error ? <p className="text-rose-200/90">{error}</p> : null}
{!loading && !error && bio ? <p>{bio}</p> : null}
{!loading && !error && bio === null ? <p className="text-white/60">No public biography available yet.</p> : null}
</div>
</div>
<div className="relative z-10 mt-5 flex shrink-0 flex-wrap gap-3">
<a
href={profileUrl}
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/[0.08]"
>
View profile
</a>
{galleryUrl ? (
<a
href={galleryUrl}
className="inline-flex items-center gap-2 rounded-xl border border-sky-300/20 bg-sky-300/10 px-4 py-2.5 text-sm font-medium text-sky-100 transition hover:border-sky-300/30 hover:bg-sky-300/16"
>
Open gallery
</a>
) : null}
</div>
</div>
</div>
</div>,
document.body,
) : null
return (
<span className="relative inline-flex items-center">
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open ? 'true' : 'false'}
aria-label={`More about ${author?.name || author?.username || 'this author'}`}
onClick={() => {
const nextOpen = !open
setOpen(nextOpen)
if (!open) {
void loadBio()
}
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-sky-300/20 bg-sky-300/8 text-sky-100/80 transition hover:border-sky-300/35 hover:bg-sky-300/14 hover:text-sky-50"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25 12 12v4.5m0-8.25h.008v.008H12V8.25Zm9 3.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
{dialog}
</span>
)
}

View File

@@ -0,0 +1,68 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AuthorBioPopover from './AuthorBioPopover'
describe('AuthorBioPopover', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('loads and shows the public biography when opened', async () => {
const user = userEvent.setup()
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
data: {
text: 'Gregor has spent decades building a public portfolio across wallpapers and digital art.',
},
}),
})
render(
<AuthorBioPopover author={{ name: 'Gregor', username: 'gregor', profile_url: '/@gregor' }} />,
)
await user.click(screen.getByRole('button', { name: /more about gregor/i }))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})
expect(fetchMock).toHaveBeenCalledWith(
'/api/profile/gregor/ai-biography',
expect.objectContaining({ credentials: 'same-origin' }),
)
expect(await screen.findByText(/spent decades building a public portfolio/i)).not.toBeNull()
expect(screen.getByRole('link', { name: /view profile/i }).getAttribute('href')).toBe('/@gregor')
expect(screen.getByRole('link', { name: /open gallery/i }).getAttribute('href')).toBe('/@gregor/gallery')
})
it('shows a fallback message when no public biography exists', async () => {
const user = userEvent.setup()
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ data: null }),
})
render(
<AuthorBioPopover author={{ name: 'Gregor', username: 'gregor', profile_url: '/@gregor' }} />,
)
await user.click(screen.getByRole('button', { name: /more about gregor/i }))
expect(await screen.findByText(/no public biography available yet/i)).not.toBeNull()
})
})

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react'
import AuthorBioPopover from './AuthorBioPopover'
import FollowButton from '../social/FollowButton'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
@@ -15,6 +16,9 @@ function toCard(item) {
id: item?.id || item?.slug || item?.url,
title: item?.title,
author: item?.author,
authorId: Number(item?.author_id || 0),
publisherType: item?.publisher_type || 'user',
publisherId: Number(item?.publisher_id || 0),
url: item?.url,
thumb: item?.thumb,
thumbSrcSet: item?.thumb_srcset,
@@ -28,21 +32,33 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
const [followersCount, setFollowersCount] = useState(Number(isGroupPublisher ? publisher?.followers_count || 0 : artwork?.user?.followers_count || 0))
const user = artwork?.credits?.primary_author || artwork?.user || {}
const primaryAuthor = artwork?.credits?.primary_author || null
const bioAuthor = isGroupPublisher ? primaryAuthor : user
const isOwnArtwork = Number(artwork?.viewer?.id || 0) > 0 && Number(artwork?.viewer?.id) === Number(user.id || 0)
const authorName = isGroupPublisher ? (publisher?.name || 'Group') : (user.name || user.username || 'Artist')
const profileUrl = isGroupPublisher ? (publisher?.profile_url || '#') : (user.profile_url || (user.username ? `/@${user.username}` : '#'))
const avatar = (isGroupPublisher ? publisher?.avatar_url : user.avatar_url) || presentSq?.url || AVATAR_FALLBACK
const creatorItems = useMemo(() => {
const currentAuthorId = Number(user?.id || 0)
const currentPublisherId = Number(publisher?.id || user?.id || 0)
const filtered = (Array.isArray(related) ? related : []).filter((item) => {
const sameAuthor = String(item?.author || '').trim().toLowerCase() === String(authorName || '').trim().toLowerCase()
const notCurrent = item?.url && item.url !== artwork?.canonical_url
return sameAuthor && notCurrent
if (!notCurrent) {
return false
}
if (isGroupPublisher) {
return item?.publisher_type === 'group' && Number(item?.publisher_id || 0) === currentPublisherId
}
return Number(item?.author_id || 0) === currentAuthorId
})
const source = filtered.length > 0 ? filtered : (Array.isArray(related) ? related : [])
return source.slice(0, 12).map(toCard)
}, [related, authorName, artwork?.canonical_url])
return filtered.slice(0, 12).map(toCard)
}, [related, isGroupPublisher, publisher?.id, user?.id, artwork?.canonical_url])
return (
<>
@@ -62,11 +78,18 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
/>
</a>
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
{authorName}
</a>
{!isGroupPublisher && user.username && <p className="text-xs text-white/40">@{user.username}</p>}
{isGroupPublisher && artwork?.credits?.primary_author ? <p className="text-xs text-white/40">Primary author: {artwork.credits.primary_author.name || artwork.credits.primary_author.username}</p> : null}
<div className="relative mt-3 w-full px-10 text-center">
<a href={profileUrl} className="block text-base font-bold text-white transition-colors hover:text-accent">
{authorName}
</a>
{!isGroupPublisher && user.username ? <p className="text-xs text-white/40">@{user.username}</p> : null}
{isGroupPublisher && primaryAuthor ? <p className="text-xs text-white/40">Primary author: {primaryAuthor.name || primaryAuthor.username}</p> : null}
{bioAuthor?.username ? (
<span className="absolute right-0 top-1/2 -translate-y-1/2">
<AuthorBioPopover author={bioAuthor} />
</span>
) : null}
</div>
<p className="mt-1 text-xs font-medium text-white/30">
{followersCount.toLocaleString()} Followers
</p>

View File

@@ -0,0 +1,79 @@
import React from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import CreatorSpotlight from './CreatorSpotlight'
describe('CreatorSpotlight related rail', () => {
afterEach(() => {
cleanup()
})
it('shows only artworks from the same author id', () => {
render(
<CreatorSpotlight
artwork={{
canonical_url: '/art/470/words',
viewer: { id: 2 },
user: { id: 2, name: 'psych0', username: 'psych0', profile_url: '/@psych0', followers_count: 25 },
credits: {},
}}
presentSq={{ url: '/thumb/current.jpg' }}
related={[
{
id: 101,
title: 'Same author work',
author: 'Completely different display name',
author_id: 2,
publisher_type: 'user',
publisher_id: 2,
url: '/art/101/same-author-work',
thumb: '/thumb/101.jpg',
},
{
id: 202,
title: 'Wrong author work',
author: 'psych0',
author_id: 99,
publisher_type: 'user',
publisher_id: 99,
url: '/art/202/wrong-author-work',
thumb: '/thumb/202.jpg',
},
]}
/>,
)
expect(screen.getByText(/more from psych0/i)).not.toBeNull()
expect(screen.getByRole('link', { name: /same author work/i }).getAttribute('href')).toBe('/art/101/same-author-work')
expect(screen.queryByRole('link', { name: /wrong author work/i })).toBeNull()
})
it('hides the rail when there are no same-author works', () => {
render(
<CreatorSpotlight
artwork={{
canonical_url: '/art/470/words',
viewer: { id: 2 },
user: { id: 2, name: 'psych0', username: 'psych0', profile_url: '/@psych0', followers_count: 25 },
credits: {},
}}
presentSq={{ url: '/thumb/current.jpg' }}
related={[
{
id: 202,
title: 'Wrong author work',
author: 'psych0',
author_id: 99,
publisher_type: 'user',
publisher_id: 99,
url: '/art/202/wrong-author-work',
thumb: '/thumb/202.jpg',
},
]}
/>,
)
expect(screen.queryByText(/more from psych0/i)).toBeNull()
expect(screen.queryByRole('link', { name: /wrong author work/i })).toBeNull()
})
})

View File

@@ -0,0 +1,48 @@
import React from 'react'
function toneClasses(tone) {
switch (tone) {
case 'featured':
return 'border-amber-300/30 bg-amber-400/12 text-amber-50 hover:border-amber-300/45 hover:bg-amber-400/18'
case 'community':
return 'border-sky-300/25 bg-sky-300/10 text-sky-100 hover:border-sky-300/40 hover:bg-sky-300/15'
case 'curated':
return 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100 hover:border-emerald-300/40 hover:bg-emerald-400/15'
default:
return 'border-white/10 bg-white/[0.04] text-white hover:border-white/20 hover:bg-white/[0.07]'
}
}
export default function WorldParticipationBadge({ items = [] }) {
const badges = Array.isArray(items) ? items : []
if (badges.length === 0) {
return null
}
return (
<div className="mt-4 flex flex-wrap items-center gap-2.5">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">World participation</span>
{badges.map((item) => {
const label = item?.badge_label || item?.world_title || 'World participation'
const badgeClassName = `inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition ${toneClasses(item?.tone)}`
if (item?.world_url) {
return (
<a key={`${item.world_id}-${item.status || item.tone || 'world'}`} href={item.world_url} className={badgeClassName}>
<i className="fa-solid fa-globe text-[11px]" />
<span>{label}</span>
</a>
)
}
return (
<span key={`${item.world_id}-${item.status || item.tone || 'world'}`} className={badgeClassName}>
<i className="fa-solid fa-globe text-[11px]" />
<span>{label}</span>
</span>
)
})}
</div>
)
}

View File

@@ -109,7 +109,7 @@ async function fetchPageData(url) {
: null;
return {
artworks,
artworks: artworks.map(normalizeArtworkItem),
nextCursor,
nextPageUrl,
hasMore,
@@ -127,7 +127,7 @@ async function fetchPageData(url) {
try { artworks = JSON.parse(el.dataset.artworks || '[]'); } catch { /* empty */ }
return {
artworks,
artworks: artworks.map(normalizeArtworkItem),
nextCursor: el.dataset.nextCursor || null,
nextPageUrl: el.dataset.nextPageUrl || null,
hasMore: null,
@@ -142,37 +142,61 @@ function SkeletonCard() {
// ── Ranking API helpers ───────────────────────────────────────────────────
/**
* Map a single ArtworkListResource item (from /api/rank/*) to the internal
* artwork object shape used by ArtworkCard.
* Normalize API / Blade artwork payloads into the internal shape used by the
* gallery layout helpers and ArtworkCard.
*/
function mapRankApiArtwork(item) {
const w = item.dimensions?.width ?? null;
const h = item.dimensions?.height ?? null;
const thumb = item.thumbnail_url ?? null;
const webUrl = item.urls?.web ?? item.category?.url ?? null;
function normalizeArtworkItem(item) {
if (!item || typeof item !== 'object') return item;
const category = item.category && typeof item.category === 'object' ? item.category : null;
const author = item.author && typeof item.author === 'object'
? item.author
: (item.creator && typeof item.creator === 'object' ? item.creator : null);
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : null;
const w = item.dimensions?.width ?? item.width ?? null;
const h = item.dimensions?.height ?? item.height ?? null;
const thumb = item.thumbnail_url ?? item.thumb_url ?? item.thumb ?? item.image ?? null;
const canonicalUrl = item.canonical_url
?? item.urls?.canonical
?? item.urls?.direct
?? item.url
?? item.href
?? item.urls?.web
?? category?.url
?? null;
return {
...item,
id: item.id ?? null,
name: item.title ?? item.name ?? null,
thumb: thumb,
thumb_url: thumb,
uname: item.author?.name ?? '',
username: publisher?.type === 'group' ? '' : (item.author?.username ?? ''),
avatar_url: item.author?.avatar_url ?? null,
profile_url: publisher?.profile_url ?? item.author?.profile_url ?? null,
published_as_type: publisher?.type ?? null,
publisher: publisher,
content_type_name: item.category?.content_type_name ?? item.category?.content_type_slug ?? item.category?.content_type ?? '',
content_type_slug: item.category?.content_type_slug ?? item.category?.content_type ?? '',
category_name: item.category?.name ?? '',
category_slug: item.category?.slug ?? '',
name: item.name ?? item.title ?? null,
title: item.title ?? item.name ?? null,
thumb: item.thumb ?? thumb,
thumb_url: item.thumb_url ?? thumb,
thumbnail_url: item.thumbnail_url ?? thumb,
author,
uname: item.uname ?? author?.name ?? item.author_name ?? '',
username: item.username ?? (publisher?.type === 'group' ? '' : (author?.username ?? '')),
avatar_url: item.avatar_url ?? author?.avatar_url ?? null,
profile_url: item.profile_url ?? publisher?.profile_url ?? author?.profile_url ?? null,
published_as_type: item.published_as_type ?? publisher?.type ?? null,
publisher: publisher ?? null,
content_type_name: item.content_type_name ?? category?.content_type_name ?? category?.content_type_slug ?? category?.content_type ?? '',
content_type_slug: item.content_type_slug ?? category?.content_type_slug ?? category?.content_type ?? '',
category_name: item.category_name ?? category?.name ?? (typeof item.category === 'string' ? item.category : ''),
category_slug: item.category_slug ?? category?.slug ?? '',
category: typeof item.category === 'string' ? item.category : (category?.name ?? item.category_name ?? ''),
slug: item.slug ?? '',
url: webUrl,
canonical_url: item.canonical_url ?? item.urls?.canonical ?? item.urls?.direct ?? null,
url: canonicalUrl,
width: w,
height: h,
};
}
function mapRankApiArtwork(item) {
return normalizeArtworkItem(item);
}
/**
* Fetch ranked artworks from the ranking API.
* Returns { artworks: [...] } in internal shape, or { artworks: [] } on failure.
@@ -261,7 +285,8 @@ function MasonryGallery({
discoveryEndpoint = null,
algoVersion: initialAlgoVersion = null,
}) {
const [artworks, setArtworks] = useState(initialArtworks);
const normalizedInitialArtworks = initialArtworks.map(normalizeArtworkItem);
const [artworks, setArtworks] = useState(normalizedInitialArtworks);
const [nextCursor, setNextCursor] = useState(initialNextCursor);
const [nextPageUrl, setNextPageUrl] = useState(initialNextPageUrl);
const [loading, setLoading] = useState(false);
@@ -279,7 +304,7 @@ function MasonryGallery({
// client-side fetch from the ranking API to hydrate the grid.
// Satisfies spec: "Fallback: Latest if ranking missing".
useEffect(() => {
if (initialArtworks.length > 0) return; // SSR artworks already present
if (normalizedInitialArtworks.length > 0) return; // SSR artworks already present
if (!rankApiEndpoint) return; // no API endpoint configured
let cancelled = false;

View File

@@ -42,6 +42,7 @@ export default function LeaderboardItem({ item, type, highlight = false }) {
</a>
) : null}
{type === 'group' && entity.headline ? <p className="mt-1 text-sm text-slate-400">{entity.headline}</p> : null}
{type === 'world' && entity.summary ? <p className="mt-1 text-sm text-slate-400">{entity.summary}</p> : null}
{entity.username ? <p className="mt-1 text-sm text-slate-500">@{entity.username}</p> : null}
</div>
@@ -65,6 +66,21 @@ export default function LeaderboardItem({ item, type, highlight = false }) {
{Number(entity.artworks_count || 0).toLocaleString()} artworks, {Number(entity.members_count || 0).toLocaleString()} members, {Number(entity.followers_count || 0).toLocaleString()} followers
</span>
) : null}
{type === 'world' ? (
<span className="text-xs text-slate-400">
{Number(entity.relations_count || 0).toLocaleString()} curated links, {Number(entity.approved_submissions_count || 0).toLocaleString()} approved submissions{entity.timeframe_label ? `, ${entity.timeframe_label}` : ''}
</span>
) : null}
{type === 'world' && entity.badge_label ? (
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] uppercase tracking-[0.14em] text-slate-300">
{entity.badge_label}
</span>
) : null}
{type === 'world' && entity.theme_label ? (
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] uppercase tracking-[0.14em] text-slate-300">
{entity.theme_label}
</span>
) : null}
</div>
</div>

View File

@@ -25,40 +25,39 @@ function formatYear(value) {
function iconForType(type) {
switch (type) {
case 'first_upload':
return 'fa-solid fa-seedling'
case 'first_featured_artwork':
return 'fa-solid fa-star'
case 'first_group_release':
return 'fa-solid fa-people-group'
case 'biggest_download_spike':
return 'fa-solid fa-bolt'
case 'best_performing_work':
return 'fa-solid fa-trophy'
case 'most_productive_year':
return 'fa-solid fa-calendar-check'
case 'yearly_recap':
return 'fa-solid fa-chart-column'
// v2
case 'comeback_minor':
return 'fa-solid fa-rotate-right'
case 'comeback_major':
return 'fa-solid fa-person-walking-arrow-right'
case 'comeback_legendary':
return 'fa-solid fa-fire-flame-curved'
case 'first_upload': return 'fa-solid fa-seedling'
case 'first_featured_artwork': return 'fa-solid fa-star'
case 'first_group_release': return 'fa-solid fa-people-group'
case 'biggest_download_spike': return 'fa-solid fa-bolt'
case 'best_performing_work': return 'fa-solid fa-trophy'
case 'most_productive_year': return 'fa-solid fa-calendar-check'
case 'yearly_recap': return 'fa-solid fa-chart-column'
case 'comeback_minor': return 'fa-solid fa-rotate-right'
case 'comeback_major': return 'fa-solid fa-person-walking-arrow-right'
case 'comeback_legendary': return 'fa-solid fa-fire-flame-curved'
case 'upload_streak_3':
case 'upload_streak_6':
case 'upload_streak_12':
return 'fa-solid fa-fire'
case 'upload_streak_12': return 'fa-solid fa-fire'
case 'active_year_streak_3':
case 'active_year_streak_5':
return 'fa-solid fa-calendar-days'
case 'before_now':
return 'fa-solid fa-arrows-rotate'
case 'era_started':
return 'fa-solid fa-flag'
default:
return 'fa-solid fa-sparkles'
case 'active_year_streak_5': return 'fa-solid fa-calendar-days'
case 'before_now': return 'fa-solid fa-arrows-rotate'
case 'era_started': return 'fa-solid fa-flag'
default: return 'fa-solid fa-sparkles'
}
}
function colorForType(type) {
switch (type) {
case 'first_featured_artwork': return { icon: 'text-amber-200', bg: 'bg-amber-400/12', border: 'border-amber-300/20', accent: 'from-amber-400/60' }
case 'best_performing_work': return { icon: 'text-amber-200', bg: 'bg-amber-400/12', border: 'border-amber-300/20', accent: 'from-amber-400/60' }
case 'biggest_download_spike': return { icon: 'text-sky-200', bg: 'bg-sky-400/12', border: 'border-sky-300/20', accent: 'from-sky-400/60' }
case 'first_upload': return { icon: 'text-emerald-200', bg: 'bg-emerald-400/12', border: 'border-emerald-300/20', accent: 'from-emerald-400/60' }
case 'first_group_release': return { icon: 'text-violet-200', bg: 'bg-violet-400/12', border: 'border-violet-300/20', accent: 'from-violet-400/60' }
case 'comeback_minor':
case 'comeback_major':
case 'comeback_legendary': return { icon: 'text-orange-200', bg: 'bg-orange-400/12', border: 'border-orange-300/20', accent: 'from-orange-400/60' }
case 'most_productive_year': return { icon: 'text-teal-200', bg: 'bg-teal-400/12', border: 'border-teal-300/20', accent: 'from-teal-400/60' }
default: return { icon: 'text-sky-200', bg: 'bg-sky-400/12', border: 'border-sky-300/20', accent: 'from-sky-400/60' }
}
}
@@ -203,6 +202,103 @@ function StreaksSection({ streaks }) {
)
}
// ── Yearly Productivity Chart ────────────────────────────────────────────────
const STATUS_BAR_COLOR = {
breakout: { bar: 'bg-emerald-400', label: 'bg-emerald-400/12 text-emerald-200 border-emerald-400/20' },
steady: { bar: 'bg-sky-400', label: 'bg-sky-400/12 text-sky-200 border-sky-400/20' },
experimental: { bar: 'bg-violet-400', label: 'bg-violet-400/12 text-violet-200 border-violet-400/20' },
comeback: { bar: 'bg-amber-400', label: 'bg-amber-400/12 text-amber-200 border-amber-400/20' },
quiet: { bar: 'bg-slate-500', label: 'bg-slate-700/60 text-slate-400 border-slate-600/30' },
}
function YearlyProductivityChart({ recaps }) {
if (!recaps?.length) return null
// Sort oldest → newest for the chart
const sorted = [...recaps]
.filter((r) => r.metrics?.year && r.metrics?.uploads_count != null)
.sort((a, b) => (a.metrics.year ?? 0) - (b.metrics.year ?? 0))
if (!sorted.length) return null
const maxUploads = Math.max(...sorted.map((r) => r.metrics.uploads_count), 1)
return (
<div className="mt-7 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 sm:p-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">Productivity</div>
<div className="mt-1 text-lg font-semibold text-white">Year-by-year upload activity</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-[11px] text-slate-500">
{Object.entries(STATUS_BAR_COLOR)
.filter(([key]) => sorted.some((r) => (r.metrics?.year_status ?? 'steady') === key))
.map(([key, val]) => (
<span key={key} className="flex items-center gap-1.5 capitalize">
<span className={`inline-block h-2 w-2 rounded-full ${val.bar}`} />
{key}
</span>
))}
</div>
</div>
<div className="mt-5 space-y-2">
{sorted.map((item) => {
const uploads = item.metrics.uploads_count
const pct = Math.max(uploads / maxUploads, uploads > 0 ? 0.018 : 0)
const status = item.metrics?.year_status ?? 'steady'
const colors = STATUS_BAR_COLOR[status] ?? STATUS_BAR_COLOR.steady
const isBest = uploads === maxUploads
return (
<div key={item.metrics.year} className="group grid grid-cols-[3.5rem_minmax(0,1fr)_4rem] items-center gap-3">
{/* Year label */}
<div className={`text-right text-[13px] font-semibold tabular-nums ${isBest ? 'text-white' : 'text-slate-400'}`}>
{item.metrics.year}
</div>
{/* Bar */}
<div className="relative h-7 overflow-hidden rounded-full bg-white/[0.04]">
<div
className={`absolute inset-y-0 left-0 rounded-full ${colors.bar} opacity-80 transition-all duration-500 group-hover:opacity-100`}
style={{ width: `${(pct * 100).toFixed(1)}%` }}
/>
{/* Tooltip on hover */}
<div className="absolute inset-y-0 left-0 flex w-full items-center px-3 opacity-0 transition-opacity group-hover:opacity-100">
<span className="text-[11px] font-semibold text-white drop-shadow-sm">
{uploads} upload{uploads !== 1 ? 's' : ''}
{(item.metrics?.views ?? 0) > 0 ? ` · ${Number(item.metrics.views).toLocaleString()} views` : ''}
{(item.metrics?.downloads ?? 0) > 0 ? ` · ${Number(item.metrics.downloads).toLocaleString()} dl` : ''}
</span>
</div>
</div>
{/* Upload count + badge */}
<div className="flex items-center gap-1.5">
<span className={`text-[13px] font-bold tabular-nums ${isBest ? 'text-white' : 'text-slate-300'}`}>{uploads}</span>
{isBest && <span className="rounded-full border border-amber-400/25 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-amber-300">best</span>}
{(item.metrics?.featured_count ?? 0) > 0 && !isBest && (
<span className="rounded-full border border-sky-400/20 bg-sky-400/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-sky-300">
<i className="fa-solid fa-star text-[8px]" /> {item.metrics.featured_count}
</span>
)}
</div>
</div>
)
})}
</div>
{/* Summary footer */}
<div className="mt-5 flex flex-wrap gap-4 border-t border-white/5 pt-4 text-[12px] text-slate-400">
<span><span className="font-semibold text-white">{sorted.length}</span> active years</span>
<span><span className="font-semibold text-white">{sorted.reduce((s, r) => s + r.metrics.uploads_count, 0).toLocaleString()}</span> total uploads</span>
<span><span className="font-semibold text-white">{Number(sorted.reduce((s, r) => s + (r.metrics.views ?? 0), 0)).toLocaleString()}</span> total views</span>
</div>
</div>
)
}
// ── v2: Growth & Evolution ───────────────────────────────────────────────────
const RELATION_LABELS = {
@@ -276,6 +372,7 @@ export default function CreatorJourneySection({ journey, username }) {
const highlights = Array.isArray(journey?.highlights) ? journey.highlights : []
const timeline = Array.isArray(journey?.timeline) ? journey.timeline.slice(0, 6) : []
const recaps = Array.isArray(journey?.yearly_recaps) ? journey.yearly_recaps.slice(0, 3) : []
const allRecaps = Array.isArray(journey?.yearly_recaps) ? journey.yearly_recaps : []
const eras = Array.isArray(journey?.eras) ? journey.eras : []
const evolution = Array.isArray(journey?.evolution) ? journey.evolution : []
const streaks = journey?.streaks ?? null
@@ -333,11 +430,13 @@ export default function CreatorJourneySection({ journey, username }) {
return (
<article
key={item.id}
className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] p-5"
className="relative overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] p-5 transition-colors hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))]"
>
{(() => { const c = colorForType(item.type); return <div className={`absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r ${c.accent} via-transparent to-transparent`} /> })()
}
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-sky-400/12 text-sky-200">
<div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ${colorForType(item.type).bg} ${colorForType(item.type).icon}`}>
<i className={iconForType(item.type)} />
</div>
<div className="min-w-0">
@@ -371,7 +470,7 @@ export default function CreatorJourneySection({ journey, username }) {
</div>
)}
<div className="mt-7 grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(18rem,0.95fr)]">
<div className="mt-7 grid gap-6">
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
@@ -387,7 +486,7 @@ export default function CreatorJourneySection({ journey, username }) {
return (
<div key={item.id} className="grid grid-cols-[2.5rem_minmax(0,1fr)] gap-3">
<div className="flex flex-col items-center">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.05] text-slate-100">
<div className={`flex h-10 w-10 items-center justify-center rounded-2xl border ${colorForType(item.type).border} ${colorForType(item.type).bg} ${colorForType(item.type).icon}`}>
<i className={iconForType(item.type)} />
</div>
{index < timeline.length - 1 && <div className="mt-2 h-full w-px bg-white/10" />}
@@ -470,6 +569,9 @@ export default function CreatorJourneySection({ journey, username }) {
{/* ── v2: Streaks ── */}
<StreaksSection streaks={streaks} />
{/* ── Yearly productivity chart ── */}
<YearlyProductivityChart recaps={allRecaps} />
{/* ── v2: Growth & Evolution ── */}
<EvolutionSection evolution={evolution} />
</section>

View File

@@ -2,13 +2,65 @@ import React from 'react'
import CreatorJourneySection from '../CreatorJourneySection'
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' },
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter', hoverClass: 'hover:border-slate-300/30 hover:text-slate-100 hover:bg-white/[0.08]' },
deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt', hoverClass: 'hover:border-green-400/35 hover:text-green-300 hover:bg-green-900/20' },
instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram', hoverClass: 'hover:border-pink-400/35 hover:text-pink-300 hover:bg-pink-900/20' },
behance: { icon: 'fa-brands fa-behance', label: 'Behance', hoverClass: 'hover:border-blue-400/35 hover:text-blue-300 hover:bg-blue-900/20' },
artstation: { icon: 'fa-solid fa-palette', label: 'ArtStation', hoverClass: 'hover:border-orange-400/35 hover:text-orange-300 hover:bg-orange-900/20' },
youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube', hoverClass: 'hover:border-red-400/35 hover:text-red-300 hover:bg-red-900/20' },
website: { icon: 'fa-solid fa-link', label: 'Website', hoverClass: 'hover:border-sky-400/35 hover:text-sky-200 hover:bg-sky-900/20' },
}
const CATEGORY_ICONS = {
animals: 'fa-solid fa-paw',
birds: 'fa-solid fa-dove',
flowers: 'fa-solid fa-seedling',
fruit: 'fa-solid fa-apple-whole',
'sci-fi': 'fa-solid fa-rocket',
scifi: 'fa-solid fa-rocket',
fantasy: 'fa-solid fa-dragon',
nature: 'fa-solid fa-leaf',
landscape: 'fa-solid fa-mountain',
abstract: 'fa-solid fa-shapes',
architecture: 'fa-solid fa-building',
people: 'fa-solid fa-person',
portrait: 'fa-solid fa-face-smile',
cars: 'fa-solid fa-car',
space: 'fa-solid fa-star',
games: 'fa-solid fa-gamepad',
food: 'fa-solid fa-utensils',
travel: 'fa-solid fa-plane',
sports: 'fa-solid fa-football',
ocean: 'fa-solid fa-water',
underwater: 'fa-solid fa-fish',
insects: 'fa-solid fa-bug',
reptiles: 'fa-solid fa-dragon',
cats: 'fa-solid fa-cat',
dogs: 'fa-solid fa-dog',
}
const CONTENT_TYPE_ICONS = {
photography: 'fa-solid fa-camera',
wallpapers: 'fa-solid fa-desktop',
'digital art': 'fa-solid fa-wand-magic-sparkles',
illustration: 'fa-solid fa-pen-nib',
'3d': 'fa-solid fa-cube',
vector: 'fa-solid fa-bezier-curve',
fractal: 'fa-solid fa-infinity',
gif: 'fa-solid fa-film',
drawing: 'fa-solid fa-pencil',
painting: 'fa-solid fa-paintbrush',
photo: 'fa-solid fa-camera',
}
function getCategoryIcon(label) {
const key = String(label || '').toLowerCase().trim()
return CATEGORY_ICONS[key] ?? null
}
function getContentTypeIcon(label) {
const key = String(label || '').toLowerCase().trim()
return CONTENT_TYPE_ICONS[key] ?? null
}
function formatNumber(value) {
@@ -120,11 +172,13 @@ function buildInterestGroups(artworks = []) {
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 className="flex items-center gap-3 rounded-2xl border border-white/[0.07] bg-white/[0.025] px-3.5 py-3 transition-colors hover:bg-white/[0.045]">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] text-slate-400">
<i className={`fa-solid ${icon} fa-fw text-[13px]`} />
</div>
<div className="min-w-0 flex-1">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 block">{label}</span>
<div className="mt-0.5 text-sm text-slate-200">{children}</div>
</div>
</div>
)
@@ -132,19 +186,21 @@ function InfoRow({ icon, label, children }) {
function StatCard({ icon, label, value, tone = 'sky' }) {
const tones = {
sky: 'text-sky-300 bg-sky-400/10 border-sky-300/15',
amber: 'text-amber-200 bg-amber-300/10 border-amber-300/15',
emerald: 'text-emerald-200 bg-emerald-400/10 border-emerald-300/15',
violet: 'text-violet-200 bg-violet-400/10 border-violet-300/15',
sky: { icon: 'text-sky-300 bg-sky-400/10 border-sky-300/20', bar: 'from-sky-400/60 via-sky-400/20 to-transparent', glow: 'shadow-[0_0_28px_rgba(56,189,248,0.10)]' },
amber: { icon: 'text-amber-200 bg-amber-300/10 border-amber-300/20', bar: 'from-amber-400/60 via-amber-400/20 to-transparent', glow: 'shadow-[0_0_28px_rgba(251,191,36,0.10)]' },
emerald: { icon: 'text-emerald-200 bg-emerald-400/10 border-emerald-300/20', bar: 'from-emerald-400/60 via-emerald-400/20 to-transparent', glow: 'shadow-[0_0_28px_rgba(52,211,153,0.10)]' },
violet: { icon: 'text-violet-200 bg-violet-400/10 border-violet-300/20', bar: 'from-violet-400/60 via-violet-400/20 to-transparent', glow: 'shadow-[0_0_28px_rgba(167,139,250,0.10)]' },
}
const t = tones[tone] || tones.sky
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 className={`relative overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_18px_44px_rgba(2,6,23,0.18)] ${t.glow}`}>
<div className={`absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r ${t.bar}`} />
<div className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${t.icon}`}>
<i className={`fa-solid ${icon} text-base`} />
</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 className="mt-1 text-2xl font-bold tracking-tight text-white">{value}</div>
</div>
)
}
@@ -221,7 +277,7 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
id="tabpanel-about"
role="tabpanel"
aria-labelledby="tab-about"
className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
className="pt-4 pb-10"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map((card) => (
@@ -233,10 +289,16 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
<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="relative">
<div className="-mt-2 mb-0 select-none font-serif text-7xl leading-none text-slate-500/20" aria-hidden="true">&ldquo;</div>
<p className="whitespace-pre-line text-[15px] leading-8 text-slate-200/90">{about}</p>
</div>
) : (
<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 className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-5 py-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500">
<i className="fa-regular fa-comment-dots text-lg" />
</div>
<p className="text-sm text-slate-400">This creator has not written a public bio yet.</p>
</div>
)}
</SectionCard>
@@ -372,24 +434,27 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
{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]"
className="group relative overflow-hidden rounded-2xl border border-amber-300/10 bg-white/[0.03] px-4 py-4 transition-all hover:border-amber-300/25 hover:bg-white/[0.06]"
>
<div className="absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r from-amber-400/50 via-amber-400/20 to-transparent" />
<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 className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-amber-300/20 bg-amber-300/10 text-amber-200 shadow-[0_0_18px_rgba(251,191,36,0.12)] transition-shadow group-hover:shadow-[0_0_24px_rgba(251,191,36,0.2)]">
<i className={`fa-solid ${achievement.icon || 'fa-trophy'} text-base`} />
</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">
<div className="mt-3 flex flex-wrap gap-2">
{achievement.unlocked_at ? (
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
<i className="fa-solid fa-calendar-check text-[10px]" />
{formatShortDate(achievement.unlocked_at) || 'Unlocked'}
</span>
) : null}
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
<span className="inline-flex items-center gap-1.5 rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.14em] text-amber-200">
<i className="fa-solid fa-bolt text-[9px]" />
+{formatNumber(achievement.xp_reward ?? 0)} XP
</span>
</div>
@@ -524,34 +589,48 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
<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 className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
<i className="fa-solid fa-tag text-slate-600" />
Top categories
</div>
<div className="flex flex-wrap gap-2.5">
{interestGroups.categories.map((category) => {
const catIcon = getCategoryIcon(category.label)
return (
<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 transition-colors hover:bg-white/[0.07]"
>
{catIcon ? <i className={`${catIcon} text-[12px] text-slate-400`} /> : null}
<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 className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
<i className="fa-solid fa-layer-group text-slate-600" />
Preferred formats
</div>
<div className="flex flex-wrap gap-2.5">
{interestGroups.contentTypes.map((contentType) => {
const ctIcon = getContentTypeIcon(contentType.label)
return (
<span
key={contentType.label}
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-3.5 py-2 text-sm text-sky-100 transition-colors hover:bg-sky-400/15"
>
{ctIcon ? <i className={`${ctIcon} text-[12px] text-sky-300/70`} /> : null}
<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}
@@ -572,7 +651,7 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
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"
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 ${si.hoverClass || 'hover:border-sky-400/30 hover:bg-white/[0.07] hover:text-white'}`}
aria-label={si.label}
>
<i className={`${si.icon} fa-fw`} />

View File

@@ -124,7 +124,7 @@ export default function NotificationDropdown({ initialUnreadCount = 0, notificat
) : null}
</div>
<div className="max-h-[28rem] overflow-y-auto">
<div className="max-h-[28rem] overflow-y-auto nova-scrollbar">
{loading ? <div className="px-4 py-6 text-sm text-white/45">Loading notifications</div> : null}
{!loading && items.length === 0 ? <div className="px-4 py-6 text-sm text-white/45">No notifications yet.</div> : null}
{!loading && items.map((item) => (

View File

@@ -0,0 +1,436 @@
import React, {
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
const DAY_ABBR = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
function pad(value) {
return String(value).padStart(2, '0')
}
function daysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate()
}
function firstWeekday(year, month) {
const day = new Date(year, month, 1).getDay()
return (day + 6) % 7
}
function toISODate(date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function parseDatePart(value) {
if (!value) return null
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
return new Date(year, month - 1, day)
}
function splitDateTime(value) {
if (!value) {
return { date: '', time: '' }
}
const [date = '', time = ''] = String(value).split('T')
return {
date,
time: time.slice(0, 5),
}
}
function mergeDateTime(date, time) {
if (!date) return ''
return `${date}T${time || '00:00'}`
}
function formatDisplay(value) {
if (!value) return ''
const { date, time } = splitDateTime(value)
const parsed = parseDatePart(date)
if (!parsed) return ''
return `${MONTH_NAMES[parsed.getMonth()].slice(0, 3)} ${parsed.getDate()}, ${parsed.getFullYear()}${time ? ` at ${time}` : ''}`
}
function isSameDay(a, b) {
return a?.getFullYear() === b?.getFullYear()
&& a?.getMonth() === b?.getMonth()
&& a?.getDate() === b?.getDate()
}
function CalendarGrid({ year, month, selectedDate, onSelect, minDate, maxDate }) {
const count = daysInMonth(year, month)
const start = firstWeekday(year, month)
const prevMonth = month - 1 < 0 ? 11 : month - 1
const prevYear = month - 1 < 0 ? year - 1 : year
const prevCount = daysInMonth(prevYear, prevMonth)
const cells = []
for (let index = start - 1; index >= 0; index -= 1) {
cells.push({
day: prevCount - index,
current: false,
date: new Date(prevYear, prevMonth, prevCount - index),
})
}
for (let day = 1; day <= count; day += 1) {
cells.push({ day, current: true, date: new Date(year, month, day) })
}
let nextDay = 1
while (cells.length % 7 !== 0) {
cells.push({ day: nextDay, current: false, date: new Date(year, month + 1, nextDay) })
nextDay += 1
}
const today = new Date()
today.setHours(0, 0, 0, 0)
return (
<div className="p-3">
<div className="mb-1 grid grid-cols-7">
{DAY_ABBR.map((day) => (
<div key={day} className="py-1 text-center text-[10px] font-semibold text-slate-500">{day}</div>
))}
</div>
<div className="grid grid-cols-7 gap-y-0.5">
{cells.map((cell, index) => {
const iso = toISODate(cell.date)
const selected = isSameDay(cell.date, selectedDate)
const todayCell = isSameDay(cell.date, today)
const disabled = (minDate && iso < minDate) || (maxDate && iso > maxDate)
return (
<button
key={`${iso}-${index}`}
type="button"
disabled={disabled}
onClick={() => onSelect(iso)}
className={[
'relative mx-auto flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-all',
!cell.current ? 'text-slate-600' : '',
cell.current && !selected && !disabled ? 'text-white hover:bg-white/10' : '',
selected ? 'bg-accent font-semibold text-white shadow shadow-accent/30' : '',
todayCell && !selected ? 'text-accent ring-1 ring-accent/50' : '',
disabled ? 'cursor-not-allowed opacity-30' : 'cursor-pointer',
].join(' ')}
>
{cell.day}
</button>
)
})}
</div>
</div>
)
}
export default function DateTimePicker({
value = '',
onChange,
label,
placeholder = 'Pick a date and time',
error,
hint,
required = false,
clearable = false,
id,
disabled = false,
minDate,
maxDate,
className = '',
}) {
const today = new Date()
const initial = splitDateTime(value)
const initialDate = parseDatePart(initial.date) || today
const [open, setOpen] = useState(false)
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 320 })
const [viewYear, setViewYear] = useState(initialDate.getFullYear())
const [viewMonth, setViewMonth] = useState(initialDate.getMonth())
const [draftDate, setDraftDate] = useState(initial.date)
const [draftTime, setDraftTime] = useState(initial.time || '12:00')
const triggerRef = useRef(null)
const inputId = id ?? (label ? `dtp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-time-picker')
const panelId = `dtp-panel-${inputId}`
useEffect(() => {
const next = splitDateTime(value)
setDraftDate(next.date)
setDraftTime(next.time || '12:00')
const nextDate = parseDatePart(next.date)
if (nextDate) {
setViewYear(nextDate.getFullYear())
setViewMonth(nextDate.getMonth())
}
}, [value])
const measure = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const panelWidth = Math.max(rect.width, 320)
const panelHeight = 420
const openUp = window.innerHeight - rect.bottom < panelHeight + 8 && rect.top > panelHeight + 8
setDropPos({
top: openUp ? rect.top - panelHeight - 4 : rect.bottom + 4,
left: Math.min(rect.left, window.innerWidth - panelWidth - 8),
width: panelWidth,
})
}, [])
const openPicker = useCallback(() => {
if (disabled) return
measure()
setOpen(true)
}, [disabled, measure])
useEffect(() => {
if (!open) return undefined
const handleMouseDown = (event) => {
if (!triggerRef.current?.contains(event.target) && !document.getElementById(panelId)?.contains(event.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleMouseDown)
return () => document.removeEventListener('mousedown', handleMouseDown)
}, [open, panelId])
useEffect(() => {
if (!open) return undefined
const handleScroll = (event) => {
if (document.getElementById(panelId)?.contains(event.target)) return
setOpen(false)
}
const handleResize = () => setOpen(false)
window.addEventListener('scroll', handleScroll, true)
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener('resize', handleResize)
}
}, [open, panelId])
const applyValue = useCallback((date, time) => {
onChange?.(date ? mergeDateTime(date, time) : '')
}, [onChange])
const handleDateSelect = (nextDate) => {
setDraftDate(nextDate)
applyValue(nextDate, draftTime)
}
const handleTimeChange = (event) => {
const nextTime = event.target.value
setDraftTime(nextTime)
applyValue(draftDate, nextTime)
}
const clearValue = (event) => {
event.stopPropagation()
setDraftDate('')
setDraftTime('12:00')
onChange?.('')
}
const prevMonth = () => {
if (viewMonth === 0) {
setViewMonth(11)
setViewYear((current) => current - 1)
return
}
setViewMonth((current) => current - 1)
}
const nextMonth = () => {
if (viewMonth === 11) {
setViewMonth(0)
setViewYear((current) => current + 1)
return
}
setViewMonth((current) => current + 1)
}
const triggerClass = [
'relative flex h-[42px] w-full cursor-pointer items-center gap-2 rounded-xl border px-3.5 text-sm transition-all duration-150',
'bg-white/[0.06] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0',
error
? 'border-red-500/60 focus-visible:ring-red-500/40'
: open
? 'border-accent/50 ring-2 ring-accent/40'
: 'border-white/12 hover:border-white/22',
disabled ? 'pointer-events-none cursor-not-allowed opacity-50' : '',
className,
].join(' ')
const selectedDate = parseDatePart(draftDate)
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={inputId} className="text-sm font-medium text-white/85 select-none">
{label}
{required && <span className="ml-1 text-red-400">*</span>}
</label>
)}
<div
ref={triggerRef}
id={inputId}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={label ?? placeholder}
className={triggerClass}
onClick={openPicker}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openPicker()
}
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0 text-slate-500" aria-hidden="true">
<rect x="1" y="2.5" width="12" height="10.5" rx="1.5" stroke="currentColor" strokeWidth="1.3" />
<path d="M1 6h12" stroke="currentColor" strokeWidth="1.3" />
<path d="M4 1v3M10 1v3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
<circle cx="4.5" cy="9" r="0.75" fill="currentColor" />
<circle cx="7" cy="9" r="0.75" fill="currentColor" />
<circle cx="9.5" cy="9" r="0.75" fill="currentColor" />
</svg>
<span className={`flex-1 truncate ${value ? 'text-white' : 'text-slate-500'}`}>
{value ? formatDisplay(value) : placeholder}
</span>
{clearable && value && (
<button
type="button"
tabIndex={-1}
onClick={clearValue}
className="flex h-5 w-5 items-center justify-center rounded text-slate-500 transition-colors hover:text-white"
aria-label="Clear date and time"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M1 1l8 8M9 1L1 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
)}
</div>
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
{open && createPortal(
<div
id={panelId}
className="fixed z-[500] overflow-hidden rounded-2xl border border-white/12 bg-nova-900 shadow-2xl shadow-black/50"
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
>
<div className="flex items-center justify-between px-3 pt-3">
<button
type="button"
onClick={prevMonth}
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white/8 hover:text-white"
aria-label="Previous month"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M7 1L3 5l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<span className="text-sm font-semibold text-white">{MONTH_NAMES[viewMonth]} {viewYear}</span>
<button
type="button"
onClick={nextMonth}
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white/8 hover:text-white"
aria-label="Next month"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
<CalendarGrid
year={viewYear}
month={viewMonth}
selectedDate={selectedDate}
onSelect={handleDateSelect}
minDate={minDate}
maxDate={maxDate}
/>
<div className="border-t border-white/8 px-4 py-3">
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem] sm:items-end">
<div>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected date</div>
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white">
{draftDate ? formatDisplay(mergeDateTime(draftDate, draftTime)).replace(` at ${draftTime}`, '') : 'Pick a day'}
</div>
</div>
<label className="grid gap-1.5 text-sm text-slate-300">
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Time</span>
<input
type="time"
value={draftTime}
onChange={handleTimeChange}
className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none transition focus:border-accent/50 focus:ring-2 focus:ring-accent/40"
/>
</label>
</div>
<div className="mt-3 flex items-center justify-between">
<button
type="button"
onClick={() => handleDateSelect(toISODate(new Date()))}
className="text-xs font-medium text-accent transition-colors hover:text-accent/80"
>
Today
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-white/[0.08]"
>
Done
</button>
</div>
</div>
</div>,
document.body,
)}
</div>
)
}

View File

@@ -5,6 +5,7 @@ export default function NovaConfirmDialog({
open,
title = 'Please confirm',
message,
children,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
confirmTone = 'danger',
@@ -65,7 +66,8 @@ export default function NovaConfirmDialog({
</div>
<div className="px-6 py-5">
<p className="text-sm leading-6 text-white/70">{message}</p>
{message ? <p className="text-sm leading-6 text-white/70">{message}</p> : null}
{children ? <div className={message ? 'mt-4' : ''}>{children}</div> : null}
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">

View File

@@ -18,5 +18,6 @@ export { default as Checkbox } from './Checkbox'
export { default as Radio, RadioGroup } from './Radio'
export { default as Toggle } from './Toggle'
export { default as DatePicker } from './DatePicker'
export { default as DateTimePicker } from './DateTimePicker'
export { default as DateRangePicker } from './DateRangePicker'
export { default as Modal } from './Modal'

View File

@@ -19,7 +19,6 @@ export default function UploadSidebar({
onChangeTitle,
onChangeTags,
onChangeDescription,
onToggleMature,
onToggleRights,
}) {
return (
@@ -100,18 +99,6 @@ export default function UploadSidebar({
</section>
)}
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<Checkbox
id="upload-sidebar-mature"
checked={Boolean(metadata.isMature)}
onChange={(event) => onToggleMature?.(event.target.checked)}
variant="accent"
size={20}
label="Mark this artwork as mature content."
hint="Use this for NSFW, explicit, or otherwise age-restricted artwork."
/>
</section>
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<Checkbox
id="upload-sidebar-rights"

View File

@@ -36,7 +36,7 @@ const wizardSteps = [
{ key: 'publish', label: 'Publish' },
]
function createInitialMetadata(initialGroupSlug = '', currentUserId = null, contributorOptionsByGroup = {}) {
function createInitialMetadata(initialGroupSlug = '', currentUserId = null, contributorOptionsByGroup = {}, eligibleWorlds = []) {
const normalizedGroupSlug = String(initialGroupSlug || '').trim()
const contributors = Array.isArray(contributorOptionsByGroup?.[normalizedGroupSlug])
? contributorOptionsByGroup[normalizedGroupSlug]
@@ -58,6 +58,9 @@ function createInitialMetadata(initialGroupSlug = '', currentUserId = null, cont
primaryAuthorUserId: defaultPrimaryAuthor,
contributorUserIds: [],
contributorCredits: {},
worldSubmissions: Array.isArray(eligibleWorlds)
? eligibleWorlds.map((world) => ({ ...world, selected: Boolean(world.selected), note: world.note || '' }))
: [],
}
}
@@ -107,6 +110,7 @@ export default function UploadWizard({
chunkRequestTimeoutMs,
contentTypes = [],
suggestedTags = [],
eligibleWorlds = [],
groupOptions = [],
contributorOptionsByGroup = {},
initialGroupSlug = '',
@@ -137,7 +141,7 @@ export default function UploadWizard({
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0)
// ── Metadata state ────────────────────────────────────────────────────────
const [metadata, setMetadata] = useState(() => createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup))
const [metadata, setMetadata] = useState(() => createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup, eligibleWorlds))
// ── Refs ──────────────────────────────────────────────────────────────────
const prefersReducedMotion = useReducedMotion()
@@ -449,7 +453,7 @@ export default function UploadWizard({
setPrimaryFile(null)
setScreenshots([])
setSelectedScreenshotIndex(0)
setMetadata(createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup))
setMetadata(createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup, eligibleWorlds))
setIsUploadLocked(false)
hasAutoAdvancedRef.current = false
setPublishMode('now')
@@ -461,7 +465,7 @@ export default function UploadWizard({
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
})
setActiveStep(1)
}, [resetMachine, initialDraftId, initialGroupSlug, currentUserId, contributorOptionsByGroup])
}, [resetMachine, initialDraftId, initialGroupSlug, currentUserId, contributorOptionsByGroup, eligibleWorlds])
const goToStep = useCallback((step) => {
if (step >= 1 && step <= highestUnlockedStep) setActiveStep(step)
@@ -472,6 +476,14 @@ export default function UploadWizard({
// Complete / success screen
if (machine.state === machineStates.complete) {
const wasScheduled = machine.lastAction === 'schedule'
const studioArtworksUrl = '/studio/artworks'
const artworkUrl = resolvedArtworkId
? `/art/${resolvedArtworkId}${machine.slug ? `/${machine.slug}` : ''}`
: '/'
const studioArtworkUrl = resolvedArtworkId
? `/studio/artworks/${resolvedArtworkId}/edit`
: studioArtworksUrl
return (
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.98 }}
@@ -502,14 +514,24 @@ export default function UploadWizard({
<div className="mt-6 flex flex-wrap justify-center gap-3">
{!wasScheduled && (
<a
href={resolvedArtworkId
? `/art/${resolvedArtworkId}${machine.slug ? `/${machine.slug}` : ''}`
: '/'}
href={artworkUrl}
className="rounded-lg ring-1 ring-emerald-300/45 bg-emerald-400/20 px-4 py-2 text-sm font-medium text-emerald-50 hover:bg-emerald-400/30 transition"
>
View artwork
</a>
)}
<a
href={studioArtworksUrl}
className="rounded-lg ring-1 ring-sky-300/35 bg-sky-400/12 px-4 py-2 text-sm font-medium text-sky-50 hover:bg-sky-400/20 transition"
>
View in studio
</a>
<a
href={studioArtworkUrl}
className="rounded-lg ring-1 ring-white/20 bg-white/8 px-4 py-2 text-sm font-medium text-white hover:bg-white/15 transition"
>
Edit artwork in studio
</a>
<button
type="button"
onClick={handleReset}
@@ -628,7 +650,6 @@ export default function UploadWizard({
onChangeTitle={(value) => setMeta({ title: value })}
onChangeTags={(value) => setMeta({ tags: value })}
onChangeDescription={(value) => setMeta({ description: value })}
onToggleMature={(value) => setMeta({ isMature: Boolean(value) })}
onToggleRights={(value) => setMeta({ rightsAccepted: Boolean(value) })}
/>
)
@@ -645,6 +666,7 @@ export default function UploadWizard({
onSelectedScreenshotChange={setSelectedScreenshotIndex}
fileMetadata={fileMetadata}
metadata={metadata}
eligibleWorlds={Array.isArray(metadata.worldSubmissions) ? metadata.worldSubmissions : []}
canPublish={canPublish}
uploadReady={uploadReady}
publishMode={publishMode}
@@ -658,6 +680,20 @@ export default function UploadWizard({
currentContributorOptions={currentContributorOptions}
allRootCategoryOptions={allRootCategoryOptions}
filteredCategoryTree={filteredCategoryTree}
onToggleWorldSubmission={(worldId) => setMetadata((current) => ({
...current,
worldSubmissions: (Array.isArray(current.worldSubmissions) ? current.worldSubmissions : []).map((world) => (
Number(world.id) === Number(worldId) && !world.selection_locked
? { ...world, selected: !world.selected }
: world
)),
}))}
onChangeWorldSubmissionNote={(worldId, note) => setMetadata((current) => ({
...current,
worldSubmissions: (Array.isArray(current.worldSubmissions) ? current.worldSubmissions : []).map((world) => (
Number(world.id) === Number(worldId) ? { ...world, note } : world
)),
}))}
/>
)
}

View File

@@ -121,20 +121,32 @@ async function completeStep1ToReady() {
})
}
async function completeRequiredDetails({ title = 'My Art', mature = false } = {}) {
async function completeRequiredDetails({ title = 'My Art' } = {}) {
await act(async () => {
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), title)
await userEvent.click(screen.getByRole('button', { name: /art .* open/i }))
await userEvent.click(await screen.findByRole('button', { name: /root .* choose/i }))
await userEvent.click(await screen.findByRole('button', { name: /sub .* choose/i }))
await userEvent.type(screen.getByLabelText(/search or add tags/i), 'fantasy{enter}')
if (mature) {
await userEvent.click(screen.getByLabelText(/mark this artwork as mature content/i))
}
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
})
}
function queryPrimaryPublishButton() {
return screen
.queryAllByRole('button', { name: /^publish now$/i })
.find((button) => !button.hasAttribute('aria-pressed')) || null
}
function getPrimaryPublishButton() {
const button = queryPrimaryPublishButton()
if (!button) {
throw new Error('Primary publish action button not found')
}
return button
}
describe('UploadWizard step flow', () => {
let originalImage
let originalScrollTo
@@ -311,7 +323,7 @@ describe('UploadWizard step flow', () => {
await completeStep1ToReady()
expect(await screen.findByText(/artwork details/i)).not.toBeNull()
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
expect(queryPrimaryPublishButton()?.disabled).toBe(true)
await completeRequiredDetails({ title: 'My Art' })
@@ -320,12 +332,12 @@ describe('UploadWizard step flow', () => {
})
await waitFor(() => {
const publish = screen.getByRole('button', { name: /^publish$/i })
const publish = getPrimaryPublishButton()
expect(publish.disabled).toBe(false)
})
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
await userEvent.click(getPrimaryPublishButton())
})
await waitFor(() => {
@@ -335,7 +347,7 @@ describe('UploadWizard step flow', () => {
expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/session-1/publish', expect.anything(), expect.anything())
})
it('includes the mature flag in the final publish payload when selected', async () => {
it('hides the mature content checkbox in the details step', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 311, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] })
@@ -343,28 +355,7 @@ describe('UploadWizard step flow', () => {
await screen.findByText(/artwork details/i)
await completeRequiredDetails({ title: 'Mature Piece', mature: true })
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
})
await waitFor(() => {
const publish = screen.getByRole('button', { name: /^publish$/i })
expect(publish.disabled).toBe(false)
})
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
})
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith(
'/api/uploads/311/publish',
expect.objectContaining({ is_mature: true }),
expect.anything(),
)
})
expect(screen.queryByLabelText(/mark this artwork as mature content/i)).toBeNull()
})
it('includes contributor credit metadata in the final publish payload', async () => {
@@ -399,12 +390,12 @@ describe('UploadWizard step flow', () => {
})
await waitFor(() => {
const publish = screen.getByRole('button', { name: /^publish$/i })
const publish = getPrimaryPublishButton()
expect(publish.disabled).toBe(false)
})
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
await userEvent.click(getPrimaryPublishButton())
})
await waitFor(() => {
@@ -444,17 +435,49 @@ describe('UploadWizard step flow', () => {
await screen.findByText(/artwork details/i)
const publishAs = screen.getByRole('combobox', { name: /publishing identity/i })
expect(screen.getByRole('option', { name: /personal profile/i })).not.toBeNull()
await act(async () => {
await userEvent.click(publishAs)
})
expect(await screen.findByRole('option', { name: /personal profile/i })).not.toBeNull()
expect(screen.getByRole('option', { name: /warp collective/i })).not.toBeNull()
await act(async () => {
await userEvent.selectOptions(publishAs, 'warp-collective')
await userEvent.click(screen.getByRole('option', { name: /warp collective/i }))
})
expect(await screen.findByRole('combobox', { name: /primary author/i })).not.toBeNull()
expect(screen.getByText(/contributors/i)).not.toBeNull()
})
it('shows studio manager and editor links after publishing', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 315, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] })
await completeStep1ToReady()
await screen.findByText(/artwork details/i)
await completeRequiredDetails({ title: 'Studio Linked Piece' })
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
})
await waitFor(() => {
expect(getPrimaryPublishButton().disabled).toBe(false)
})
await act(async () => {
await userEvent.click(getPrimaryPublishButton())
})
const studioManagerLink = await screen.findByRole('link', { name: /view in studio/i })
expect(studioManagerLink.getAttribute('href')).toBe('/studio/artworks')
const studioEditLink = screen.getByRole('link', { name: /edit artwork in studio/i })
expect(studioEditLink.getAttribute('href')).toBe('/studio/artworks/315/edit')
})
it('keeps mobile sticky action bar visible class', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 306 })

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
import UploadSidebar from '../UploadSidebar'
import { NovaSelect } from '../../ui'
import { getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils'
/**
@@ -47,7 +48,6 @@ export default function Step2Details({
onChangeTitle,
onChangeTags,
onChangeDescription,
onToggleMature,
onToggleRights,
}) {
const [isContentTypeChooserOpen, setIsContentTypeChooserOpen] = useState(() => !metadata.contentType)
@@ -488,34 +488,33 @@ export default function Step2Details({
</div>
<label className="block">
<span className="text-sm font-medium text-white/90">Publishing identity</span>
<select
<NovaSelect
label="Publishing identity"
value={metadata.group || ''}
onChange={(event) => onGroupChange?.(event.target.value)}
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
>
<option value="">Personal profile</option>
{groupOptions.map((group) => (
<option key={group.slug} value={group.slug}>{group.name}</option>
))}
</select>
onChange={(nextValue) => onGroupChange?.(String(nextValue || ''))}
options={[
{ value: '', label: 'Personal profile' },
...groupOptions.map((group) => ({ value: group.slug, label: group.name })),
]}
searchable={false}
className="mt-2 bg-black/20"
/>
</label>
{metadata.group && (
<div className="mt-5 grid gap-5 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<div>
<label className="block">
<span className="text-sm font-medium text-white/90">Primary author</span>
<select
value={metadata.primaryAuthorUserId || ''}
onChange={(event) => onPrimaryAuthorChange?.(event.target.value)}
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
>
{currentContributorOptions.map((user) => (
<option key={user.id} value={user.id}>{user.name || user.username}</option>
))}
</select>
</label>
<NovaSelect
label="Primary author"
value={metadata.primaryAuthorUserId || null}
onChange={(nextValue) => onPrimaryAuthorChange?.(nextValue == null ? '' : String(nextValue))}
options={currentContributorOptions.map((user) => ({
value: user.id,
label: user.name || user.username,
}))}
searchable={false}
className="mt-2 bg-black/20"
/>
<p className="mt-2 text-xs text-slate-400">The primary author is shown as the lead creator for this group-published artwork.</p>
</div>
@@ -613,7 +612,6 @@ export default function Step2Details({
onChangeTitle={onChangeTitle}
onChangeTags={onChangeTags}
onChangeDescription={onChangeDescription}
onToggleMature={onToggleMature}
onToggleRights={onToggleRights}
/>
</div>

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { motion, useReducedMotion } from 'framer-motion'
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
import WorldSubmissionSelector from '../../worlds/WorldSubmissionSelector'
function stripHtml(value) {
return String(value || '')
@@ -51,6 +52,7 @@ export default function Step3Publish({
fileMetadata,
// Metadata
metadata,
eligibleWorlds = [],
// Readiness
canPublish,
uploadReady,
@@ -67,6 +69,8 @@ export default function Step3Publish({
// Category tree (for label lookup)
allRootCategoryOptions = [],
filteredCategoryTree = [],
onToggleWorldSubmission,
onChangeWorldSubmissionNote,
}) {
const prefersReducedMotion = useReducedMotion()
const quickTransition = prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
@@ -219,6 +223,14 @@ export default function Step3Publish({
</div>
{/* ── Visibility selector ────────────────────────────────────────── */}
<WorldSubmissionSelector
title="Add to Worlds"
description="Attach this artwork to active worlds for creator participation. These placements stay separate from editorial curated relations."
options={eligibleWorlds}
onToggle={onToggleWorldSubmission}
onNoteChange={onChangeWorldSubmissionNote}
/>
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visibility</p>
<div className="grid gap-2 sm:grid-cols-3">

View File

@@ -0,0 +1,52 @@
import React from 'react'
function themeStyle(theme) {
return {
'--world-accent': theme?.accent_color || '#38bdf8',
'--world-accent-secondary': theme?.accent_color_secondary || '#0f172a',
}
}
export default function WorldCard({ world, compact = false }) {
if (!world) {
return null
}
return (
<a
href={world.public_url}
className={`group relative block w-full overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/70 transition duration-300 hover:-translate-y-1 hover:border-white/20 ${compact ? 'p-5' : 'p-6'}`}
style={themeStyle(world.theme)}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_color-mix(in_srgb,var(--world-accent)_30%,transparent),_transparent_42%),linear-gradient(135deg,_color-mix(in_srgb,var(--world-accent-secondary)_94%,black),_rgba(2,6,23,0.94))] opacity-95" />
{world.cover_url ? <img src={world.cover_url} alt={world.title} className="absolute inset-0 h-full w-full object-cover opacity-20 transition duration-500 group-hover:scale-[1.03]" /> : null}
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/80 to-slate-950/10" />
<div className="relative flex h-full min-h-[16rem] flex-col justify-between">
<div>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-white/70">
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{world.phase || world.status}</span>
{world.badge_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.badge_label}</span> : null}
</div>
<h3 className={`mt-4 max-w-xl font-semibold tracking-[-0.03em] text-white ${compact ? 'text-2xl' : 'text-3xl'}`}>{world.title}</h3>
{world.tagline ? <p className="mt-2 text-sm uppercase tracking-[0.18em] text-white/55">{world.tagline}</p> : null}
{world.summary ? <p className="mt-4 max-w-2xl text-sm leading-6 text-slate-200/85">{world.summary}</p> : null}
</div>
<div className="mt-6 flex flex-wrap items-end justify-between gap-3">
<div className="space-y-1 text-sm text-slate-200/80">
{world.timeframe_label ? <div>{world.timeframe_label}</div> : null}
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-white/55">
<i className={world.icon_name || 'fa-solid fa-globe'} />
<span>{world.theme?.label || world.type}</span>
</div>
</div>
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-white transition group-hover:bg-white/15">
{world.cta_label || 'Open world'}
<i className="fa-solid fa-arrow-right" />
</span>
</div>
</div>
</a>
)
}

View File

@@ -0,0 +1,56 @@
import React from 'react'
function statusTone(item) {
return item?.status_label === 'Featured'
? 'border-amber-300/30 bg-amber-400/12 text-amber-100'
: 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100'
}
export default function WorldCommunitySubmissionsSection({ section }) {
if (!section || !Array.isArray(section.items) || section.items.length === 0) {
return null
}
return (
<section className="mt-10">
<div className="mb-5 flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section.title}</h2>
{section.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{section.description}</p> : null}
</div>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{section.items.length} artworks</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{section.items.map((item) => (
<a key={item.id} href={item.url} className="group overflow-hidden rounded-[26px] border border-white/10 bg-white/[0.03] transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
<div className="relative overflow-hidden border-b border-white/10 bg-slate-950/80">
{item.image ? (
<img src={item.image} alt={item.title} className="aspect-[16/10] w-full object-cover transition duration-500 group-hover:scale-[1.04]" />
) : (
<div className="aspect-[16/10] w-full bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))]" />
)}
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between gap-2 p-4">
{item.context_label ? <span className="rounded-full border border-white/15 bg-black/35 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white/80">{item.context_label}</span> : null}
{item.status_label ? <span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${statusTone(item)}`}>{item.status_label}</span> : null}
</div>
</div>
<div className="p-4">
<h3 className="text-lg font-semibold tracking-[-0.02em] text-white">{item.title}</h3>
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.subtitle}</div> : null}
{item.description ? <p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p> : null}
{Array.isArray(item.meta) && item.meta.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{item.meta.map((entry) => (
<span key={entry} className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs text-slate-300">{entry}</span>
))}
</div>
) : null}
</div>
</a>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,91 @@
import React from 'react'
function styleForWorld(world) {
return {
'--world-accent': world?.theme?.accent_color || '#38bdf8',
'--world-accent-secondary': world?.theme?.accent_color_secondary || '#0f172a',
}
}
function resolvedIconName(world) {
const icon = String(world?.icon_name || '').trim()
if (icon) {
return icon
}
const themeIcon = String(world?.theme?.icon_name || '').trim()
return themeIcon || 'fa-solid fa-globe'
}
export default function WorldHero({ world, previewMode = false }) {
if (!world) {
return null
}
return (
<section className="relative overflow-hidden rounded-[36px] border border-white/10" style={styleForWorld(world)}>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,_color-mix(in_srgb,var(--world-accent)_30%,transparent),_transparent_34%),radial-gradient(circle_at_82%_18%,_color-mix(in_srgb,var(--world-accent-secondary)_68%,transparent),_transparent_42%),linear-gradient(135deg,_rgba(2,6,23,0.92),_rgba(15,23,42,0.82)_45%,_rgba(2,6,23,0.95))]" />
{world.cover_url ? <img src={world.cover_url} alt={world.title} className="absolute inset-0 h-full w-full object-cover opacity-20" /> : null}
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/80 to-slate-950/20" />
<div className="relative grid gap-10 px-6 py-8 sm:px-8 lg:grid-cols-[minmax(0,1.25fr)_20rem] lg:px-10 lg:py-10">
<div>
{previewMode ? <div className="inline-flex items-center gap-2 rounded-full border border-amber-300/25 bg-amber-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-amber-100">Preview Mode</div> : null}
<div className="mt-4 flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100/70">
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{world.type}</span>
{world.badge_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.badge_label}</span> : null}
{world.timeframe_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.timeframe_label}</span> : null}
</div>
<h1 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl lg:text-6xl">{world.title}</h1>
{world.tagline ? <p className="mt-4 text-sm uppercase tracking-[0.24em] text-white/55">{world.tagline}</p> : null}
{world.summary ? <p className="mt-6 max-w-3xl text-base leading-7 text-slate-200/86 sm:text-lg">{world.summary}</p> : null}
{world.description ? (
<div
className="prose prose-invert prose-sm mt-5 max-w-3xl prose-p:text-slate-300/88 prose-p:leading-7 prose-headings:text-white prose-strong:text-white prose-a:text-sky-300 prose-blockquote:border-l-sky-500/40 prose-blockquote:text-slate-300 prose-code:text-amber-300 prose-code:bg-white/[0.06] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-pre:border prose-pre:border-white/[0.06] prose-pre:bg-white/[0.04] prose-hr:border-white/10 prose-ul:text-slate-300/88 prose-ol:text-slate-300/88"
dangerouslySetInnerHTML={{ __html: world.description }}
/>
) : null}
<div className="mt-8 flex flex-wrap gap-3">
{world.cta_url ? <a href={world.cta_url} className="inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-semibold text-slate-950 transition hover:bg-sky-100">{world.cta_label || 'Explore'}<i className="fa-solid fa-arrow-right" /></a> : null}
{world.public_url ? <a href={world.public_url} className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/8 px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/12">Canonical page<i className="fa-solid fa-up-right-from-square" /></a> : null}
</div>
{Array.isArray(world.related_tags) && world.related_tags.length > 0 ? (
<div className="mt-8 flex flex-wrap gap-2">
{world.related_tags.map((tag) => (
<span key={tag} className="rounded-full border border-white/12 bg-black/25 px-3 py-1.5 text-xs font-medium uppercase tracking-[0.16em] text-slate-200/80">#{tag}</span>
))}
</div>
) : null}
</div>
<aside className="grid gap-4 self-end">
<div className="rounded-[28px] border border-white/12 bg-black/25 p-5 text-white shadow-2xl shadow-slate-950/30 backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/12 bg-white/10 text-lg text-white">
<i className={resolvedIconName(world)} />
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Theme</div>
<div className="mt-1 text-lg font-semibold">{world.theme?.label || world.type}</div>
</div>
</div>
{world.badge_description ? <p className="mt-4 text-sm leading-6 text-slate-300">{world.badge_description}</p> : null}
<div className="mt-5 grid gap-3 text-sm text-slate-200/90">
{world.timeframe_label ? <div className="flex items-center gap-2"><i className="fa-regular fa-calendar" /><span>{world.timeframe_label}</span></div> : null}
{world.edition_year ? <div className="flex items-center gap-2"><i className="fa-solid fa-repeat" /><span>Edition {world.edition_year}</span></div> : null}
{world.is_recurring ? <div className="flex items-center gap-2"><i className="fa-solid fa-clock-rotate-left" /><span>Recurring world</span></div> : null}
</div>
{world.badge_url ? <a href={world.badge_url} className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-sky-100 hover:text-white">View badge<i className="fa-solid fa-arrow-right" /></a> : null}
</div>
</aside>
</div>
</section>
)
}

View File

@@ -0,0 +1,51 @@
import React from 'react'
function EntityCard({ item }) {
return (
<a href={item.url} className="group rounded-[26px] border border-white/10 bg-white/[0.03] p-4 transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
<div className="relative overflow-hidden rounded-[22px] border border-white/10 bg-slate-950/70">
{item.image ? (
<img src={item.image} alt={item.title} className="aspect-[16/10] w-full object-cover transition duration-500 group-hover:scale-[1.04]" />
) : (
<div className="aspect-[16/10] w-full bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))]" />
)}
{item.avatar ? <img src={item.avatar} alt="" className="absolute bottom-3 left-3 h-12 w-12 rounded-2xl border border-white/15 object-cover shadow-lg shadow-black/40" /> : null}
</div>
<div className="mt-4">
{item.context_label ? <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/70">{item.context_label}</div> : null}
<h3 className="mt-2 text-lg font-semibold tracking-[-0.02em] text-white">{item.title}</h3>
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.subtitle}</div> : null}
{item.description ? <p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p> : null}
{Array.isArray(item.meta) && item.meta.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{item.meta.map((entry) => (
<span key={entry} className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs text-slate-300">{entry}</span>
))}
</div>
) : null}
</div>
</a>
)
}
export default function WorldSection({ section }) {
if (!section || !Array.isArray(section.items) || section.items.length === 0) {
return null
}
return (
<section className="mt-10">
<div className="mb-5 flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section.title}</h2>
{section.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{section.description}</p> : null}
</div>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{section.items.length} items</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{section.items.map((item) => <EntityCard key={`${item.entity_type}-${item.id}`} item={item} />)}
</div>
</section>
)
}

View File

@@ -0,0 +1,164 @@
import React from 'react'
function statusTone(item) {
if (item?.is_featured) {
return 'border-amber-300/30 bg-amber-400/10 text-amber-100'
}
switch (item?.status) {
case 'live':
return 'border-emerald-300/30 bg-emerald-400/10 text-emerald-100'
case 'removed':
return 'border-orange-300/30 bg-orange-400/10 text-orange-100'
case 'blocked':
return 'border-rose-300/30 bg-rose-400/10 text-rose-100'
case 'pending':
return 'border-sky-300/30 bg-sky-400/10 text-sky-100'
default:
return 'border-white/10 bg-white/[0.04] text-slate-300'
}
}
function modeTone(mode) {
switch (mode) {
case 'manual_approval':
return 'border-sky-300/30 bg-sky-400/10 text-sky-100'
case 'auto_add':
return 'border-emerald-300/30 bg-emerald-400/10 text-emerald-100'
default:
return 'border-white/10 bg-white/[0.04] text-slate-300'
}
}
function dateBadgeLabel(item) {
const timeframe = String(item?.timeframe_label || '').trim()
const submissionWindow = String(item?.submission_window_label || '').trim()
if (timeframe && submissionWindow) {
return timeframe === submissionWindow ? timeframe : `${submissionWindow}${timeframe}`
}
return submissionWindow || timeframe || ''
}
export default function WorldSubmissionSelector({
title = 'Add to Worlds',
description = 'Attach this artwork to active worlds while keeping community participation separate from curated editorial relations.',
options = [],
emptyMessage = 'No worlds are currently open for creator participation.',
onToggle,
onNoteChange,
className = '',
}) {
const items = Array.isArray(options) ? options : []
return (
<section className={`rounded-[28px] border border-white/10 bg-white/[0.03] p-5 ${className}`.trim()}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-xl font-semibold text-white">{title}</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-400">{description}</p>
</div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{items.filter((item) => item.selected).length} selected</div>
</div>
{items.length === 0 ? (
<div className="mt-5 rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">{emptyMessage}</div>
) : (
<div className="mt-5 grid gap-4 xl:grid-cols-2">
{items.map((item) => {
const checked = Boolean(item.selected)
const locked = Boolean(item.selection_locked)
const combinedDateLabel = dateBadgeLabel(item)
return (
<div key={item.id} className={`overflow-hidden rounded-[24px] border ${checked ? 'border-sky-300/25 bg-sky-400/[0.07]' : 'border-white/10 bg-black/20'}`}>
<button
type="button"
onClick={() => !locked && onToggle?.(item.id)}
disabled={locked}
className="w-full p-4 text-left disabled:cursor-not-allowed disabled:opacity-100"
>
<div className="grid gap-4 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-start">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950/80">
{item.cover_url ? (
<img src={item.cover_url} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_48%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))] text-slate-500">
<i className="fa-solid fa-globe text-lg" />
</div>
)}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-white">{item.title}</h3>
{item.status_label ? (
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${statusTone(item)}`}>
{item.status_label}
</span>
) : null}
{item.participation_mode_label ? (
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${modeTone(item.participation_mode)}`}>
{item.participation_mode_label}
</span>
) : null}
</div>
{item.tagline ? <p className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.tagline}</p> : null}
</div>
<span className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs md:mt-0.5 ${checked ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-500'}`}>
{checked ? '✓' : ''}
</span>
{item.summary ? <p className="text-sm leading-6 text-slate-300 md:col-span-3">{item.summary}</p> : null}
<div className="flex flex-wrap gap-2 text-xs text-slate-300 md:col-span-3">
{combinedDateLabel ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{combinedDateLabel}</span> : null}
{item.can_resubmit ? <span className="rounded-full border border-amber-300/25 bg-amber-400/10 px-3 py-1 text-amber-100">Can re-add</span> : null}
</div>
</div>
</button>
<div className="border-t border-white/10 px-4 py-4">
{item.submission_guidelines ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm leading-6 text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">World guidelines</div>
<div className="mt-2">{item.submission_guidelines}</div>
</div>
) : null}
{item.selection_locked_reason ? (
<div className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">{item.selection_locked_reason}</div>
) : null}
{item.reviewer_note ? (
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Moderator note</div>
<div className="mt-2 leading-6">{item.reviewer_note}</div>
</div>
) : null}
{checked && item.submission_note_enabled ? (
<label className="mt-3 grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Creator note</span>
<textarea
rows={4}
value={item.note || ''}
onChange={(event) => onNoteChange?.(item.id, event.target.value)}
disabled={locked}
placeholder="Optional note for world moderators: fit, context, challenge angle, or why this artwork belongs here."
className="nova-scrollbar rounded-[20px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
) : null}
</div>
</div>
)
})}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
export default function WorldDuplicateActionMenu({ duplicateUrl, newEditionUrl, canCreateEdition, onDuplicate, onCreateEdition }) {
if (!duplicateUrl && !newEditionUrl) {
return null
}
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold text-white">Reuse this world</div>
<p className="mt-1 text-sm leading-6 text-slate-400">Duplicate the current campaign structure or roll it forward into the next edition without rebuilding the curated setup.</p>
</div>
<div className="flex flex-wrap gap-2">
{duplicateUrl ? <button type="button" onClick={onDuplicate} className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white">Duplicate world</button> : null}
{newEditionUrl ? <button type="button" onClick={onCreateEdition} disabled={!canCreateEdition} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-100 disabled:cursor-not-allowed disabled:opacity-50">Create next edition</button> : null}
</div>
</div>
{!canCreateEdition ? <div className="mt-3 text-xs leading-5 text-slate-500">Next-edition creation unlocks once this world has recurrence data.</div> : null}
<div className="mt-3 text-xs leading-5 text-slate-500">Template creation is prepared through duplication. A dedicated preset/template browser can be layered on top later without changing the editor data model.</div>
</div>
)
}

View File

@@ -0,0 +1,231 @@
import React, { useMemo, useRef, useState } from 'react'
function formatBytes(bytes) {
const value = Number(bytes || 0)
if (!Number.isFinite(value) || value <= 0) return null
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
return `${(value / (1024 * 1024)).toFixed(1)} MB`
}
export default function WorldMediaUploadField({
label,
slot,
value,
previewUrl,
emptyLabel,
helperText,
uploadUrl,
deleteUrl,
worldId = null,
onChange,
isTemporaryValue = false,
accept = 'image/jpeg,image/png,image/webp',
maxFileSizeMb = 6,
}) {
const inputRef = useRef(null)
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [meta, setMeta] = useState(null)
const csrfToken = useMemo(
() => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
[],
)
const deleteTemporaryUpload = async (path) => {
if (!deleteUrl || !path) return
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
path,
world_id: worldId || undefined,
}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Could not remove uploaded image.')
}
}
const handleFile = async (file) => {
if (!file || uploading) return
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (!allowed.includes(String(file.type || '').toLowerCase())) {
setError('Use a JPG, PNG, or WEBP image.')
return
}
if (file.size > maxFileSizeMb * 1024 * 1024) {
setError(`Image is too large. Maximum allowed size is ${maxFileSizeMb} MB.`)
return
}
setUploading(true)
setError('')
try {
if (value && isTemporaryValue) {
await deleteTemporaryUpload(value)
}
const body = new FormData()
body.append('slot', slot)
body.append('image', file)
if (worldId) {
body.append('world_id', String(worldId))
}
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
credentials: 'same-origin',
body,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Upload failed.')
}
setMeta({
width: payload?.width || null,
height: payload?.height || null,
size: formatBytes(payload?.size_bytes),
})
onChange?.({ path: payload?.path || '', url: payload?.url || '' })
} catch (uploadError) {
setError(uploadError?.message || 'Upload failed.')
} finally {
setUploading(false)
if (inputRef.current) {
inputRef.current.value = ''
}
}
}
return (
<div className="grid gap-3 text-sm text-slate-300">
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
{value ? (
<button
type="button"
onClick={async (event) => {
event.stopPropagation()
setError('')
setMeta(null)
try {
if (value && isTemporaryValue) {
setUploading(true)
await deleteTemporaryUpload(value)
}
onChange?.({ path: '', url: '' })
} catch (deleteError) {
setError(deleteError?.message || 'Could not remove uploaded image.')
} finally {
setUploading(false)
}
}}
disabled={uploading}
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white"
>
Clear
</button>
) : null}
</div>
<div
role="button"
tabIndex={0}
onClick={() => !uploading && inputRef.current?.click()}
onKeyDown={(event) => {
if (uploading) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
inputRef.current?.click()
}
}}
onDragOver={(event) => {
event.preventDefault()
if (!uploading) setDragging(true)
}}
onDragEnter={(event) => {
event.preventDefault()
if (!uploading) setDragging(true)
}}
onDragLeave={(event) => {
event.preventDefault()
setDragging(false)
}}
onDrop={(event) => {
event.preventDefault()
setDragging(false)
void handleFile(event.dataTransfer?.files?.[0])
}}
className={[
'rounded-[24px] border border-dashed px-5 py-5 transition outline-none',
uploading
? 'cursor-progress border-sky-300/35 bg-sky-400/10'
: dragging
? 'cursor-pointer border-sky-300/50 bg-sky-400/12'
: 'cursor-pointer border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]',
].join(' ')}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100">
<i className={`fa-solid ${uploading ? 'fa-circle-notch fa-spin' : 'fa-cloud-arrow-up'}`} />
</div>
<div>
<div className="text-sm font-semibold text-white">{uploading ? 'Uploading image…' : 'Drop image here or browse'}</div>
<div className="mt-1 text-xs leading-5 text-slate-400">{helperText}</div>
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-400">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">JPG</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">PNG</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">WEBP</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">Max {maxFileSizeMb} MB</span>
</div>
</div>
</div>
<div className="h-28 w-full overflow-hidden rounded-[20px] border border-white/10 bg-slate-950 lg:w-44">
{previewUrl ? (
<img src={previewUrl} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-500">{emptyLabel}</div>
)}
</div>
</div>
{value ? <div className="mt-4 truncate rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs text-slate-400">Stored path: <span className="text-slate-200">{value}</span></div> : null}
{meta ? <div className="mt-3 text-xs text-slate-400">Optimized to {meta.width}×{meta.height}{meta.size ? `${meta.size}` : ''}</div> : null}
{error ? <div className="mt-3 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
disabled={uploading}
onChange={(event) => {
void handleFile(event.target.files?.[0])
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import React from 'react'
import WorldPreviewButton from './WorldPreviewButton'
export default function WorldMiniPreviewPanel({ world, sections, previewUrl }) {
return (
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-white">Live mini preview</h2>
<p className="mt-1 text-sm leading-6 text-slate-400">Hero hierarchy, CTA, badges, section order, and attached content update immediately as you edit.</p>
</div>
<WorldPreviewButton previewUrl={previewUrl} />
</div>
<div className="mt-5 overflow-hidden rounded-[28px] border border-white/10 bg-slate-950">
<div className="relative px-5 py-5" style={{ background: `linear-gradient(135deg, ${world?.accent_color || '#38bdf8'}22, transparent 45%), linear-gradient(180deg, #020617 0%, #0f172a 100%)` }}>
{world?.cover_url ? <img src={world.cover_url} alt="" className="absolute inset-0 h-full w-full object-cover opacity-25" /> : null}
<div className="relative">
<div className="flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{world?.type || 'seasonal'}</span>
{world?.badge_label ? <span className="rounded-full border border-white/15 bg-black/25 px-3 py-1">{world.badge_label}</span> : null}
{world?.is_featured ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-emerald-100">Homepage feature</span> : null}
</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.05em] text-white">{world?.title || 'Untitled world'}</div>
{world?.tagline ? <div className="mt-3 text-xs uppercase tracking-[0.22em] text-white/60">{world.tagline}</div> : null}
{world?.summary ? <div className="mt-4 max-w-2xl text-sm leading-7 text-slate-200/85">{world.summary}</div> : null}
<div className="mt-6 flex flex-wrap gap-3">
{world?.cta_label ? <span className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-semibold text-slate-950">{world.cta_label}<i className="fa-solid fa-arrow-right" /></span> : null}
{world?.badge_description ? <span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/8 px-4 py-2 text-sm font-semibold text-white">{world.badge_description}</span> : null}
</div>
</div>
</div>
<div className="border-t border-white/10 px-5 py-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visible section order</div>
<div className="mt-3 grid gap-3">
{Array.isArray(sections) && sections.length > 0 ? sections.map((section) => (
<div key={section.key} className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{section.label}</div>
<div className="mt-1 text-xs text-slate-500">{section.count} attached items</div>
</div>
{section.count === 0 ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Empty</span> : null}
</div>
{Array.isArray(section.items) && section.items.length > 0 ? <div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-400">{section.items.map((item) => <span key={`${section.key}-${item.id}`} className="rounded-full bg-white/[0.04] px-3 py-1.5">{item.title}</span>)}</div> : null}
</div>
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No sections are visible yet. Enable sections and attach content to shape the public world.</div>}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import React from 'react'
export default function WorldPreviewButton({ previewUrl, className = '', disabledReason = 'Save the world once to unlock the full preview page.' }) {
if (!previewUrl) {
return (
<div className={`rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-400 ${className}`.trim()}>
<div className="font-semibold text-slate-200">Full preview unavailable</div>
<div className="mt-1 text-xs leading-5 text-slate-500">{disabledReason}</div>
</div>
)
}
return (
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
className={`inline-flex items-center justify-center gap-2 rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-3 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15 ${className}`.trim()}
>
<i className="fa-regular fa-eye" />
Open full preview
</a>
)
}

View File

@@ -0,0 +1,31 @@
import React from 'react'
export default function WorldRecurrenceHelper({ enabled, recurrenceKey, editionYear, recurrenceKeyError, editionYearError }) {
if (!enabled) {
return (
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm leading-6 text-slate-400">
Turn on recurrence when this world belongs to a campaign family such as Halloween, Retro Month, or Pixel Week and needs a reusable edition pattern.
</div>
)
}
const exampleKey = recurrenceKey || 'halloween'
const exampleYear = editionYear || new Date().getFullYear()
return (
<div className="rounded-[24px] border border-sky-300/15 bg-sky-400/10 p-4 text-sm text-slate-200">
<div className="font-semibold text-white">Recurring world guidance</div>
<div className="mt-2 space-y-2 leading-6 text-slate-300">
<p>Use the recurrence key to identify the campaign family. Example: <span className="font-semibold text-white">{exampleKey}</span>.</p>
<p>Use the edition year for the specific annual or seasonal instance. Example: <span className="font-semibold text-white">{exampleYear}</span>.</p>
<p className="text-sky-100">Example output: {exampleKey === '' ? 'Halloween' : exampleKey.replace(/-/g, ' ')} {exampleYear} is part of the recurring world <span className="font-semibold text-white">{exampleKey}</span>.</p>
</div>
{recurrenceKeyError || editionYearError ? (
<div className="mt-3 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-xs leading-5 text-rose-100">
{recurrenceKeyError || editionYearError}
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,51 @@
import React from 'react'
function SmallBadge({ children, tone = 'default' }) {
const styles = {
default: 'border-white/10 bg-white/[0.06] text-slate-200',
accent: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
feature: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
}
return <span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${styles[tone] || styles.default}`}>{children}</span>
}
export default function WorldRelationCard({ relation, index, total, sectionLabel, onEdit, onRemove, onMove }) {
const preview = relation?.preview || null
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-start gap-4">
<div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
{preview?.image ? <img src={preview.image} alt="" className="h-full w-full object-cover" /> : null}
{!preview?.image && preview?.avatar ? <img src={preview.avatar} alt="" className="h-full w-full object-cover" /> : null}
{!preview?.image && !preview?.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-shapes" /></div> : null}
{preview?.avatar && preview?.image ? <img src={preview.avatar} alt="" className="absolute bottom-2 left-2 h-8 w-8 rounded-xl border border-white/10 object-cover" /> : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
{preview?.entity_label ? <SmallBadge tone="accent">{preview.entity_label}</SmallBadge> : null}
{sectionLabel ? <SmallBadge>{sectionLabel}</SmallBadge> : null}
{relation?.is_featured ? <SmallBadge tone="feature">Featured</SmallBadge> : null}
</div>
<div className="mt-3 text-base font-semibold text-white">{preview?.title || 'Choose a relation'}</div>
{preview?.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{preview.subtitle}</div> : null}
{preview?.description ? <div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{preview.description}</div> : null}
{relation?.context_label ? <div className="mt-2 text-sm font-medium text-sky-100">{relation.context_label}</div> : null}
{Array.isArray(preview?.meta) && preview.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{preview.meta.map((item) => <span key={item}>{item}</span>)}</div> : null}
</div>
<div className="flex flex-col gap-2">
<button type="button" onClick={() => onMove(index, -1)} disabled={index === 0} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:opacity-40">Up</button>
<button type="button" onClick={() => onMove(index, 1)} disabled={index === total - 1} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:opacity-40">Down</button>
<button type="button" onClick={onEdit} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold text-sky-100">Edit</button>
<button type="button" onClick={onRemove} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold text-rose-100">Remove</button>
</div>
</div>
{preview?.url ? <a href={preview.url} target="_blank" rel="noreferrer" className="mt-4 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300 hover:text-white">Open entity <i className="fa-solid fa-up-right-from-square" /></a> : null}
</div>
)
}

View File

@@ -0,0 +1,184 @@
import React, { useEffect, useMemo, useState } from 'react'
import Modal from '../../ui/Modal'
import { Checkbox, NovaSelect } from '../../ui'
function relationTypeForSection(sectionKey, sectionOptions, relationTypeOptions) {
const section = sectionOptions.find((option) => option.value === sectionKey)
return section?.relation_types?.[0] || relationTypeOptions?.[0]?.value || 'artwork'
}
function emptyRelation(sectionOptions, relationTypeOptions) {
const sectionKey = sectionOptions?.[0]?.value || 'featured_artworks'
const relationType = relationTypeForSection(sectionKey, sectionOptions, relationTypeOptions)
return {
section_key: sectionKey,
related_type: relationType,
related_id: '',
context_label: '',
sort_order: 0,
is_featured: false,
preview: null,
query: '',
}
}
function SearchResultList({ items, loading, selectedId, onSelect }) {
if (loading) {
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Searching campaign entities</div>
}
if (!Array.isArray(items) || items.length === 0) {
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Search by title, slug, creator, or project name to attach curated content.</div>
}
return (
<div className="grid gap-3">
{items.map((item) => (
<button key={`${item.entity_type}-${item.id}`} type="button" onClick={() => onSelect(item)} className={`min-w-0 flex items-start gap-3 rounded-[24px] border px-4 py-4 text-left transition ${String(selectedId) === String(item.id) ? 'border-emerald-300/25 bg-emerald-400/10' : 'border-white/10 bg-black/20 hover:border-white/20'}`}>
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950">
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : null}
{!item.image && item.avatar ? <img src={item.avatar} alt="" className="h-full w-full object-cover" /> : null}
{!item.image && !item.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-shapes" /></div> : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
{item.entity_label ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{item.entity_label}</span> : null}
</div>
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
{item.description ? <div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{item.description}</div> : null}
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{item.meta.map((meta) => <span key={meta}>{meta}</span>)}</div> : null}
</div>
</button>
))}
</div>
)
}
export default function WorldRelationPickerModal({ open, onClose, onSave, initialRelation, sectionOptions, relationTypeOptions, searchEntities }) {
const [draft, setDraft] = useState(() => emptyRelation(sectionOptions, relationTypeOptions))
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) return
const nextDraft = initialRelation || emptyRelation(sectionOptions, relationTypeOptions)
setDraft({
...nextDraft,
query: nextDraft.query || nextDraft.preview?.title || '',
})
setResults([])
setLoading(false)
}, [open, initialRelation, sectionOptions, relationTypeOptions])
const selectedSection = useMemo(() => sectionOptions.find((option) => option.value === draft.section_key), [sectionOptions, draft.section_key])
const availableRelationTypes = useMemo(() => relationTypeOptions.filter((option) => !selectedSection?.relation_types?.length || selectedSection.relation_types.includes(option.value)), [relationTypeOptions, selectedSection])
const selectedPreview = useMemo(() => {
if (draft.preview) return draft.preview
return results.find((item) => String(item.id) === String(draft.related_id)) || null
}, [draft.preview, draft.related_id, results])
useEffect(() => {
if (!open || availableRelationTypes.length === 0) return
if (availableRelationTypes.some((option) => option.value === draft.related_type)) return
setDraft((current) => ({
...current,
related_type: availableRelationTypes[0].value,
related_id: '',
preview: null,
}))
}, [open, availableRelationTypes, draft.related_type])
useEffect(() => {
if (!open || !draft.related_type) {
setResults([])
setLoading(false)
return undefined
}
let cancelled = false
const timeoutId = window.setTimeout(async () => {
setLoading(true)
try {
const items = await searchEntities(draft.related_type, draft.query || '')
if (!cancelled) {
setResults(Array.isArray(items) ? items : [])
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}, draft.query ? 220 : 0)
return () => {
cancelled = true
window.clearTimeout(timeoutId)
}
}, [open, draft.related_type, draft.query, searchEntities])
const actionLabel = initialRelation?.related_id ? 'Save relation' : 'Attach relation'
const canSubmit = Boolean(draft.related_id)
const nextRelation = selectedPreview ? { ...draft, preview: selectedPreview } : draft
const footer = (
<>
<button type="button" onClick={onClose} className="rounded-full border border-white/10 px-4 py-2 text-sm font-semibold text-white">Cancel</button>
<button type="button" onClick={() => onSave(nextRelation)} disabled={!canSubmit} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:cursor-not-allowed disabled:opacity-50">{actionLabel}</button>
</>
)
return (
<Modal open={open} onClose={onClose} title="Attach curated relation" size="2xl" footer={footer}>
<div className="grid gap-5 overflow-x-hidden">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.35fr)] lg:items-end">
<NovaSelect label="Section" value={draft.section_key || null} onChange={(nextValue) => setDraft((current) => {
const nextSectionKey = String(nextValue || '')
return {
...current,
section_key: nextSectionKey,
related_type: relationTypeForSection(nextSectionKey, sectionOptions, relationTypeOptions),
related_id: '',
preview: null,
}
})} options={sectionOptions} searchable={false} className="bg-black/20" />
<NovaSelect label="Entity type" value={draft.related_type || null} onChange={(nextValue) => setDraft((current) => ({ ...current, related_type: String(nextValue || ''), related_id: '', preview: null }))} options={availableRelationTypes} searchable={false} className="bg-black/20" />
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
<div className="flex min-w-0 flex-wrap gap-2 sm:flex-nowrap">
<input value={draft.query || ''} onChange={(event) => setDraft((current) => ({ ...current, query: event.target.value }))} placeholder="Search title, slug, group, or creator" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="shrink-0 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-slate-300">Auto</div>
</div>
</label>
</div>
<SearchResultList items={results} loading={loading} selectedId={draft.related_id} onSelect={(item) => setDraft((current) => ({ ...current, related_id: item.id, preview: item, query: item.title }))} />
{selectedPreview ? (
<div className="rounded-[24px] border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
<div className="break-words font-semibold">Selected: {selectedPreview.title}</div>
{selectedPreview.subtitle ? <div className="mt-1 break-words text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedPreview.subtitle}</div> : null}
{Array.isArray(selectedPreview.meta) && selectedPreview.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-emerald-100/75">{selectedPreview.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
<div className="mt-2 break-words text-xs text-emerald-100/80">Section: {selectedSection?.label || draft.section_key} · {draft.is_featured ? 'Featured relation' : 'Standard relation'}</div>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_10rem] lg:grid-cols-[minmax(0,1fr)_10rem_minmax(0,15rem)] md:items-end">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Context label</span>
<input value={draft.context_label || ''} onChange={(event) => setDraft((current) => ({ ...current, context_label: event.target.value }))} placeholder="Featured release, Join this challenge, Meet the creator" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Sort order</span>
<input type="number" min="0" value={draft.sort_order} onChange={(event) => setDraft((current) => ({ ...current, sort_order: Number(event.target.value) || 0 }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 md:col-span-2 lg:col-span-1">
<Checkbox checked={Boolean(draft.is_featured)} onChange={(event) => setDraft((current) => ({ ...current, is_featured: event.target.checked }))} label="Featured relation" size={20} variant="accent" />
</div>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,60 @@
import React from 'react'
import { Checkbox } from '../../ui'
function Pill({ children, tone = 'default' }) {
const tones = {
default: 'border-white/10 bg-white/[0.05] text-slate-200',
accent: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
muted: 'border-white/10 bg-black/20 text-slate-400',
warn: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
}
return <span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${tones[tone]}`}>{children}</span>
}
export default function WorldSectionToggleList({ sectionOptions, order, visibility, relationCounts, onChange }) {
const selected = Array.isArray(order) && order.length > 0 ? order : sectionOptions.map((option) => option.value)
const move = (index, delta) => {
const nextIndex = index + delta
if (nextIndex < 0 || nextIndex >= selected.length) return
const next = [...selected]
const [entry] = next.splice(index, 1)
next.splice(nextIndex, 0, entry)
onChange(next, visibility)
}
const toggle = (key, enabled) => {
onChange(selected, { ...visibility, [key]: enabled })
}
return (
<div className="grid gap-3">
{selected.map((key, index) => {
const option = sectionOptions.find((entry) => entry.value === key)
if (!option) return null
return (
<div key={key} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Checkbox checked={visibility?.[key] !== false} onChange={(event) => toggle(key, event.target.checked)} label={option.label} size={20} variant="accent" />
{option.description ? <div className="mt-2 text-sm leading-6 text-slate-400">{option.description}</div> : null}
<div className="mt-3 flex flex-wrap gap-2">
<Pill tone={visibility?.[key] !== false ? 'accent' : 'muted'}>{visibility?.[key] !== false ? 'Visible on public page' : 'Hidden on public page'}</Pill>
<Pill tone={(relationCounts?.[key] || 0) > 0 ? 'default' : 'warn'}>{relationCounts?.[key] || 0} attached items</Pill>
</div>
{(relationCounts?.[key] || 0) === 0 ? <div className="mt-2 text-xs leading-5 text-slate-500">This section is ready, but it will stay empty until you attach curated items.</div> : null}
</div>
<div className="flex gap-2">
<button type="button" onClick={() => move(index, -1)} disabled={index === 0} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40">Up</button>
<button type="button" onClick={() => move(index, 1)} disabled={index === selected.length - 1} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40">Down</button>
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,115 @@
import React from 'react'
function formatDateTime(value) {
if (!value) return 'Not set'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Not set'
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
function typeLabel(value) {
const labels = {
seasonal: 'Seasonal',
event: 'Event',
campaign: 'Campaign',
tribute: 'Tribute',
}
return labels[value] || value || 'Seasonal'
}
function promotionState(world, state) {
if (!world?.is_featured) {
return {
label: 'Public page only',
message: 'This world will live at its own URL, but it is not currently marked for homepage or Worlds spotlight placement.',
tone: 'slate',
}
}
if (state.label === 'Live') {
return {
label: 'Active seasonal promotion',
message: 'Featured promotion is enabled and the world is live, so it is ready for homepage spotlight and promoted Worlds surfaces.',
tone: 'emerald',
}
}
return {
label: 'Homepage spotlight eligible',
message: 'Featured promotion is enabled. Once the world is live, it becomes eligible for homepage and Worlds spotlight treatment.',
tone: 'sky',
}
}
function workflowState(world) {
const now = Date.now()
const publishedAt = world?.published_at ? new Date(world.published_at).getTime() : null
const startsAt = world?.starts_at ? new Date(world.starts_at).getTime() : null
const endsAt = world?.ends_at ? new Date(world.ends_at).getTime() : null
if (world?.status === 'archived') {
return { label: 'Archived', message: 'This world has ended and is no longer part of the active campaign cycle.', tone: 'amber' }
}
if (world?.status !== 'published') {
return { label: 'Draft', message: 'Editors can keep refining this world before it becomes publicly visible.', tone: 'slate' }
}
if (publishedAt && publishedAt > now) {
return { label: 'Scheduled', message: `This world will publish automatically on ${formatDateTime(world.published_at)}.`, tone: 'sky' }
}
if (startsAt && startsAt > now) {
return { label: 'Scheduled', message: `This world is published and will go live automatically on ${formatDateTime(world.starts_at)}.`, tone: 'sky' }
}
if (endsAt && endsAt < now) {
return { label: 'Ended', message: 'The campaign window has passed. Archive it or create a new edition to continue the lineage.', tone: 'amber' }
}
return { label: 'Live', message: 'This world is currently active on public surfaces.', tone: 'emerald' }
}
const tones = {
slate: 'border-white/10 bg-white/[0.04] text-slate-100',
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
}
export default function WorldSummaryCard({ world, themeLabel, relationCount, enabledSectionsCount }) {
const state = workflowState(world)
const promotion = promotionState(world, state)
return (
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-white">Campaign summary</h2>
<p className="mt-1 text-sm leading-6 text-slate-400">See the world lifecycle, promotion state, and editorial readiness without parsing the whole form.</p>
</div>
<div className={`rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${tones[state.tone]}`}>{state.label}</div>
</div>
<div className={`mt-4 rounded-[24px] border px-4 py-4 text-sm leading-6 ${tones[state.tone]}`}>
{state.message}
</div>
<div className={`mt-3 rounded-[24px] border px-4 py-4 text-sm leading-6 ${tones[promotion.tone]}`}>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">Promotion scope</div>
<div className="mt-1 font-semibold">{promotion.label}</div>
<div className="mt-1">{promotion.message}</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</div><div className="mt-2 text-sm font-semibold text-white">{typeLabel(world?.type)}</div></div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Theme preset</div><div className="mt-2 text-sm font-semibold text-white">{themeLabel || 'No preset'}</div></div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Campaign window</div><div className="mt-2 text-sm font-semibold text-white">{world?.starts_at || world?.ends_at ? `${formatDateTime(world?.starts_at)} to ${formatDateTime(world?.ends_at)}` : 'Open ended'}</div></div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</div><div className="mt-2 text-sm font-semibold text-white">{formatDateTime(world?.published_at)}</div></div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recurrence</div><div className="mt-2 text-sm font-semibold text-white">{world?.is_recurring ? `${world?.recurrence_key || 'recurring'} ${world?.edition_year || ''}`.trim() : 'One-off world'}</div></div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Editorial setup</div><div className="mt-2 text-sm font-semibold text-white">{relationCount} relations · {enabledSectionsCount} enabled sections</div></div>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import React from 'react'
function Pill({ children }) {
return <span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{children}</span>
}
function ColorSwatch({ label, value }) {
if (!value) return null
return (
<div className="flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
<span className="h-3 w-3 rounded-full border border-white/15" style={{ backgroundColor: value }} />
<span>{label}</span>
</div>
)
}
export default function WorldThemePresetHelper({ theme, onApply }) {
if (!theme) {
return null
}
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold text-white">{theme.label} preset</div>
<p className="mt-1 text-sm leading-6 text-slate-400">Preset suggestions fill the campaign basics fast. You can still override every field manually afterwards.</p>
</div>
<button type="button" onClick={onApply} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100">
Apply suggestions
</button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<ColorSwatch label={theme.accent_color || 'accent'} value={theme.accent_color} />
<ColorSwatch label={theme.accent_color_secondary || 'secondary'} value={theme.accent_color_secondary} />
{theme.background_motif ? <Pill>{theme.background_motif}</Pill> : null}
{theme.icon_name ? <Pill>{theme.icon_name.replace('fa-solid ', '')}</Pill> : null}
{theme.suggested_badge_label ? <Pill>{theme.suggested_badge_label}</Pill> : null}
{theme.suggested_cta_label ? <Pill>{theme.suggested_cta_label}</Pill> : null}
</div>
{Array.isArray(theme.related_tags_json) && theme.related_tags_json.length > 0 ? (
<div className="mt-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Suggested related tags</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-400">
{theme.related_tags_json.map((tag) => <span key={tag} className="rounded-full bg-white/[0.04] px-3 py-1.5">#{tag}</span>)}
</div>
</div>
) : null}
</div>
)
}

View File

@@ -588,6 +588,15 @@ export default function useUploadMachine({
...(mode === 'schedule' && publishAt ? { publish_at: publishAt } : {}),
...(timezone ? { timezone } : {}),
visibility,
world_submissions: Array.isArray(metadata.worldSubmissions)
? metadata.worldSubmissions
.filter((world) => Boolean(world?.selected))
.map((world) => ({
world_id: Number(world.id),
note: typeof world.note === 'string' ? world.note : '',
}))
.filter((entry) => Number.isFinite(entry.world_id) && entry.world_id > 0)
: [],
})
try {

View File

@@ -3,19 +3,17 @@ import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
const pages = {
'Moderation/ArtworkMaturityQueue': () => import('./Pages/Moderation/ArtworkMaturityQueue.jsx').then((module) => module.default),
}
const pages = import.meta.glob('./Pages/Moderation/**/*.jsx')
createInertiaApp({
resolve: (name) => {
const page = pages[name]
const page = pages[`./Pages/${name}.jsx`]
if (!page) {
throw new Error(`Unknown moderation page: ./Pages/${name}.jsx`)
}
return page()
return page().then((module) => module.default)
},
setup({ el, App, props }) {
const root = createRoot(el)