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

@@ -11,6 +11,7 @@ import NovaSelect from '../../components/ui/NovaSelect'
import TagPicker from '../../components/tags/TagPicker'
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'
import ArtworkEvolutionSearchPicker from '../../components/artwork/ArtworkEvolutionSearchPicker'
import WorldSubmissionSelector from '../../components/worlds/WorldSubmissionSelector'
const EDIT_SECTIONS = [
{ id: 'taxonomy', label: 'Category', hint: 'Content type and category path' },
@@ -18,6 +19,7 @@ const EDIT_SECTIONS = [
{ id: 'evolution', label: 'Evolution', hint: 'Link an older original artwork' },
{ id: 'ai-assist', label: 'AI Assist', hint: 'Suggestions and similar matches' },
{ id: 'tags', label: 'Tags', hint: 'Search, add, and refine keywords' },
{ id: 'worlds', label: 'Worlds', hint: 'Community submissions and review state' },
{ id: 'visibility', label: 'Visibility', hint: 'Publishing state' },
]
@@ -26,6 +28,7 @@ const TABS = [
{ id: 'media', label: 'Media', icon: 'fa-solid fa-photo-film' },
{ id: 'evolution', label: 'Evolution', icon: 'fa-solid fa-code-branch' },
{ id: 'tags', label: 'Tags', icon: 'fa-solid fa-tags' },
{ id: 'worlds', label: 'Worlds', icon: 'fa-solid fa-globe' },
{ id: 'taxonomy', label: 'Category', icon: 'fa-solid fa-palette' },
{ id: 'visibility', label: 'Visibility', icon: 'fa-solid fa-eye' },
{ id: 'ai', label: 'AI Assist', icon: 'fa-solid fa-wand-magic-sparkles' },
@@ -218,6 +221,14 @@ function mapContributorCredits(contributorCredits = []) {
}, {})
}
function normalizeWorldSubmissionOptions(options = []) {
return (Array.isArray(options) ? options : []).map((world) => ({
...world,
selected: Boolean(world?.selected),
note: typeof world?.note === 'string' ? world.note : '',
}))
}
// ─── Sub-components ──────────────────────────────────────────────────────────
/** Glass-morphism section card (Nova theme) */
@@ -282,6 +293,7 @@ export default function StudioArtworkEdit() {
const contributorOptionsByGroup = props.contributorOptionsByGroup && typeof props.contributorOptionsByGroup === 'object'
? props.contributorOptionsByGroup
: {}
const initialWorldSubmissionOptions = Array.isArray(props.worldSubmissionOptions) ? props.worldSubmissionOptions : []
const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes])
@@ -299,6 +311,7 @@ export default function StudioArtworkEdit() {
const [primaryAuthorUserId, setPrimaryAuthorUserId] = useState(artwork?.primary_author_user_id || null)
const [contributorUserIds, setContributorUserIds] = useState(() => (Array.isArray(artwork?.contributor_user_ids) ? artwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : []))
const [contributorCredits, setContributorCredits] = useState(() => normalizeContributorCredits(artwork?.contributor_user_ids || [], mapContributorCredits(artwork?.contributor_credits || [])))
const [worldSubmissionOptions, setWorldSubmissionOptions] = useState(() => normalizeWorldSubmissionOptions(initialWorldSubmissionOptions))
const [titleSource, setTitleSource] = useState(artwork?.title_source || 'manual')
const [descriptionSource, setDescriptionSource] = useState(artwork?.description_source || 'manual')
const [tagsSource, setTagsSource] = useState(artwork?.tags_source || 'manual')
@@ -314,8 +327,6 @@ export default function StudioArtworkEdit() {
const [aiAction, setAiAction] = useState('')
const [aiDirect, setAiDirect] = useState(false)
const [isAiPanelOpen, setIsAiPanelOpen] = useState(true)
const [isAiDebugOpen, setIsAiDebugOpen] = useState(false)
const [lastAiRequest, setLastAiRequest] = useState(null)
const [selectedAiTags, setSelectedAiTags] = useState([])
const [activeTab, setActiveTab] = useState('details')
const [isCategoryChooserOpen, setIsCategoryChooserOpen] = useState(() => !artwork?.parent_category_id)
@@ -543,12 +554,6 @@ export default function StudioArtworkEdit() {
const direct = typeof options.direct === 'boolean' ? options.direct : aiDirect
const intent = options.intent || 'analyze'
const requestBody = { direct, intent }
setLastAiRequest({
endpoint: `/api/studio/artworks/${artwork.id}/ai/${action}`,
method: 'POST',
body: requestBody,
at: new Date().toISOString(),
})
const res = await fetch(`/api/studio/artworks/${artwork.id}/ai/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
@@ -574,12 +579,6 @@ export default function StudioArtworkEdit() {
if (!artwork?.id) return
setAiAction('apply')
try {
setLastAiRequest({
endpoint: `/api/studio/artworks/${artwork.id}/ai/apply`,
method: 'POST',
body: payload,
at: new Date().toISOString(),
})
const res = await fetch(`/api/studio/artworks/${artwork.id}/ai/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
@@ -685,11 +684,6 @@ export default function StudioArtworkEdit() {
})
}, [aiData, aiStatus, aiSuggestedTags, persistAiAction, trackAiEvent, triggerAi])
const aiDebugPayload = useMemo(() => ({
last_editor_request: lastAiRequest,
stored_debug: aiData?.debug || null,
}), [aiData?.debug, lastAiRequest])
const requestAiIntent = useCallback((intent, action = null) => {
const nextAction = action || (aiStatus === 'ready' ? 'regenerate' : 'analyze')
trackAiEvent('intent_requested', { intent, action: nextAction })
@@ -792,6 +786,13 @@ export default function StudioArtworkEdit() {
description_source: descriptionSource,
tags_source: tagsSource,
category_source: categorySource,
world_submissions: worldSubmissionOptions
.filter((world) => Boolean(world?.selected))
.map((world) => ({
world_id: Number(world.id),
note: typeof world.note === 'string' ? world.note : '',
}))
.filter((entry) => Number.isFinite(entry.world_id) && entry.world_id > 0),
evolution_target_artwork_id: evolutionTarget?.id || null,
evolution_relation_type: evolutionTarget ? evolutionRelationType : null,
evolution_note: evolutionTarget ? evolutionNote : null,
@@ -815,6 +816,9 @@ export default function StudioArtworkEdit() {
setContributorUserIds(Array.isArray(updatedArtwork.contributor_user_ids) ? updatedArtwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : [])
setContributorCredits(normalizeContributorCredits(updatedArtwork.contributor_user_ids || [], mapContributorCredits(updatedArtwork.contributor_credits || [])))
}
if (Array.isArray(data?.world_submission_options)) {
setWorldSubmissionOptions(normalizeWorldSubmissionOptions(data.world_submission_options))
}
setEvolutionTarget(updatedEvolutionRelation?.target_artwork || null)
setEvolutionRelationType(updatedEvolutionRelation?.relation_type || evolutionRelationTypes[0]?.value || 'remake_of')
setEvolutionNote(updatedEvolutionRelation?.note || '')
@@ -829,7 +833,7 @@ export default function StudioArtworkEdit() {
} finally {
setSaving(false)
}
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, evolutionTarget, evolutionRelationType, evolutionNote, artwork?.id, evolutionRelationTypes])
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, worldSubmissionOptions, evolutionTarget, evolutionRelationType, evolutionNote, artwork?.id, evolutionRelationTypes])
const handleFileReplace = async (file) => {
if (!file) return
@@ -2254,9 +2258,6 @@ export default function StudioArtworkEdit() {
<Button variant="ghost" size="xs" onClick={() => requestAiIntent('analyze', 'regenerate')} loading={aiAction === 'regenerate'}>
Refresh suggestions
</Button>
<Button variant="ghost" size="xs" onClick={() => setIsAiDebugOpen((current) => !current)}>
{isAiDebugOpen ? 'Hide debug' : 'Show debug'}
</Button>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
@@ -2284,34 +2285,6 @@ export default function StudioArtworkEdit() {
</div>
)}
{isAiDebugOpen && (
<div className="rounded-2xl border border-amber-400/20 bg-amber-400/[0.06] p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-white">AI debug</h4>
<p className="mt-1 text-xs text-slate-400">Inspect the editor request, the outbound vision POST payload, and the raw analysis returned to the suggestion builder.</p>
</div>
<button type="button" onClick={() => copyText(JSON.stringify(aiDebugPayload, null, 2))} className="text-xs text-slate-300 transition hover:text-white">Copy JSON</button>
</div>
<div className="grid gap-3 xl:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Editor request</div>
<pre className="mt-3 overflow-x-auto text-xs leading-6 text-slate-300 whitespace-pre-wrap">{JSON.stringify(lastAiRequest, null, 2)}</pre>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Vision request + response</div>
<pre className="mt-3 overflow-x-auto text-xs leading-6 text-slate-300 whitespace-pre-wrap">{JSON.stringify(aiData?.debug?.vision_debug || null, null, 2)}</pre>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Raw analysis used for suggestions</div>
<pre className="mt-3 overflow-x-auto text-xs leading-6 text-slate-300 whitespace-pre-wrap">{JSON.stringify(aiData?.debug?.analysis || null, null, 2)}</pre>
</div>
</div>
)}
<div className="grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
@@ -2487,6 +2460,23 @@ export default function StudioArtworkEdit() {
</Section>
)}
{activeTab === 'worlds' && (
<WorldSubmissionSelector
title="Add to Worlds"
description="Attach this artwork to active worlds for creator participation. These remain separate from moderator-curated world relations and keep their own review state."
options={worldSubmissionOptions}
emptyMessage="No worlds are currently open for creator participation, and this artwork has no existing world history yet."
onToggle={(worldId) => setWorldSubmissionOptions((current) => current.map((world) => (
Number(world.id) === Number(worldId) && !world.selection_locked
? { ...world, selected: !world.selected }
: world
)))}
onNoteChange={(worldId, note) => setWorldSubmissionOptions((current) => current.map((world) => (
Number(world.id) === Number(worldId) ? { ...world, note } : world
)))}
/>
)}
{/* ── Visibility tab ── */}
{activeTab === 'visibility' && (
<Section id="visibility" className="space-y-5">