Files
dashboards/components/BlogCategoriesPage.tsx

445 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
import CategoryTreeView, {
type CategoryTreeNode,
} from "@/components/CategoryTreeView";
import ConfirmDialog from "@/components/ConfirmDialog";
import type { MeshkeeCategory } from "@/services/meshkee-categories";
import {
createWebsiteBlogCategory,
deleteWebsiteBlogCategory,
listWebsiteBlogCategories,
updateWebsiteBlogCategory,
type WebsiteBlogCategory,
} from "@/services/website-blog";
import styles from "./BusinessCategoriesPage.module.css";
type Draft = {
id?: string;
parentId: string;
name: string;
nameFa: string;
description: string;
};
function emptyDraft(parentId = ""): Draft {
return {
parentId,
name: "",
nameFa: "",
description: "",
};
}
function categoryLabel(category: {
name: string;
nameFa?: string | null;
}) {
if (category.nameFa && category.nameFa !== category.name) {
return `${category.name} / ${category.nameFa}`;
}
return category.name;
}
function toTreeSelect(items: WebsiteBlogCategory[]): MeshkeeCategory[] {
return items.map((item) => ({
id: item.id,
parentId: item.parentId,
name: item.name,
nameFa: item.nameFa ?? null,
slug: "",
description: item.description ?? null,
icon: null,
sortOrder: 0,
tags: [],
}));
}
function toViewNodes(items: WebsiteBlogCategory[]): CategoryTreeNode[] {
const byId = new Map<string, CategoryTreeNode>();
for (const item of items) {
byId.set(item.id, {
id: item.id,
name: categoryLabel(item),
children: [],
});
}
const roots: CategoryTreeNode[] = [];
for (const item of items) {
const node = byId.get(item.id)!;
if (item.parentId && byId.has(item.parentId)) {
byId.get(item.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
const sortNodes = (nodes: CategoryTreeNode[]) => {
nodes.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
for (const node of nodes) sortNodes(node.children);
};
sortNodes(roots);
return roots;
}
function collectDescendantIds(
rootId: string,
items: WebsiteBlogCategory[],
): Set<string> {
const childrenByParent = new Map<string, string[]>();
for (const item of items) {
if (!item.parentId) continue;
const list = childrenByParent.get(item.parentId) ?? [];
list.push(item.id);
childrenByParent.set(item.parentId, list);
}
const result = new Set<string>([rootId]);
const queue = [rootId];
while (queue.length) {
const current = queue.shift()!;
for (const childId of childrenByParent.get(current) ?? []) {
if (!result.has(childId)) {
result.add(childId);
queue.push(childId);
}
}
}
return result;
}
export default function BlogCategoriesPage() {
const [categories, setCategories] = useState<WebsiteBlogCategory[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [removing, setRemoving] = useState<WebsiteBlogCategory | null>(null);
const [removeBusy, setRemoveBusy] = useState(false);
const tree = useMemo(() => toViewNodes(categories), [categories]);
const byId = useMemo(() => {
const map = new Map<string, WebsiteBlogCategory>();
for (const item of categories) map.set(item.id, item);
return map;
}, [categories]);
const parentOptions = useMemo(() => {
const draftId = draft?.id;
const source = !draftId
? categories
: categories.filter(
(item) => !collectDescendantIds(draftId, categories).has(item.id),
);
return toTreeSelect(source);
}, [categories, draft?.id]);
const reload = useCallback(async () => {
setLoading(true);
setError("");
try {
setCategories(await listWebsiteBlogCategories());
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not load categories.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
function openCreate(parentId = "") {
setDraft(emptyDraft(parentId));
setError("");
}
function openEdit(id: string) {
const category = byId.get(id);
if (!category) return;
setDraft({
id: category.id,
parentId: category.parentId ?? "",
name: category.name,
nameFa: category.nameFa ?? "",
description: category.description ?? "",
});
setError("");
}
async function saveDraft() {
if (!draft) return;
if (!draft.name.trim()) {
setError("Enter an English name.");
return;
}
setSaving(true);
setError("");
try {
const body = {
parentId: draft.parentId || null,
name: draft.name.trim(),
nameFa: draft.nameFa.trim(),
description: draft.description.trim(),
};
if (draft.id) {
await updateWebsiteBlogCategory(draft.id, body);
} else {
await createWebsiteBlogCategory(body);
}
setDraft(null);
await reload();
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not save category.",
);
} finally {
setSaving(false);
}
}
async function confirmRemove() {
if (!removing) return;
setRemoveBusy(true);
setError("");
try {
await deleteWebsiteBlogCategory(removing.id);
setRemoving(null);
await reload();
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not remove category.",
);
} finally {
setRemoveBusy(false);
}
}
const removingName = removing ? categoryLabel(removing) : "this category";
return (
<div className={styles.page}>
<header className={styles.head}>
<div>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<Link href="/">Home</Link>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<Link href="/website/blog">Blog</Link>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<span className={styles.breadcrumbCurrent}>Categories</span>
</nav>
<h1 className={styles.title}>Categories</h1>
<p className={styles.lead}>
Blog categories. Add, edit, or remove the tree.
</p>
</div>
<div className={styles.headActions}>
<p className={styles.count}>
{loading ? "Loading…" : `${categories.length} categories`}
</p>
<button
type="button"
className={`outline-btn ${styles.addBtn}`}
onClick={() => openCreate()}
>
Add category
</button>
</div>
</header>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
<section className={styles.card}>
{loading ? <p className={styles.empty}>Loading categories…</p> : null}
{!loading ? (
<CategoryTreeView
nodes={tree}
actions={{
onAddChild: openCreate,
onEdit: openEdit,
onRemove: (id) => {
const category = byId.get(id);
if (category) setRemoving(category);
},
}}
/>
) : null}
</section>
{draft ? (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !saving) {
setDraft(null);
}
}}
>
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby="blog-category-draft-title"
>
<div className={styles.modalHead}>
<div>
<p className={styles.kicker}>Categories</p>
<h2 id="blog-category-draft-title" className={styles.modalTitle}>
{draft.id ? "Edit category" : "Add category"}
</h2>
</div>
<button
type="button"
className={styles.closeBtn}
aria-label="Close"
disabled={saving}
onClick={() => setDraft(null)}
>
×
</button>
</div>
<form
className={styles.form}
onSubmit={(event) => {
event.preventDefault();
void saveDraft();
}}
>
<CategoryTreeSelect
categories={parentOptions}
value={draft.parentId ? [draft.parentId] : []}
selection="single"
label="Parent"
placeholder="Top level"
disabled={saving}
onChange={(ids) =>
setDraft((current) =>
current
? { ...current, parentId: ids[0] ?? "" }
: current,
)
}
/>
{draft.parentId ? (
<button
type="button"
className={styles.clearParent}
disabled={saving}
onClick={() =>
setDraft((current) =>
current ? { ...current, parentId: "" } : current,
)
}
>
Top level
</button>
) : null}
<label className={styles.field}>
<span>Name EN</span>
<input
value={draft.name}
disabled={saving}
required
autoFocus
onChange={(event) =>
setDraft((current) =>
current
? { ...current, name: event.target.value }
: current,
)
}
/>
</label>
<label className={styles.field}>
<span>Name FA</span>
<input
className="fa"
lang="fa"
dir="rtl"
value={draft.nameFa}
disabled={saving}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, nameFa: event.target.value }
: current,
)
}
/>
</label>
<label className={styles.field}>
<span>Description</span>
<textarea
rows={3}
value={draft.description}
disabled={saving}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, description: event.target.value }
: current,
)
}
/>
</label>
<div className={styles.modalActions}>
<button
type="button"
className={`outline-btn ${styles.ghostBtn}`}
disabled={saving}
onClick={() => setDraft(null)}
>
Cancel
</button>
<button
type="submit"
className={`outline-btn ${styles.addBtn}`}
disabled={saving}
>
{saving ? "Saving…" : draft.id ? "Update" : "Add"}
</button>
</div>
</form>
</div>
</div>
) : null}
<ConfirmDialog
open={Boolean(removing)}
kicker="Remove category"
title={`Remove ${removingName}?`}
message="This category and its subcategories will be removed."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removeBusy}
onClose={() => {
if (!removeBusy) setRemoving(null);
}}
onConfirm={() => {
void confirmRemove();
}}
/>
</div>
);
}