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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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