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:
2026-09-20 14:50:12 +02:00
parent ce0f278bac
commit 6b00652a20
43 changed files with 2896 additions and 565 deletions
@@ -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>
+48 -4
View File
@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/react'
import SeoHead from '../../components/seo/SeoHead'
import TagInput from '../../components/tags/TagInput'
import UploadWizard from '../../components/upload/UploadWizard'
import UploadReviewNotice from '../../components/upload/UploadReviewNotice'
import UploadDescriptionEditor from '../../components/upload/UploadDescriptionEditor'
import Checkbox from '../../Components/ui/Checkbox'
import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices'
@@ -52,6 +53,7 @@ const initialState = {
title: '',
type: '',
category: '',
additionalCategories: [],
tags: '',
description: '',
isMature: false,
@@ -320,6 +322,8 @@ function useUploadMachine({ draftId = null, filesCdnUrl = '', chunkSize, chunkRe
const res = await window.axios.post('/api/artworks', {
title: state.metadata.title,
category: state.metadata.category,
primary_category_id: state.metadata.category ? Number(state.metadata.category) : null,
secondary_category_ids: (state.metadata.additionalCategories || []).map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0),
tags: state.metadata.tags,
description: state.metadata.description,
is_mature: state.metadata.isMature,
@@ -671,6 +675,8 @@ export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize
contributorOptionsByGroup={props?.contributor_options_by_group && typeof props.contributor_options_by_group === 'object' ? props.contributor_options_by_group : {}}
initialGroupSlug={props?.initial_group || ''}
currentUserId={props?.auth?.user?.id ?? null}
maxSecondaryCategories={Number(props?.max_secondary_categories) > 0 ? Number(props.max_secondary_categories) : 5}
requiresUploadReview={Boolean(props?.requires_upload_review)}
/>
</div>
</div>
@@ -841,6 +847,7 @@ export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize
<div className="grid gap-8 lg:grid-cols-[1.1fr,0.9fr]">
<div className="space-y-6">
<SeoHead seo={{ title: pageTitle, description: pageDescription, robots: 'noindex, nofollow' }} />
<UploadReviewNotice visible={Boolean(props?.requires_upload_review)} />
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 shadow-[0_0_50px_rgba(59,130,246,0.15)]">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-semibold tracking-tight">Upload artwork</h1>
@@ -911,7 +918,7 @@ export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize
<button
key={ct.id}
type="button"
onClick={() => dispatch({ type: 'SET_METADATA', payload: { type: String(ct.id), category: '' } })}
onClick={() => dispatch({ type: 'SET_METADATA', payload: { type: String(ct.id), category: '', additionalCategories: [] } })}
className={`group rounded-2xl border px-4 py-3 text-left transition ${active
? 'border-emerald-400/60 bg-emerald-500/10 shadow-[0_0_25px_rgba(16,185,129,0.25)]'
: 'border-white/10 bg-white/5 hover:border-sky-400/40 hover:bg-white/10'}`}
@@ -981,11 +988,11 @@ export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize
onClick={() => {
if (hasChildren) {
setSelectedParentCategory(String(cat.id))
dispatch({ type: 'SET_METADATA', payload: { category: '' } })
dispatch({ type: 'SET_METADATA', payload: { category: '', additionalCategories: state.metadata.additionalCategories || [] } })
return
}
setSelectedParentCategory(null)
dispatch({ type: 'SET_METADATA', payload: { category: String(cat.id) } })
dispatch({ type: 'SET_METADATA', payload: { category: String(cat.id), additionalCategories: (state.metadata.additionalCategories || []).filter((id) => String(id) !== String(cat.id)) } })
}}
className={`rounded-full border px-4 py-2 text-sm transition ${isSelected || isExpanded
? 'border-purple-300/60 bg-purple-400/20 text-purple-100'
@@ -1019,7 +1026,7 @@ export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize
type="button"
onClick={() => {
// Select child but keep parent expanded so the panel remains visible
dispatch({ type: 'SET_METADATA', payload: { category: String(child.id) } })
dispatch({ type: 'SET_METADATA', payload: { category: String(child.id), additionalCategories: (state.metadata.additionalCategories || []).filter((id) => String(id) !== String(child.id)) } })
}}
className={`rounded-full border px-4 py-2 text-sm transition ${activeChild
? 'border-purple-300/60 bg-purple-400/20 text-purple-100'
@@ -1038,6 +1045,43 @@ export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize
)}
</div>
{selectedType && state.metadata.category ? (
<div className="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4 sm:p-5">
<div className="text-sm font-semibold text-white">Additional categories</div>
<div className="mt-1 text-xs text-white/60">Optional. Add up to 5 other relevant categories from the same type. They will not change the artwork URL or breadcrumb.</div>
<div className="mt-3 flex flex-wrap gap-2">
{categoryOptions.flatMap((cat) => [cat, ...(Array.isArray(cat.children) ? cat.children : [])])
.filter((cat, index, all) => all.findIndex((item) => String(item.id) === String(cat.id)) === index)
.filter((cat) => String(cat.id) !== String(state.metadata.category))
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' }))
.map((cat) => {
const selected = (state.metadata.additionalCategories || []).map(String).includes(String(cat.id))
const atLimit = !selected && (state.metadata.additionalCategories || []).length >= 5
return (
<button
key={`extra-${cat.id}`}
type="button"
disabled={atLimit}
onClick={() => {
const current = (state.metadata.additionalCategories || []).map(String)
const next = selected
? current.filter((id) => id !== String(cat.id))
: [...current, String(cat.id)]
dispatch({ type: 'SET_METADATA', payload: { additionalCategories: next } })
}}
className={`rounded-full border px-4 py-2 text-sm transition ${selected
? 'border-sky-300/60 bg-sky-400/20 text-sky-100'
: 'border-white/10 bg-white/5 text-white/70 hover:border-sky-300/50 hover:bg-sky-400/10 disabled:opacity-40'}`}
aria-pressed={selected}
>
{cat.name}
</button>
)
})}
</div>
</div>
) : null}
<div className="mt-4">
<span className="text-sm text-white/80">Tags</span>
<div className="mt-2">