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