Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
674 lines
18 KiB
TypeScript
674 lines
18 KiB
TypeScript
/**
|
|
* Taxonomy Terms Manager
|
|
*
|
|
* Provides UI for managing taxonomy terms (categories, tags, custom taxonomies).
|
|
* Shows hierarchical structure for categories, flat list for tags.
|
|
*/
|
|
|
|
import { Button, Checkbox, Dialog, Input, InputArea, Select, Toast } from "@cloudflare/kumo";
|
|
import { useLingui } from "@lingui/react/macro";
|
|
import { Plus, Pencil, Trash, X } from "@phosphor-icons/react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import * as React from "react";
|
|
|
|
import { fetchManifest } from "../lib/api/client.js";
|
|
import type { TaxonomyTerm, TaxonomyDef, CreateTaxonomyInput } from "../lib/api/taxonomies.js";
|
|
import {
|
|
fetchTaxonomyDef,
|
|
fetchTerms,
|
|
createTaxonomy,
|
|
createTerm,
|
|
updateTerm,
|
|
deleteTerm,
|
|
} from "../lib/api/taxonomies.js";
|
|
import { slugify } from "../lib/utils";
|
|
import { ConfirmDialog } from "./ConfirmDialog.js";
|
|
import { DialogError, getMutationError } from "./DialogError.js";
|
|
|
|
interface TaxonomyManagerProps {
|
|
taxonomyName: string;
|
|
}
|
|
|
|
// Regex patterns for taxonomy name generation and validation (module-scoped per lint rules)
|
|
const NON_ALPHANUMERIC_PATTERN = /[^a-z0-9]+/g;
|
|
const LEADING_TRAILING_UNDERSCORE_PATTERN = /^_|_$/g;
|
|
const TAXONOMY_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
|
|
/**
|
|
* Flatten tree to get all terms
|
|
*/
|
|
function flattenTerms(terms: TaxonomyTerm[]): TaxonomyTerm[] {
|
|
return terms.flatMap((t) => [t, ...flattenTerms(t.children)]);
|
|
}
|
|
|
|
/**
|
|
* Term row component (recursive for hierarchy)
|
|
*/
|
|
function TermRow({
|
|
term,
|
|
level = 0,
|
|
onEdit,
|
|
onDelete,
|
|
}: {
|
|
term: TaxonomyTerm;
|
|
level?: number;
|
|
onEdit: (term: TaxonomyTerm) => void;
|
|
onDelete: (term: TaxonomyTerm) => void;
|
|
}) {
|
|
const { t } = useLingui();
|
|
return (
|
|
<>
|
|
<div className="flex items-center gap-4 py-2 px-4 border-b hover:bg-kumo-tint/50">
|
|
<div style={{ marginInlineStart: `${level * 1.5}rem` }} className="flex-1">
|
|
<span className="font-medium">{term.label}</span>
|
|
<span className="text-sm text-kumo-subtle ms-2">({term.slug})</span>
|
|
</div>
|
|
<div className="text-sm text-kumo-subtle">{term.count || 0}</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label={t`Edit ${term.label}`}
|
|
onClick={() => onEdit(term)}
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label={t`Delete ${term.label}`}
|
|
onClick={() => onDelete(term)}
|
|
>
|
|
<Trash className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{term.children.map((child) => (
|
|
<TermRow
|
|
key={child.id}
|
|
term={child}
|
|
level={level + 1}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Term form dialog
|
|
*/
|
|
function TermFormDialog({
|
|
open,
|
|
onClose,
|
|
taxonomyName,
|
|
taxonomyDef,
|
|
term,
|
|
allTerms,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
taxonomyName: string;
|
|
taxonomyDef: TaxonomyDef;
|
|
term?: TaxonomyTerm;
|
|
allTerms: TaxonomyTerm[];
|
|
}) {
|
|
const { t } = useLingui();
|
|
const queryClient = useQueryClient();
|
|
const [label, setLabel] = React.useState(term?.label || "");
|
|
const [slug, setSlug] = React.useState(term?.slug || "");
|
|
const [parentId, setParentId] = React.useState(term?.parentId || "");
|
|
const [description, setDescription] = React.useState(term?.description || "");
|
|
const [autoSlug, setAutoSlug] = React.useState(!term);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
// Sync form state when term prop changes (for edit mode)
|
|
React.useEffect(() => {
|
|
setLabel(term?.label || "");
|
|
setSlug(term?.slug || "");
|
|
setParentId(term?.parentId || "");
|
|
setDescription(term?.description || "");
|
|
setAutoSlug(!term);
|
|
setError(null);
|
|
}, [term]);
|
|
|
|
// Auto-generate slug from label
|
|
React.useEffect(() => {
|
|
if (autoSlug && label) {
|
|
setSlug(slugify(label));
|
|
}
|
|
}, [label, autoSlug]);
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: () =>
|
|
createTerm(taxonomyName, {
|
|
slug,
|
|
label,
|
|
parentId: parentId || undefined,
|
|
description: description || undefined,
|
|
}),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["taxonomy-terms", taxonomyName],
|
|
});
|
|
onClose();
|
|
},
|
|
onError: (err: Error) => {
|
|
setError(err.message);
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: () => {
|
|
if (!term) throw new Error("No term to update");
|
|
return updateTerm(taxonomyName, term.slug, {
|
|
slug,
|
|
label,
|
|
parentId: parentId || undefined,
|
|
description: description || undefined,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["taxonomy-terms", taxonomyName],
|
|
});
|
|
onClose();
|
|
},
|
|
onError: (err: Error) => {
|
|
setError(err.message);
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
if (term) {
|
|
updateMutation.mutate();
|
|
} else {
|
|
createMutation.mutate();
|
|
}
|
|
};
|
|
|
|
// Flatten terms for parent selector (exclude current term and its children)
|
|
const flatTerms = flattenTerms(allTerms);
|
|
const availableParents = term
|
|
? flatTerms.filter((item) => item.id !== term.id && item.parentId !== term.id)
|
|
: flatTerms;
|
|
|
|
return (
|
|
<Dialog.Root
|
|
open={open}
|
|
onOpenChange={(isOpen: boolean) => {
|
|
if (!isOpen) {
|
|
setError(null);
|
|
onClose();
|
|
}
|
|
}}
|
|
>
|
|
<Dialog className="p-6" size="lg">
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="flex items-start justify-between gap-4 mb-4">
|
|
<div className="flex flex-col space-y-1.5">
|
|
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
|
{term ? t`Edit` : t`Add`} {taxonomyDef.labelSingular || t`Term`}
|
|
</Dialog.Title>
|
|
<Dialog.Description className="text-sm text-kumo-subtle">
|
|
{term
|
|
? t`Update the ${taxonomyDef.labelSingular?.toLowerCase() || "term"} details`
|
|
: t`Create a new ${taxonomyDef.labelSingular?.toLowerCase() || "term"}`}
|
|
</Dialog.Description>
|
|
</div>
|
|
<Dialog.Close
|
|
aria-label={t`Close`}
|
|
render={(props) => (
|
|
<Button
|
|
{...props}
|
|
variant="ghost"
|
|
shape="square"
|
|
aria-label={t`Close`}
|
|
className="absolute end-4 top-4"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
<span className="sr-only">{t`Close`}</span>
|
|
</Button>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-4 py-4">
|
|
<Input
|
|
label={t`Name`}
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
placeholder="News"
|
|
required
|
|
/>
|
|
|
|
<div>
|
|
<Input
|
|
label={t`Slug`}
|
|
value={slug}
|
|
onChange={(e) => {
|
|
setSlug(e.target.value);
|
|
setAutoSlug(false);
|
|
}}
|
|
placeholder="news"
|
|
required
|
|
/>
|
|
<p className="text-sm text-kumo-subtle mt-1">
|
|
{t`Auto-generated from name (you can edit)`}
|
|
</p>
|
|
</div>
|
|
|
|
{taxonomyDef.hierarchical && (
|
|
<Select
|
|
label={t`Parent`}
|
|
value={parentId}
|
|
onValueChange={(v) => setParentId(v ?? "")}
|
|
items={{
|
|
"": t`None (top level)`,
|
|
...Object.fromEntries(
|
|
availableParents.map((parentTerm) => [parentTerm.id, parentTerm.label]),
|
|
),
|
|
}}
|
|
>
|
|
<Select.Option value="">{t`None (top level)`}</Select.Option>
|
|
{availableParents.map((parentTerm) => (
|
|
<Select.Option key={parentTerm.id} value={parentTerm.id}>
|
|
{parentTerm.label}
|
|
</Select.Option>
|
|
))}
|
|
</Select>
|
|
)}
|
|
|
|
<InputArea
|
|
label={t`Description (optional)`}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder={t`Optional description`}
|
|
rows={3}
|
|
/>
|
|
|
|
<DialogError
|
|
message={
|
|
error ||
|
|
getMutationError(createMutation.error) ||
|
|
getMutationError(updateMutation.error)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
|
<Button type="button" variant="outline" onClick={onClose}>
|
|
{t`Cancel`}
|
|
</Button>
|
|
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
|
{createMutation.isPending || updateMutation.isPending
|
|
? t`Saving...`
|
|
: term
|
|
? t`Update`
|
|
: t`Create`}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
</Dialog.Root>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create Taxonomy dialog
|
|
*/
|
|
function CreateTaxonomyDialog({
|
|
open,
|
|
onClose,
|
|
onCreated,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onCreated: () => void;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const queryClient = useQueryClient();
|
|
const [name, setName] = React.useState("");
|
|
const [label, setLabel] = React.useState("");
|
|
const [hierarchical, setHierarchical] = React.useState(false);
|
|
const [selectedCollections, setSelectedCollections] = React.useState<string[]>([]);
|
|
const [autoName, setAutoName] = React.useState(true);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
const { data: manifest } = useQuery({
|
|
queryKey: ["manifest"],
|
|
queryFn: fetchManifest,
|
|
});
|
|
|
|
const collectionEntries = manifest
|
|
? Object.entries(manifest.collections).map(([slug, config]) => ({
|
|
slug,
|
|
label: config.label,
|
|
}))
|
|
: [];
|
|
|
|
// Auto-generate name from label
|
|
React.useEffect(() => {
|
|
if (autoName && label) {
|
|
setName(
|
|
label
|
|
.toLowerCase()
|
|
.replace(NON_ALPHANUMERIC_PATTERN, "_")
|
|
.replace(LEADING_TRAILING_UNDERSCORE_PATTERN, ""),
|
|
);
|
|
}
|
|
}, [label, autoName]);
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (input: CreateTaxonomyInput) => createTaxonomy(input),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ["taxonomy-defs"] });
|
|
void queryClient.invalidateQueries({ queryKey: ["taxonomy-def"] });
|
|
onCreated();
|
|
resetForm();
|
|
},
|
|
});
|
|
|
|
const resetForm = () => {
|
|
setName("");
|
|
setLabel("");
|
|
setHierarchical(false);
|
|
setSelectedCollections([]);
|
|
setAutoName(true);
|
|
setError(null);
|
|
createMutation.reset();
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
if (!name || !label) {
|
|
setError(t`Name and label are required`);
|
|
return;
|
|
}
|
|
|
|
if (!TAXONOMY_NAME_PATTERN.test(name)) {
|
|
setError(
|
|
t`Name must start with a letter and contain only lowercase letters, numbers, and underscores`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
createMutation.mutate({
|
|
name,
|
|
label,
|
|
hierarchical,
|
|
collections: selectedCollections,
|
|
});
|
|
};
|
|
|
|
const toggleCollection = (slug: string) => {
|
|
setSelectedCollections((prev) =>
|
|
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug],
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Dialog.Root
|
|
open={open}
|
|
onOpenChange={(isOpen: boolean) => {
|
|
if (!isOpen) {
|
|
resetForm();
|
|
onClose();
|
|
}
|
|
}}
|
|
>
|
|
<Dialog className="p-6" size="lg">
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="flex items-start justify-between gap-4 mb-4">
|
|
<div className="flex flex-col space-y-1.5">
|
|
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
|
{t`Create Taxonomy`}
|
|
</Dialog.Title>
|
|
<Dialog.Description className="text-sm text-kumo-subtle">
|
|
{t`Define a new taxonomy for classifying content`}
|
|
</Dialog.Description>
|
|
</div>
|
|
<Dialog.Close
|
|
aria-label={t`Close`}
|
|
render={(props) => (
|
|
<Button
|
|
{...props}
|
|
variant="ghost"
|
|
shape="square"
|
|
aria-label={t`Close`}
|
|
className="absolute end-4 top-4"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
<span className="sr-only">{t`Close`}</span>
|
|
</Button>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-4 py-4">
|
|
<Input
|
|
label={t`Label`}
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
placeholder="Genres"
|
|
required
|
|
/>
|
|
|
|
<div>
|
|
<Input
|
|
label={t`Name`}
|
|
value={name}
|
|
onChange={(e) => {
|
|
setName(e.target.value);
|
|
setAutoName(false);
|
|
}}
|
|
placeholder="genre"
|
|
required
|
|
pattern="[a-z][a-z0-9_]*"
|
|
title={t`Lowercase letters, numbers, and underscores only, starting with a letter`}
|
|
/>
|
|
<p className="text-xs text-kumo-subtle mt-1">
|
|
{t`Used as the identifier. Lowercase letters, numbers, and underscores only.`}
|
|
</p>
|
|
</div>
|
|
|
|
<Checkbox
|
|
label={t`Hierarchical (like categories, with parent/child relationships)`}
|
|
checked={hierarchical}
|
|
onCheckedChange={(checked) => setHierarchical(checked)}
|
|
/>
|
|
|
|
{collectionEntries.length > 0 && (
|
|
<div>
|
|
<label className="text-sm font-medium">{t`Collections`}</label>
|
|
<p className="text-xs text-kumo-subtle mb-2">
|
|
{t`Which content types can use this taxonomy`}
|
|
</p>
|
|
<div className="border rounded-md p-2 space-y-1">
|
|
{collectionEntries.map(({ slug, label: collLabel }) => (
|
|
<label
|
|
key={slug}
|
|
className="flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-kumo-tint/50 rounded"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedCollections.includes(slug)}
|
|
onChange={() => toggleCollection(slug)}
|
|
className="rounded"
|
|
/>
|
|
<span className="text-sm">{collLabel}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<DialogError message={error || getMutationError(createMutation.error)} />
|
|
</div>
|
|
|
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
resetForm();
|
|
onClose();
|
|
}}
|
|
>
|
|
{t`Cancel`}
|
|
</Button>
|
|
<Button type="submit" disabled={createMutation.isPending}>
|
|
{createMutation.isPending ? t`Creating...` : t`Create Taxonomy`}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
</Dialog.Root>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Main TaxonomyManager component
|
|
*/
|
|
export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
|
|
const { t } = useLingui();
|
|
const queryClient = useQueryClient();
|
|
const toastManager = Toast.useToastManager();
|
|
const [formOpen, setFormOpen] = React.useState(false);
|
|
const [editingTerm, setEditingTerm] = React.useState<TaxonomyTerm | undefined>();
|
|
const [deleteTarget, setDeleteTarget] = React.useState<TaxonomyTerm | null>(null);
|
|
const [createTaxonomyOpen, setCreateTaxonomyOpen] = React.useState(false);
|
|
|
|
const { data: taxonomyDef, isLoading: defLoading } = useQuery({
|
|
queryKey: ["taxonomy-def", taxonomyName],
|
|
queryFn: () => fetchTaxonomyDef(taxonomyName),
|
|
});
|
|
|
|
const { data: terms = [], isLoading: termsLoading } = useQuery({
|
|
queryKey: ["taxonomy-terms", taxonomyName],
|
|
queryFn: () => fetchTerms(taxonomyName),
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (term: TaxonomyTerm) => deleteTerm(taxonomyName, term.slug),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["taxonomy-terms", taxonomyName],
|
|
});
|
|
setDeleteTarget(null);
|
|
toastManager.add({ title: t`Term deleted` });
|
|
},
|
|
});
|
|
|
|
const handleEdit = (term: TaxonomyTerm) => {
|
|
setEditingTerm(term);
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const handleDelete = (term: TaxonomyTerm) => {
|
|
setDeleteTarget(term);
|
|
};
|
|
|
|
const handleCloseForm = () => {
|
|
setFormOpen(false);
|
|
setEditingTerm(undefined);
|
|
};
|
|
|
|
if (defLoading) {
|
|
return <div>{t`Loading...`}</div>;
|
|
}
|
|
|
|
if (!taxonomyDef) {
|
|
return (
|
|
<div>
|
|
{t`Taxonomy not found:`} {taxonomyName}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const flatTerms = flattenTerms(terms);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">{taxonomyDef.label}</h1>
|
|
<p className="text-kumo-subtle mt-1">
|
|
{t`Manage ${taxonomyDef.label.toLowerCase()} for ${taxonomyDef.collections.join(", ")}`}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" icon={<Plus />} onClick={() => setCreateTaxonomyOpen(true)}>
|
|
{t`New Taxonomy`}
|
|
</Button>
|
|
<Button icon={<Plus />} onClick={() => setFormOpen(true)}>
|
|
{t`Add`} {taxonomyDef.labelSingular || t`Term`}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border rounded-lg">
|
|
<div className="flex items-center gap-4 py-2 px-4 border-b bg-kumo-tint/50 font-medium">
|
|
<div className="flex-1">{t`Name`}</div>
|
|
<div className="w-16 text-center">{t`Count`}</div>
|
|
<div className="w-24 text-center">{t`Actions`}</div>
|
|
</div>
|
|
|
|
{termsLoading ? (
|
|
<div className="p-8 text-center text-kumo-subtle">{t`Loading terms...`}</div>
|
|
) : terms.length === 0 ? (
|
|
<div className="p-8 text-center text-kumo-subtle">
|
|
{t`No ${taxonomyDef.label.toLowerCase()} yet. Create one to get started.`}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{terms.map((term) => (
|
|
<TermRow key={term.id} term={term} onEdit={handleEdit} onDelete={handleDelete} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<TermFormDialog
|
|
open={formOpen}
|
|
onClose={handleCloseForm}
|
|
taxonomyName={taxonomyName}
|
|
taxonomyDef={taxonomyDef}
|
|
term={editingTerm}
|
|
allTerms={flatTerms}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={!!deleteTarget}
|
|
onClose={() => {
|
|
setDeleteTarget(null);
|
|
deleteMutation.reset();
|
|
}}
|
|
title={t`Delete ${taxonomyDef.labelSingular || "Term"}?`}
|
|
description={
|
|
<>{t`This will permanently delete "${deleteTarget?.label}" and remove it from all content.`}</>
|
|
}
|
|
confirmLabel={t`Delete`}
|
|
pendingLabel={t`Deleting...`}
|
|
isPending={deleteMutation.isPending}
|
|
error={deleteMutation.error}
|
|
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
|
/>
|
|
|
|
<CreateTaxonomyDialog
|
|
open={createTaxonomyOpen}
|
|
onClose={() => setCreateTaxonomyOpen(false)}
|
|
onCreated={() => {
|
|
setCreateTaxonomyOpen(false);
|
|
toastManager.add({ title: t`Taxonomy created` });
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|