Allow artworks to have one primary category and extra secondaries.
Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
This commit is contained in:
@@ -301,6 +301,8 @@ export default function StudioArtworkEdit() {
|
||||
const [contentTypeId, setContentTypeId] = useState(artwork?.content_type_id || null)
|
||||
const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null)
|
||||
const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null)
|
||||
const [secondaryCategoryIds, setSecondaryCategoryIds] = useState(() => (Array.isArray(artwork?.secondary_category_ids) ? artwork.secondary_category_ids.map(Number).filter((id) => Number.isFinite(id) && id > 0) : []))
|
||||
const maxSecondaryCategories = Number(artwork?.max_secondary_categories) > 0 ? Number(artwork.max_secondary_categories) : 5
|
||||
const [title, setTitle] = useState(artwork?.title || '')
|
||||
const [description, setDescription] = useState(() => normalizeMarkdownLiteContent(artwork?.description || ''))
|
||||
const [tagSlugs, setTagSlugs] = useState(() => (artwork?.tags || []).map((t) => t.slug || t.name))
|
||||
@@ -415,6 +417,22 @@ export default function StudioArtworkEdit() {
|
||||
const aiStatus = aiData?.status || artwork?.ai_status || 'not_analyzed'
|
||||
const aiSuggestedTags = useMemo(() => (aiData?.tag_suggestions || []).map((item) => item.tag).filter(Boolean), [aiData])
|
||||
const selectedLeafCategoryId = subCategoryId || categoryId || null
|
||||
const additionalCategoryOptions = useMemo(() => {
|
||||
const options = []
|
||||
const seen = new Set()
|
||||
for (const root of rootCategories) {
|
||||
const items = [root, ...(root.children || [])]
|
||||
for (const item of items) {
|
||||
const id = Number(item.id)
|
||||
if (!Number.isFinite(id) || id === Number(selectedLeafCategoryId) || seen.has(id)) {
|
||||
continue
|
||||
}
|
||||
seen.add(id)
|
||||
options.push(item)
|
||||
}
|
||||
}
|
||||
return options.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' }))
|
||||
}, [rootCategories, selectedLeafCategoryId])
|
||||
const visibilitySummary = publishMode === 'schedule'
|
||||
? `Scheduled as ${visibilityLabel(visibility)}`
|
||||
: visibilityLabel(visibility)
|
||||
@@ -469,6 +487,7 @@ export default function StudioArtworkEdit() {
|
||||
setContentTypeId(id)
|
||||
setCategoryId(null)
|
||||
setSubCategoryId(null)
|
||||
setSecondaryCategoryIds([])
|
||||
setIsCategoryChooserOpen(true)
|
||||
setCategorySource((current) => nextSourceForManualEdit(current))
|
||||
}
|
||||
@@ -476,12 +495,14 @@ export default function StudioArtworkEdit() {
|
||||
const handleCategoryChange = (id) => {
|
||||
setCategoryId(id)
|
||||
setSubCategoryId(null)
|
||||
setSecondaryCategoryIds((current) => current.filter((value) => Number(value) !== Number(id)))
|
||||
setIsCategoryChooserOpen(false)
|
||||
setCategorySource((current) => nextSourceForManualEdit(current))
|
||||
}
|
||||
|
||||
const handleSubCategoryChange = (id) => {
|
||||
setSubCategoryId(id)
|
||||
setSecondaryCategoryIds((current) => current.filter((value) => Number(value) !== Number(id)))
|
||||
setCategorySource((current) => nextSourceForManualEdit(current))
|
||||
}
|
||||
|
||||
@@ -781,6 +802,8 @@ export default function StudioArtworkEdit() {
|
||||
: [],
|
||||
content_type_id: contentTypeId,
|
||||
category_id: selectedLeafCategoryId,
|
||||
primary_category_id: selectedLeafCategoryId,
|
||||
secondary_category_ids: secondaryCategoryIds.filter((id) => Number(id) !== Number(selectedLeafCategoryId)),
|
||||
tags: tagSlugs,
|
||||
title_source: titleSource,
|
||||
description_source: descriptionSource,
|
||||
@@ -834,7 +857,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, worldSubmissionOptions, evolutionTarget, evolutionRelationType, evolutionNote, artwork?.id, evolutionRelationTypes])
|
||||
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, secondaryCategoryIds, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, worldSubmissionOptions, evolutionTarget, evolutionRelationType, evolutionNote, artwork?.id, evolutionRelationTypes])
|
||||
|
||||
const handleFileReplace = async (file) => {
|
||||
if (!file) return
|
||||
@@ -1448,7 +1471,45 @@ export default function StudioArtworkEdit() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.category_id && <p className="mt-4 text-xs text-red-400">{errors.category_id[0]}</p>}
|
||||
{(errors.category_id || errors.primary_category_id) && <p className="mt-4 text-xs text-red-400">{(errors.category_id || errors.primary_category_id)[0]}</p>}
|
||||
{errors.secondary_category_ids && <p className="mt-2 text-xs text-red-400">{errors.secondary_category_ids[0]}</p>}
|
||||
|
||||
{selectedLeafCategoryId && additionalCategoryOptions.length > 0 && (
|
||||
<div className="mt-6 rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Additional categories</div>
|
||||
<p className="mt-2 text-sm text-slate-400">Optional. Add up to {maxSecondaryCategories} other categories from this content type. They appear on those category pages but do not change the artwork URL or breadcrumb.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{additionalCategoryOptions.map((option) => {
|
||||
const selected = secondaryCategoryIds.includes(Number(option.id))
|
||||
const atLimit = !selected && secondaryCategoryIds.length >= maxSecondaryCategories
|
||||
return (
|
||||
<button
|
||||
key={`secondary-${option.id}`}
|
||||
type="button"
|
||||
disabled={atLimit}
|
||||
onClick={() => {
|
||||
setSecondaryCategoryIds((current) => (
|
||||
selected
|
||||
? current.filter((id) => Number(id) !== Number(option.id))
|
||||
: [...current, Number(option.id)]
|
||||
))
|
||||
setCategorySource((current) => nextSourceForManualEdit(current))
|
||||
}}
|
||||
className={[
|
||||
'rounded-full border px-3 py-1.5 text-sm transition',
|
||||
selected
|
||||
? 'border-sky-400/40 bg-sky-400/15 text-sky-100'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-300 hover:border-white/20 disabled:opacity-40',
|
||||
].join(' ')}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
{option.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
Reference in New Issue
Block a user