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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -374,10 +374,17 @@ export default function useUploadMachine({
|
||||
String(titleSourceFile?.name || '').replace(/\.[^.]+$/, '') ||
|
||||
'Untitled upload'
|
||||
|
||||
const draftCategoryId = metadata.subCategoryId || metadata.rootCategoryId || null
|
||||
const draftSecondaryIds = (Array.isArray(metadata.additionalCategoryIds) ? metadata.additionalCategoryIds : [])
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => Number.isFinite(id) && id > 0 && String(id) !== String(draftCategoryId || ''))
|
||||
|
||||
const draftResponse = await window.axios.post('/api/artworks', {
|
||||
title: derivedTitle,
|
||||
description: String(metadata.description || '').trim() || null,
|
||||
category: metadata.subCategoryId || metadata.rootCategoryId || null,
|
||||
category: draftCategoryId,
|
||||
primary_category_id: draftCategoryId ? Number(draftCategoryId) : null,
|
||||
secondary_category_ids: draftSecondaryIds,
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags.join(', ') : '',
|
||||
license: Boolean(metadata.rightsAccepted),
|
||||
is_mature: Boolean(metadata.isMature),
|
||||
@@ -560,11 +567,16 @@ export default function useUploadMachine({
|
||||
const { mode = 'now', publishAt = null, timezone = null, visibility = 'public', action = 'publish' } = opts
|
||||
|
||||
const resolvedCategoryId = metadata.subCategoryId || metadata.rootCategoryId || null
|
||||
const secondaryCategoryIds = (Array.isArray(metadata.additionalCategoryIds) ? metadata.additionalCategoryIds : [])
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => Number.isFinite(id) && id > 0 && String(id) !== String(resolvedCategoryId || ''))
|
||||
|
||||
const buildPayload = () => ({
|
||||
title: String(metadata.title || '').trim() || undefined,
|
||||
description: String(metadata.description || '').trim() || null,
|
||||
category: resolvedCategoryId ? String(resolvedCategoryId) : null,
|
||||
primary_category_id: resolvedCategoryId ? Number(resolvedCategoryId) : null,
|
||||
secondary_category_ids: secondaryCategoryIds,
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||
is_mature: Boolean(metadata.isMature),
|
||||
group: String(metadata.group || '').trim() || null,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { additionalCategoryOptions } from './uploadUtils'
|
||||
|
||||
describe('additionalCategoryOptions', () => {
|
||||
it('excludes the primary leaf and sorts remaining names alphabetically', () => {
|
||||
const options = additionalCategoryOptions([
|
||||
{ id: '10', name: 'Zebra', children: [{ id: '11', name: 'Moon' }] },
|
||||
{ id: '12', name: 'Apple', children: [{ id: '13', name: 'City' }] },
|
||||
], '11')
|
||||
|
||||
expect(options.map((option) => option.name)).toEqual(['Apple', 'City', 'Zebra'])
|
||||
expect(options.map((option) => option.id)).not.toContain('11')
|
||||
})
|
||||
})
|
||||
@@ -103,6 +103,33 @@ export function buildCategoryTree(contentTypes = []) {
|
||||
return Array.from(rootsById.values())
|
||||
}
|
||||
|
||||
export function compareCategoryNames(a, b) {
|
||||
return String(a?.name || '').localeCompare(String(b?.name || ''), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
})
|
||||
}
|
||||
|
||||
export function additionalCategoryOptions(roots = [], primaryId = '') {
|
||||
const options = []
|
||||
const seen = new Set()
|
||||
const primary = String(primaryId || '')
|
||||
|
||||
roots.forEach((root) => {
|
||||
const items = [root, ...(Array.isArray(root.children) ? root.children : [])]
|
||||
items.forEach((item) => {
|
||||
const id = String(item?.id || '')
|
||||
if (!id || id === primary || seen.has(id)) {
|
||||
return
|
||||
}
|
||||
seen.add(id)
|
||||
options.push({ id, name: item.name || `Category ${id}` })
|
||||
})
|
||||
})
|
||||
|
||||
return options.sort(compareCategoryNames)
|
||||
}
|
||||
|
||||
// ─── Content type helpers ─────────────────────────────────────────────────────
|
||||
export function getContentTypeValue(type) {
|
||||
if (!type) return ''
|
||||
|
||||
Reference in New Issue
Block a user