Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
147 lines
5.7 KiB
JavaScript
147 lines
5.7 KiB
JavaScript
import React from 'react'
|
|
|
|
/**
|
|
* CategorySelector
|
|
*
|
|
* Reusable pill-based category + subcategory selector.
|
|
* Renders root categories as pills; when a root with children is selected,
|
|
* subcategory pills appear in an animated block below.
|
|
*
|
|
* @param {object} props
|
|
* @param {Array} props.categories Flat list of root-category objects { id, name, children[] }
|
|
* @param {string} props.rootCategoryId Currently selected root id
|
|
* @param {string} props.subCategoryId Currently selected sub id
|
|
* @param {boolean} props.hasContentType Whether a content type is selected (gate)
|
|
* @param {string} [props.error] Validation error message
|
|
* @param {function} props.onRootChange Called with (rootId: string)
|
|
* @param {function} props.onSubChange Called with (subId: string)
|
|
* @param {Array} [props.allRoots] All root options (for the hidden accessible select)
|
|
* @param {function} [props.onRootChangeAll] Fallback handler with full cross-type info
|
|
*/
|
|
export default function CategorySelector({
|
|
categories = [],
|
|
rootCategoryId = '',
|
|
subCategoryId = '',
|
|
hasContentType = false,
|
|
error = '',
|
|
onRootChange,
|
|
onSubChange,
|
|
allRoots = [],
|
|
onRootChangeAll,
|
|
}) {
|
|
const rootOptions = hasContentType ? categories : allRoots
|
|
const selectedRoot = categories.find((c) => String(c.id) === String(rootCategoryId || '')) ?? null
|
|
const hasSubcategories = Boolean(
|
|
selectedRoot && Array.isArray(selectedRoot.children) && selectedRoot.children.length > 0
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{!hasContentType ? (
|
|
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
|
|
Select a content type to load categories.
|
|
</div>
|
|
) : categories.length === 0 ? (
|
|
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
|
|
No categories available for this content type.
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2" role="group" aria-label="Category">
|
|
{categories.map((root) => {
|
|
const active = String(root.id) === String(rootCategoryId || '')
|
|
return (
|
|
<button
|
|
key={root.id}
|
|
type="button"
|
|
aria-pressed={active}
|
|
onClick={() => onRootChange?.(String(root.id))}
|
|
className={[
|
|
'rounded-full border px-3.5 py-1.5 text-sm transition-all',
|
|
active
|
|
? 'border-violet-500/70 bg-violet-600/25 text-white shadow-sm'
|
|
: 'border-white/10 bg-white/5 text-white/65 hover:border-violet-300/40 hover:bg-violet-400/10 hover:text-white/90',
|
|
].join(' ')}
|
|
>
|
|
{root.name}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Subcategories (shown when root has children) */}
|
|
{hasSubcategories && (
|
|
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-3">
|
|
<p className="mb-2 text-[11px] uppercase tracking-wide text-white/45">
|
|
Subcategory for <span className="text-white/70">{selectedRoot.name}</span>
|
|
</p>
|
|
<div className="flex flex-wrap gap-2" role="group" aria-label="Subcategory">
|
|
{selectedRoot.children.map((sub) => {
|
|
const active = String(sub.id) === String(subCategoryId || '')
|
|
return (
|
|
<button
|
|
key={sub.id}
|
|
type="button"
|
|
aria-pressed={active}
|
|
onClick={() => onSubChange?.(String(sub.id))}
|
|
className={[
|
|
'rounded-full border px-3 py-1 text-sm transition-all',
|
|
active
|
|
? 'border-cyan-500/70 bg-cyan-600/20 text-white shadow-sm'
|
|
: 'border-white/10 bg-white/5 text-white/60 hover:border-cyan-300/40 hover:bg-cyan-400/10 hover:text-white/85',
|
|
].join(' ')}
|
|
>
|
|
{sub.name}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Accessible hidden select (screen readers / fallback) */}
|
|
<div className="sr-only">
|
|
<label htmlFor="category-root-select">Root category</label>
|
|
<select
|
|
id="category-root-select"
|
|
value={String(rootCategoryId || '')}
|
|
onChange={(e) => {
|
|
const nextRootId = String(e.target.value || '')
|
|
if (onRootChangeAll) {
|
|
const matched = allRoots.find((r) => String(r.id) === nextRootId)
|
|
onRootChangeAll(nextRootId, matched?.contentTypeValue ?? null)
|
|
} else {
|
|
onRootChange?.(nextRootId)
|
|
}
|
|
}}
|
|
>
|
|
<option value="">Select root category</option>
|
|
{rootOptions.map((root) => (
|
|
<option key={root.id} value={String(root.id)}>{root.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
{hasSubcategories && (
|
|
<>
|
|
<label htmlFor="category-sub-select">Subcategory</label>
|
|
<select
|
|
id="category-sub-select"
|
|
value={String(subCategoryId || '')}
|
|
onChange={(e) => onSubChange?.(String(e.target.value || ''))}
|
|
>
|
|
<option value="">Select subcategory</option>
|
|
{selectedRoot.children.map((sub) => (
|
|
<option key={sub.id} value={String(sub.id)}>{sub.name}</option>
|
|
))}
|
|
</select>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="mt-1 text-xs text-red-300" role="alert">{error}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|