Files
gerbeur/src/components/category/CategoryManager.tsx
khannurien 0d4e344d77 v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:31:14 +00:00

261 lines
7.5 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, on the "manage" tab of one's own
* profile 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,
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";
/**
* Renders the categories 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 feeds' category switcher follows. `UserPublicProfile`
* shows it only to those who hold `category:manage`, and the server checks it
* again.
*/
export function CategoryManager(): React.JSX.Element {
const { categories, isLoaded, refresh } = useCategories();
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}
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
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 {
/** 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 stay, without it.
*/
function CategoryEditor(
{ 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 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>
);
}