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

@@ -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>
)
}