Files
gerbeur/src/components/category/CategoryManager.tsx
khannurien 53515ce46f v4: playlists have categories of their own — a set apart from the dumps', which admins manage on /manage under "Playlist categories", next to "Dump categories". Each category now has a kind, dump or playlist, its slug and name unique within its kind only; migration 0025 rebuilds categories with the column, every existing one becoming a dump category with its dumps, and adds playlist_categories. A playlist's owner files it under any of them in the create and edit forms, and it shows them as chips on its card and its page, each leading to /playlists/<slug>.
/playlists is the new page of every public playlist, newest first (GET /api/playlists), and /playlists/<slug> a playlist category's, with a menu of the categories, linked from the user menu as "All playlists"; `playlists` is reserved as a dump category's slug, and one that had it is renamed. A profile's Playlists tab narrows to a playlist category with the same menu as its Dumps tab (?category=), and so do the Dumps and Playlists tabs of /search, each with its own kind (category and playlistCategory for GET /api/search), another tab dropping it.

The category service takes the kind throughout, so a dump can't be filed under a playlist category nor the other way round, and categoryJoin is the one filter join the dump, follow and playlist services share. useCategories(kind) gives one kind's set from the one fetch, CategoryChips replaces the chips the dump card and page each drew, useCategoryFilter keeps a list to its category as the socket brings more, and useCategorySelection holds a form's picked categories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:53:16 +00:00

277 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string | null>(null);
const close = () => setEditing(null);
const handleChanged = () => {
refresh();
close();
};
if (!isLoaded && categories.length === 0) {
return (
<p className="index-status">
<Trans>Loading…</Trans>
</p>
);
}
return (
<div className="category-chips">
{categories.map((category) =>
editing === category.id
? (
<CategoryEditor
key={category.id}
kind={kind}
category={category}
onCancel={close}
onChanged={handleChanged}
/>
)
: (
<button
key={category.id}
type="button"
className="category-chip"
onClick={() => setEditing(category.id)}
disabled={editing !== null}
title={t`Edit ${category.name}`}
>
{category.name}
</button>
)
)}
{editing === NEW
? (
<CategoryEditor
kind={kind}
onCancel={close}
onChanged={handleChanged}
/>
)
: (
<button
type="button"
className="category-chip category-chip--add"
onClick={() => setEditing(NEW)}
disabled={editing !== null}
aria-label={t`Add category`}
title={t`Add category`}
>
+
</button>
)}
</div>
);
}
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<CategoryFields>({
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<Wire<Category>>(
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 (
<FormProvider {...form}>
<div className="category-editor-wrap">
<form
className="category-editor"
onSubmit={onSubmit}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<input
className="category-editor-input category-editor-input--name"
type="text"
placeholder={t`Name`}
aria-label={t`Name`}
maxLength={VALIDATION.CATEGORY_NAME_MAX}
disabled={deleting}
autoFocus
{...form.register("name")}
/>
<span className="category-editor-sep" aria-hidden>
/
</span>
<input
className="category-editor-input category-editor-input--slug"
type="text"
placeholder={t`slug`}
aria-label={t`URL slug`}
maxLength={VALIDATION.CATEGORY_SLUG_MAX}
disabled={deleting}
{...form.register("slug")}
/>
<div className="category-editor-actions">
<SubmitButton
className="category-editor-btn category-editor-btn--save"
pendingLabel="…"
disabled={!canSave}
aria-label={t`Save`}
>
✓
</SubmitButton>
<button
type="button"
className="category-editor-btn"
onClick={onCancel}
disabled={deleting}
title={t`Cancel`}
aria-label={t`Cancel`}
>
✕
</button>
{!isNew && (
<button
type="button"
className="category-editor-btn category-editor-btn--delete"
onClick={() => setConfirmOpen(true)}
disabled={deleting}
title={t`Delete`}
aria-label={t`Delete`}
>
🗑
</button>
)}
</div>
</form>
<FormError
title={isNew
? t`Failed to create category`
: t`Failed to save category`}
/>
</div>
{confirmOpen && category && (
<ConfirmModal
message={t`Delete category "${category.name}"? This cannot be undone.`}
confirmLabel={t`Delete category`}
onConfirm={remove}
onCancel={() => setConfirmOpen(false)}
/>
)}
</FormProvider>
);
}