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

@@ -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">