Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -0,0 +1,146 @@
import React, { useEffect, useMemo, useState } from 'react'
function getScreenshotName(item, fallbackIndex) {
if (item && typeof item === 'object' && typeof item.name === 'string' && item.name.trim()) {
return item.name.trim()
}
return `Screenshot ${fallbackIndex + 1}`
}
function resolveScreenshotSource(item) {
if (!item) return { src: null, revoke: null }
if (typeof item === 'string') {
return { src: item, revoke: null }
}
if (typeof item === 'object') {
if (typeof item.preview === 'string' && item.preview) {
return { src: item.preview, revoke: null }
}
if (typeof item.src === 'string' && item.src) {
return { src: item.src, revoke: null }
}
if (typeof item.url === 'string' && item.url) {
return { src: item.url, revoke: null }
}
if (typeof File !== 'undefined' && item instanceof File) {
const objectUrl = URL.createObjectURL(item)
return {
src: objectUrl,
revoke: () => URL.revokeObjectURL(objectUrl),
}
}
}
return { src: null, revoke: null }
}
export default function ArchiveScreenshotPicker({
screenshots = [],
selectedIndex = 0,
onSelect,
compact = false,
title = 'Screenshots',
description = 'Choose which screenshot should be used as the default preview.',
}) {
const [resolvedScreenshots, setResolvedScreenshots] = useState([])
useEffect(() => {
const cleanup = []
const next = (Array.isArray(screenshots) ? screenshots : []).map((item, index) => {
const { src, revoke } = resolveScreenshotSource(item)
if (revoke) cleanup.push(revoke)
return {
src,
alt: getScreenshotName(item, index),
}
}).filter((item) => Boolean(item.src))
setResolvedScreenshots(next)
return () => {
cleanup.forEach((revoke) => revoke())
}
}, [screenshots])
const normalizedIndex = useMemo(() => {
if (resolvedScreenshots.length === 0) return 0
if (!Number.isFinite(selectedIndex)) return 0
return Math.min(Math.max(0, Math.floor(selectedIndex)), resolvedScreenshots.length - 1)
}, [resolvedScreenshots.length, selectedIndex])
const selectedScreenshot = resolvedScreenshots[normalizedIndex] ?? null
if (!selectedScreenshot) {
return null
}
return (
<div className={compact ? 'space-y-3' : 'space-y-4'}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">{title}</p>
<p className="mt-1 text-xs text-white/55">{description}</p>
</div>
<span className="rounded-full border border-emerald-300/30 bg-emerald-400/10 px-2.5 py-1 text-[11px] text-emerald-100">
Default preview
</span>
</div>
<div className={compact ? 'overflow-hidden rounded-2xl border border-white/10 bg-black/25' : 'overflow-hidden rounded-3xl border border-white/10 bg-black/25'}>
<img
src={selectedScreenshot.src}
alt={selectedScreenshot.alt}
className={compact ? 'h-40 w-full object-cover' : 'h-56 w-full object-cover sm:h-72'}
loading="lazy"
decoding="async"
/>
</div>
<div className={compact ? 'grid grid-cols-4 gap-2' : 'grid grid-cols-2 gap-3 sm:grid-cols-4'}>
{resolvedScreenshots.map((item, index) => {
const isSelected = index === normalizedIndex
return (
<button
key={`${item.src}-${index}`}
type="button"
onClick={() => onSelect?.(index)}
aria-label={`Use ${item.alt} as default screenshot`}
className={[
'group relative overflow-hidden rounded-2xl border text-left transition',
isSelected
? 'border-emerald-300/45 bg-emerald-400/10 shadow-[0_0_0_1px_rgba(52,211,153,0.22)]'
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]',
].join(' ')}
aria-pressed={isSelected}
>
<img
src={item.src}
alt={item.alt}
className={compact ? 'h-16 w-full object-cover' : 'h-20 w-full object-cover'}
loading="lazy"
decoding="async"
/>
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
<span className="truncate text-[11px] text-white/70">{item.alt}</span>
<span className={[
'shrink-0 rounded-full px-2 py-0.5 text-[10px]',
isSelected ? 'bg-emerald-300/20 text-emerald-100' : 'bg-white/10 text-white/45',
].join(' ')}>
{isSelected ? 'Default' : 'Use'}
</span>
</div>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import React, { useCallback } from 'react'
import ArchiveScreenshotPicker from './ArchiveScreenshotPicker'
import ReadinessChecklist from './ReadinessChecklist'
import SchedulePublishPicker from './SchedulePublishPicker'
import Checkbox from '../../Components/ui/Checkbox'
@@ -36,6 +37,8 @@ export default function PublishPanel({
primaryPreviewUrl = null,
isArchive = false,
screenshots = [],
selectedScreenshotIndex = 0,
onSelectedScreenshotChange,
// Metadata
metadata = {},
// Readiness
@@ -64,8 +67,6 @@ export default function PublishPanel({
}) {
const pill = STATUS_PILL[machineState] ?? null
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
const hasAnyPreview = hasPreview || (isArchive && screenshots.length > 0)
const previewSrc = hasPreview ? primaryPreviewUrl : (screenshots[0]?.preview ?? screenshots[0] ?? null)
const title = String(metadata.title || '').trim()
const hasTitle = Boolean(title)
@@ -126,9 +127,9 @@ export default function PublishPanel({
<div className="flex items-start gap-3">
{/* Thumbnail */}
<div className="shrink-0 h-[72px] w-[72px] overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 flex items-center justify-center">
{previewSrc ? (
{hasPreview ? (
<img
src={previewSrc}
src={primaryPreviewUrl}
alt="Artwork preview"
className="max-h-full max-w-full object-contain"
loading="lazy"
@@ -162,6 +163,19 @@ export default function PublishPanel({
</div>
</div>
{isArchive && screenshots.length > 0 && (
<div className="rounded-2xl border border-white/8 bg-white/[0.03] p-3">
<ArchiveScreenshotPicker
screenshots={screenshots}
selectedIndex={selectedScreenshotIndex}
onSelect={onSelectedScreenshotChange}
compact
title="Preview screenshot"
description="Choose which screenshot should represent this archive in the publish panel."
/>
</div>
)}
{/* Divider */}
<div className="border-t border-white/8" />

View File

@@ -85,6 +85,7 @@ export default function UploadWizard({
// ── File + screenshot state ───────────────────────────────────────────────
const [primaryFile, setPrimaryFile] = useState(null)
const [screenshots, setScreenshots] = useState([])
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0)
// ── Metadata state ────────────────────────────────────────────────────────
const [metadata, setMetadata] = useState(initialMetadata)
@@ -112,6 +113,18 @@ export default function UploadWizard({
const canStartUpload = isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors)
useEffect(() => {
if (!Array.isArray(screenshots) || screenshots.length === 0) {
setSelectedScreenshotIndex(0)
return
}
setSelectedScreenshotIndex((prev) => {
if (!Number.isFinite(prev) || prev < 0) return 0
return Math.min(prev, screenshots.length - 1)
})
}, [screenshots])
// ── Machine hook ──────────────────────────────────────────────────────────
const {
machine,
@@ -124,6 +137,8 @@ export default function UploadWizard({
clearPolling,
} = useUploadMachine({
primaryFile,
screenshots,
selectedScreenshotIndex,
canStartUpload,
primaryType,
isArchive,
@@ -322,6 +337,7 @@ export default function UploadWizard({
resetMachine()
setPrimaryFile(null)
setScreenshots([])
setSelectedScreenshotIndex(0)
setMetadata(initialMetadata)
setIsUploadLocked(false)
hasAutoAdvancedRef.current = false
@@ -408,9 +424,11 @@ export default function UploadWizard({
onPrimaryFileChange={setPrimaryFile}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
screenshotErrors={screenshotErrors}
screenshotPerFileErrors={screenshotPerFileErrors}
onScreenshotsChange={setScreenshots}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
machine={machine}
/>
)
@@ -425,6 +443,8 @@ export default function UploadWizard({
isArchive={isArchive}
fileMetadata={fileMetadata}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
contentTypes={contentTypes}
metadata={metadata}
metadataErrors={metadataErrors}
@@ -456,6 +476,8 @@ export default function UploadWizard({
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
fileMetadata={fileMetadata}
metadata={metadata}
canPublish={canPublish}
@@ -607,6 +629,8 @@ export default function UploadWizard({
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
metadata={metadata}
machineState={machine.state}
uploadReady={uploadReady}
@@ -689,6 +713,8 @@ export default function UploadWizard({
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
metadata={metadata}
machineState={machine.state}
uploadReady={uploadReady}

View File

@@ -68,6 +68,15 @@ function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk
},
})
}
if (url === '/api/tags/popular' || String(url).startsWith('/api/tags/search')) {
return Promise.resolve({
data: {
data: [],
},
})
}
return Promise.reject(new Error(`Unhandled GET ${url}`))
}),
}
@@ -112,6 +121,20 @@ async function completeStep1ToReady() {
})
}
async function completeRequiredDetails({ title = 'My Art', mature = false } = {}) {
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))
})
}
describe('UploadWizard step flow', () => {
let originalImage
let originalScrollTo
@@ -216,6 +239,43 @@ describe('UploadWizard step flow', () => {
})
})
it('uses the selected archive screenshot as the preview upload source', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 312 })
await uploadPrimary(new File(['zip'], 'bundle.zip', { type: 'application/zip' }))
await uploadScreenshot(new File(['shot-1'], 'shot-1.png', { type: 'image/png' }))
await uploadScreenshot(new File(['shot-2'], 'shot-2.png', { type: 'image/png' }))
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /use shot-2\.png as default screenshot/i }))
})
await waitFor(() => {
expect(screen.getAllByText('Default').length).toBeGreaterThan(0)
})
await act(async () => {
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
})
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith(
'/api/uploads/finish',
expect.objectContaining({
file_name: 'shot-2.png',
archive_file_name: 'bundle.zip',
additional_screenshot_sessions: [
expect.objectContaining({
file_name: 'shot-1.png',
}),
],
}),
expect.anything(),
)
})
})
it('allows navigation back to completed previous step', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 304 })
@@ -227,7 +287,7 @@ describe('UploadWizard step flow', () => {
await act(async () => {
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
})
expect(await screen.findByText(/upload your artwork file/i)).not.toBeNull()
expect(await screen.findByRole('heading', { level: 2, name: /upload your artwork/i })).not.toBeNull()
})
it('triggers scroll-to-top behavior on step change', async () => {
@@ -253,11 +313,9 @@ describe('UploadWizard step flow', () => {
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
await completeRequiredDetails({ title: 'My Art' })
await act(async () => {
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'My Art')
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
})
@@ -284,14 +342,10 @@ describe('UploadWizard step flow', () => {
await completeStep1ToReady()
await screen.findByText(/artwork details/i)
const titleInput = screen.getByPlaceholderText(/give your artwork a clear title/i)
await completeRequiredDetails({ title: 'Mature Piece', mature: true })
await act(async () => {
await userEvent.type(titleInput, 'Mature Piece')
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
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))
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
})
@@ -361,7 +415,7 @@ describe('UploadWizard step flow', () => {
const dropzoneButton = screen.getByTestId('upload-dropzone')
expect(dropzoneButton.getAttribute('aria-disabled')).toBe('true')
})
expect(screen.getByText(/file is locked after upload\. reset to change\./i)).not.toBeNull()
expect(screen.getByText(/file is locked after upload starts\. reset to change the file\./i)).not.toBeNull()
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /reset upload/i }))

View File

@@ -1,4 +1,5 @@
import React from 'react'
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
import UploadDropzone from '../UploadDropzone'
import ScreenshotUploader from '../ScreenshotUploader'
@@ -22,9 +23,11 @@ export default function Step1FileUpload({
// Archive screenshots
isArchive,
screenshots,
selectedScreenshotIndex,
screenshotErrors,
screenshotPerFileErrors,
onScreenshotsChange,
onSelectedScreenshotChange,
// Machine state (passed for potential future use)
machine,
}) {
@@ -95,6 +98,19 @@ export default function Step1FileUpload({
onFilesChange={onScreenshotsChange}
/>
{isArchive && screenshots.length > 0 && (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 sm:p-5">
<ArchiveScreenshotPicker
screenshots={screenshots}
selectedIndex={selectedScreenshotIndex}
onSelect={onSelectedScreenshotChange}
compact
title="Choose default screenshot"
description="Pick the screenshot that should be uploaded as the archive preview before you start the upload."
/>
</div>
)}
{/* ── Subtle what-happens-next hints (shown only before a file is picked) */}
{!fileSelected && (
<div className="grid gap-3 sm:grid-cols-3">

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
import UploadSidebar from '../UploadSidebar'
import { getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils'
@@ -17,6 +18,8 @@ export default function Step2Details({
isArchive,
fileMetadata,
screenshots,
selectedScreenshotIndex,
onSelectedScreenshotChange,
// Content type + category
contentTypes,
metadata,
@@ -167,6 +170,18 @@ export default function Step2Details({
</span>
</div>
</div>
{isArchive && screenshots.length > 0 && (
<div className="mt-5 border-t border-white/8 pt-5">
<ArchiveScreenshotPicker
screenshots={screenshots}
selectedIndex={selectedScreenshotIndex}
onSelect={onSelectedScreenshotChange}
title="Archive screenshots"
description="All selected screenshots are shown here. Pick the one that should become the main preview thumbnail."
/>
</div>
)}
</div>
{/* ── Combined: Content type → Category → Subcategory ─────────────────── */}

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { motion, useReducedMotion } from 'framer-motion'
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
function stripHtml(value) {
return String(value || '')
@@ -45,6 +46,8 @@ export default function Step3Publish({
primaryPreviewUrl,
isArchive,
screenshots,
selectedScreenshotIndex,
onSelectedScreenshotChange,
fileMetadata,
// Metadata
metadata,
@@ -161,6 +164,18 @@ export default function Step3Publish({
)}
</div>
</div>
{isArchive && screenshots.length > 0 && (
<div className="mt-5 border-t border-white/8 pt-5">
<ArchiveScreenshotPicker
screenshots={screenshots}
selectedIndex={selectedScreenshotIndex}
onSelect={onSelectedScreenshotChange}
title="Archive preview"
description="This screenshot will be used as the default preview once the archive is published."
/>
</div>
)}
</div>
{/* ── Visibility selector ────────────────────────────────────────── */}