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
@@ -35,9 +35,10 @@ export default function ArtworkTags({ artwork }) {
seen.add(category.id)
categoryPills.push({
key: `cat-${category.id}`,
label: category.name,
label: category.is_primary ? category.name : category.name,
href: category.url || `/${category.content_type_slug}/${category.slug}`,
isCategory: true,
isPrimary: Boolean(category.is_primary),
})
}
}
@@ -28,6 +28,7 @@ import {
getContentTypeValue,
getProcessingTransparencyLabel,
} from '../../lib/uploadUtils'
import UploadReviewNotice from './UploadReviewNotice'
import { validateMarkdownLiteContent } from '../../utils/contentValidation'
// ─── Wizard step config ───────────────────────────────────────────────────────
@@ -55,6 +56,7 @@ function createInitialMetadata(initialGroupSlug = '', currentUserId = null, cont
isMature: false,
rightsAccepted: false,
contentType: '',
additionalCategoryIds: [],
group: normalizedGroupSlug,
primaryAuthorUserId: defaultPrimaryAuthor,
contributorUserIds: [],
@@ -116,6 +118,8 @@ export default function UploadWizard({
contributorOptionsByGroup = {},
initialGroupSlug = '',
currentUserId = null,
maxSecondaryCategories = 5,
requiresUploadReview = false,
}) {
const [notices, setNotices] = useState([])
// ── UI state ──────────────────────────────────────────────────────────────
@@ -589,9 +593,18 @@ export default function UploadWizard({
filteredCategoryTree={filteredCategoryTree}
allRootCategoryOptions={allRootCategoryOptions}
requiresSubCategory={requiresSubCategory}
onContentTypeChange={(value) => setMeta({ contentType: value, rootCategoryId: '', subCategoryId: '' })}
onRootCategoryChange={(rootId) => setMeta({ rootCategoryId: rootId, subCategoryId: '' })}
onSubCategoryChange={(subId) => setMeta({ subCategoryId: subId })}
maxSecondaryCategories={maxSecondaryCategories}
onContentTypeChange={(value) => setMeta({ contentType: value, rootCategoryId: '', subCategoryId: '', additionalCategoryIds: [] })}
onRootCategoryChange={(rootId) => setMeta({
rootCategoryId: rootId,
subCategoryId: '',
additionalCategoryIds: (metadata.additionalCategoryIds || []).filter((id) => String(id) !== String(rootId)),
})}
onSubCategoryChange={(subId) => setMeta({
subCategoryId: subId,
additionalCategoryIds: (metadata.additionalCategoryIds || []).filter((id) => String(id) !== String(subId)),
})}
onAdditionalCategoriesChange={(ids) => setMeta({ additionalCategoryIds: ids })}
groupOptions={groupOptions}
currentContributorOptions={currentContributorOptions}
onGroupChange={(groupSlug) => setMeta({ group: groupSlug })}
@@ -743,6 +756,8 @@ export default function UploadWizard({
</div>
)}
<UploadReviewNotice visible={requiresUploadReview} />
{/* Restored draft banner */}
{showRestoredBanner && (
<div className="rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-3 text-sm text-sky-100 shadow-[0_14px_44px_rgba(14,165,233,0.10)]">
@@ -347,6 +347,30 @@ describe('UploadWizard step flow', () => {
expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/session-1/publish', expect.anything(), expect.anything())
})
it('shows additional categories alphabetically after the primary path is chosen', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({
initialDraftId: 318,
contentTypes: [{
id: 1,
name: 'Art',
categories: [
{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }, { id: 14, name: 'Zebra' }] },
{ id: 12, name: 'Apple', children: [{ id: 13, name: 'City' }] },
],
}],
})
await completeStep1ToReady()
expect(await screen.findByText(/artwork details/i)).not.toBeNull()
await completeRequiredDetails({ title: 'My Art' })
const extraHeading = await screen.findByText(/additional categories/i)
const extraSection = extraHeading.closest('div')?.parentElement
const extraNames = Array.from(extraSection?.querySelectorAll('button') || []).map((button) => button.textContent?.trim())
expect(extraNames).toEqual(['Apple', 'City', 'Root', 'Zebra'])
})
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' }] }] }] })
@@ -2,7 +2,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'
import { additionalCategoryOptions, getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils'
/**
* Step2Details
@@ -31,6 +31,8 @@ export default function Step2Details({
onContentTypeChange,
onRootCategoryChange,
onSubCategoryChange,
onAdditionalCategoriesChange,
maxSecondaryCategories = 5,
groupOptions,
currentContributorOptions,
onGroupChange,
@@ -100,6 +102,16 @@ export default function Step2Details({
const q = subCategorySearch.trim().toLowerCase()
return q ? sorted.filter((s) => s.name.toLowerCase().includes(q)) : sorted
}, [subCategories, subCategorySearch])
const selectedLeafCategoryId = metadata.subCategoryId || (!requiresSubCategory ? metadata.rootCategoryId : '')
const extraCategoryOptions = useMemo(
() => additionalCategoryOptions(filteredCategoryTree, selectedLeafCategoryId),
[filteredCategoryTree, selectedLeafCategoryId]
)
const selectedAdditionalIds = useMemo(
() => (Array.isArray(metadata.additionalCategoryIds) ? metadata.additionalCategoryIds.map(String) : []),
[metadata.additionalCategoryIds]
)
const contributorCredits = metadata.contributorCredits || {}
useEffect(() => {
@@ -475,6 +487,50 @@ export default function Step2Details({
<span className="font-medium text-slate-300">{selectedRoot.name}</span> has no subcategories — selecting it is enough.
</div>
)}
{selectedLeafCategoryId && extraCategoryOptions.length > 0 && (
<>
<div className="my-5 border-t border-white/8" />
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Additional categories</p>
<span className="text-[11px] text-slate-600">{selectedAdditionalIds.length}/{maxSecondaryCategories}</span>
</div>
<p className="mb-3 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="flex flex-wrap gap-2">
{extraCategoryOptions.map((option) => {
const selected = selectedAdditionalIds.includes(String(option.id))
const atLimit = !selected && selectedAdditionalIds.length >= maxSecondaryCategories
return (
<button
key={`extra-${option.id}`}
type="button"
disabled={atLimit}
onClick={() => {
const next = selected
? selectedAdditionalIds.filter((id) => id !== String(option.id))
: [...selectedAdditionalIds, String(option.id)]
onAdditionalCategoriesChange?.(next)
}}
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.04] text-slate-300 hover:border-white/20 hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40',
].join(' ')}
aria-pressed={selected}
>
{option.name}
</button>
)
})}
</div>
{metadataErrors.secondary_category_ids && (
<p className="mt-3 text-xs text-red-300">{metadataErrors.secondary_category_ids}</p>
)}
</div>
</>
)}
</section>
{Array.isArray(groupOptions) && groupOptions.length > 0 && (
@@ -169,6 +169,9 @@ export default function Step3Publish({
{subLabel && (
<span>Sub: <span className="text-white/75">{subLabel}</span></span>
)}
{(metadata.additionalCategoryIds || []).length > 0 && (
<span>Also in: <span className="text-white/75">{metadata.additionalCategoryIds.length}</span></span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/55">