/** * Taxonomy Sidebar for Content Editor * * Shows taxonomy selection UI in the content editor sidebar. * - Checkbox tree for hierarchical taxonomies (categories) * - Tag input for flat taxonomies (tags) */ import { Input, Label } from "@cloudflare/kumo"; import { X } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { apiFetch, parseApiResponse, throwResponseError } from "../lib/api/client.js"; interface TaxonomyTerm { id: string; name: string; slug: string; label: string; parentId?: string; children: TaxonomyTerm[]; } interface TaxonomyDef { id: string; name: string; label: string; labelSingular?: string; hierarchical: boolean; collections: string[]; } interface TaxonomySidebarProps { collection: string; entryId?: string; onChange?: (taxonomyName: string, termIds: string[]) => void; } /** * Fetch taxonomy definitions */ async function fetchTaxonomyDefs(): Promise { const res = await apiFetch(`/_emdash/api/taxonomies`); const data = await parseApiResponse<{ taxonomies: TaxonomyDef[] }>( res, "Failed to fetch taxonomies", ); return data.taxonomies; } /** * Fetch terms for a taxonomy */ async function fetchTerms(taxonomyName: string): Promise { const res = await apiFetch(`/_emdash/api/taxonomies/${taxonomyName}/terms`); const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(res, "Failed to fetch terms"); return data.terms; } /** * Fetch entry terms */ async function fetchEntryTerms( collection: string, entryId: string, taxonomy: string, ): Promise { const res = await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`); const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>( res, "Failed to fetch entry terms", ); return data.terms; } /** * Set entry terms */ async function setEntryTerms( collection: string, entryId: string, taxonomy: string, termIds: string[], ): Promise { const res = await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ termIds }), }); if (!res.ok) await throwResponseError(res, "Failed to set entry terms"); } /** * Checkbox tree for hierarchical taxonomies */ function CategoryCheckboxTree({ term, level = 0, selectedIds, onToggle, }: { term: TaxonomyTerm; level?: number; selectedIds: Set; onToggle: (termId: string) => void; }) { const isChecked = selectedIds.has(term.id); return (
{term.children.map((child) => ( ))}
); } /** * Tag input for flat taxonomies */ function TagInput({ terms, selectedIds, onAdd, onRemove, label, }: { terms: TaxonomyTerm[]; selectedIds: Set; onAdd: (termId: string) => void; onRemove: (termId: string) => void; label: string; }) { const [input, setInput] = React.useState(""); const selectedTerms = terms.filter((t) => selectedIds.has(t.id)); const suggestions = React.useMemo(() => { if (!input) return []; return terms .filter((t) => t.label.toLowerCase().includes(input.toLowerCase()) && !selectedIds.has(t.id)) .slice(0, 5); }, [input, terms, selectedIds]); const handleSelect = (term: TaxonomyTerm) => { onAdd(term.id); setInput(""); }; return (
{/* Selected tags */} {selectedTerms.length > 0 && (
{selectedTerms.map((term) => ( {term.label} ))}
)} {/* Input with autocomplete */}
setInput(e.target.value)} placeholder="Add tags..." aria-label={`Add ${label}`} className="text-sm" /> {/* Suggestions dropdown */} {suggestions.length > 0 && (
{suggestions.map((term) => ( ))}
)}
); } /** * Single taxonomy section */ function TaxonomySection({ taxonomy, collection, entryId, onChange, }: { taxonomy: TaxonomyDef; collection: string; entryId?: string; onChange?: (termIds: string[]) => void; }) { const queryClient = useQueryClient(); const { data: terms = [] } = useQuery({ queryKey: ["taxonomy-terms", taxonomy.name], queryFn: () => fetchTerms(taxonomy.name), }); const { data: entryTerms = [] } = useQuery({ queryKey: ["entry-terms", collection, entryId, taxonomy.name], queryFn: () => { if (!entryId) return []; return fetchEntryTerms(collection, entryId, taxonomy.name); }, enabled: !!entryId, }); const saveMutation = useMutation({ mutationFn: (termIds: string[]) => { if (!entryId) throw new Error("No entry ID"); return setEntryTerms(collection, entryId, taxonomy.name, termIds); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["entry-terms", collection, entryId, taxonomy.name], }); }, }); const [selectedIds, setSelectedIds] = React.useState>(new Set()); // Sync selected IDs from entry terms React.useEffect(() => { setSelectedIds(new Set(entryTerms.map((t) => t.id))); }, [entryTerms]); const handleToggle = (termId: string) => { const newSelected = new Set(selectedIds); if (newSelected.has(termId)) { newSelected.delete(termId); } else { newSelected.add(termId); } setSelectedIds(newSelected); // Notify parent of change const termIdsArray = [...newSelected]; onChange?.(termIdsArray); // Auto-save if entry exists if (entryId) { saveMutation.mutate(termIdsArray); } }; const handleAdd = (termId: string) => { handleToggle(termId); }; const handleRemove = (termId: string) => { handleToggle(termId); }; return (
{terms.length === 0 ? (

No {taxonomy.label.toLowerCase()} available.

) : taxonomy.hierarchical ? (
{terms.map((term) => ( ))}
) : ( )}
); } /** * Main TaxonomySidebar component */ export function TaxonomySidebar({ collection, entryId, onChange }: TaxonomySidebarProps) { const { data: taxonomies = [] } = useQuery({ queryKey: ["taxonomy-defs"], queryFn: fetchTaxonomyDefs, }); // Filter to taxonomies that apply to this collection const applicableTaxonomies = taxonomies.filter((t) => t.collections.includes(collection)); if (applicableTaxonomies.length === 0) { return null; } return (

Taxonomies

{applicableTaxonomies.map((taxonomy) => ( onChange?.(taxonomy.name, termIds)} /> ))}
); }