Files
gerbeur/src/components/CategoryManager.tsx
khannurien fae25f3e6c
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s
v3: added site-wide categories, added admin category management, various visual fixes
2026-06-28 16:23:44 +00:00

244 lines
6.8 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.
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import {
type Category,
type CreateCategoryRequest,
type RawCategory,
type UpdateCategoryRequest,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useCategories } from "../hooks/useCategories.ts";
import { ConfirmModal } from "./ConfirmModal.tsx";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
useApiForm,
} from "./form/index.ts";
interface CategoryFields {
name: string;
slug: string;
}
/** Sentinel for "the create chip is open" (vs. a category id being edited). */
const NEW = "new";
/**
* Admin UI to create, edit and delete categories. Rendered inside the "Manage"
* profile tab (gated by the `category:manage` permission).
*
* Categories show as chips; clicking one expands it into an inline editor. A
* trailing "+" chip opens the same editor in create mode. Only one editor is
* open at a time.
*/
export function CategoryManager() {
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 {
/** Omit to render the editor in "create" mode. */
category?: Category;
onCancel: () => void;
onChanged: () => void;
}
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<RawCategory>(
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);
}
}
// Create needs both fields; edit needs an actual change too.
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}
>
✓
</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>
);
}