Save workspace changes
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)),
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user