/** * Renders the editor of the site's categories of one kind, dump or playlist, * on `/manage` for a user with the `category:manage` permission. * * @module */ import { useState } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL } from "../../config/api.ts"; import { VALIDATION } from "../../../shared/validation.ts"; import type { Category, CategoryKind, CreateCategoryRequest, UpdateCategoryRequest, } from "../../../shared/category.ts"; import type { Wire } from "../../../shared/api.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useCategories } from "../../hooks/category/useCategories.ts"; import { ConfirmModal } from "../shared/ConfirmModal.tsx"; import { expectOk } from "../../utils/shared/api.ts"; import { FormError } from "../form/FormError.tsx"; import { FormProvider } from "react-hook-form"; import { SubmitButton } from "../form/SubmitButton.tsx"; import { useApiForm } from "../../hooks/form/useApiForm.ts"; interface CategoryFields { name: string; slug: string; } /** * The value of `editing` while a new category is being created. Its leading * space keeps it from matching a category id. */ const NEW = "new"; /** Passed to {@link CategoryManager} by `Manage`, once for each kind. */ export interface CategoryManagerProps { /** The set it edits, and the kind of the categories it creates. */ kind: CategoryKind; } /** * Renders the categories of `kind` as chips that open an inline editor, and a * "+" chip that creates one. Only one editor is open at a time. A change * refetches the app-wide list, so the category switchers follow. `Manage` * shows it only to those who hold `category:manage`, and the server checks it * again. */ export function CategoryManager( { kind }: CategoryManagerProps, ): React.JSX.Element { const { categories, isLoaded, refresh } = useCategories(kind); const [editing, setEditing] = useState(null); const close = () => setEditing(null); const handleChanged = () => { refresh(); close(); }; if (!isLoaded && categories.length === 0) { return (

Loading…

); } return (
{categories.map((category) => editing === category.id ? ( ) : ( ) )} {editing === NEW ? ( ) : ( )}
); } interface CategoryEditorProps { /** The kind of the category it creates. */ kind: CategoryKind; /** Left out to create a category. */ category?: Category; onCancel: () => void; onChanged: () => void; } /** * Renders the inline editor of a category's name and slug, which also deletes * it after a confirmation. A deleted category's dumps or playlists stay, * without it. */ function CategoryEditor( { kind, category, onCancel, onChanged }: CategoryEditorProps, ) { const isNew = category === undefined; const { authFetch } = useAuth(); const form = useApiForm({ defaultValues: { name: category?.name ?? "", slug: category?.slug ?? "" }, }); const name = form.watch("name"); const slug = form.watch("slug"); const { isDirty } = form.formState; const [deleting, setDeleting] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const onSubmit = form.submit(async ({ name, slug }) => { const body = { name: name.trim(), slug: slug.trim() }; await expectOk>( isNew ? await authFetch(`${API_URL}/api/categories`, { method: "POST", body: JSON.stringify( { ...body, kind } satisfies CreateCategoryRequest, ), }) : await authFetch(`${API_URL}/api/categories/${category.id}`, { method: "PUT", body: JSON.stringify(body satisfies UpdateCategoryRequest), }), ); onChanged(); }); async function remove() { if (isNew) return; setConfirmOpen(false); form.clearErrors("root"); setDeleting(true); try { await expectOk( await authFetch(`${API_URL}/api/categories/${category.id}`, { method: "DELETE", }), ); onChanged(); } catch (err) { form.setError("root", { message: err instanceof Error ? err.message : t`Failed to delete`, }); setDeleting(false); } } // An edit must change something. const canSave = !deleting && name.trim() !== "" && slug.trim() !== "" && (isNew || isDirty); return (
{ if (e.key === "Escape") onCancel(); }} > /
✓ {!isNew && ( )}
{confirmOpen && category && ( setConfirmOpen(false)} /> )}
); }