mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat(categories): allow unlimited depth of categories
This commit is contained in:
1 parent
172270fce8
commit
04f3106ce8
13 files changed
+501
-434
No files matched your search
@@ -207,10 +207,28 @@ router.delete(
|
||||
const category = await prisma.category.findFirst({ where: { id: req.params.id, userId: req.userId } });
|
||||
if (!category) throw new HttpError(404, "Not found");
|
||||
|
||||
await prisma.category.updateMany({
|
||||
// Subcategories move up one level, after the deleted category's former siblings.
|
||||
const children = await prisma.category.findMany({
|
||||
where: { parentId: category.id, userId: req.userId },
|
||||
data: { parentId: null },
|
||||
orderBy: [{ position: "asc" }, { name: "asc" }],
|
||||
select: { id: true },
|
||||
});
|
||||
if (children.length) {
|
||||
const last = await prisma.category.findFirst({
|
||||
where: { userId: req.userId, parentId: category.parentId, id: { not: category.id } },
|
||||
orderBy: { position: "desc" },
|
||||
select: { position: true },
|
||||
});
|
||||
const start = (last?.position ?? -1) + 1;
|
||||
await prisma.$transaction(
|
||||
children.map((child, idx) =>
|
||||
prisma.category.update({
|
||||
where: { id: child.id },
|
||||
data: { parentId: category.parentId, position: start + idx },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const prints = await prisma.print.findMany({ where: { categoryId: category.id, userId: req.userId } });
|
||||
for (const print of prints) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Starter category tree modeled on MakerWorld's, with matching Thingiverse and Printables ids so
|
||||
* imports are auto-categorized from the start. At most two levels deep. */
|
||||
* imports are auto-categorized from the start. */
|
||||
export type DefaultCategoryNode = {
|
||||
name: string;
|
||||
tags?: string[];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { DEFAULT_CATEGORIES, type DefaultCategoryNode } from "../seedData/defaultCategories";
|
||||
|
||||
/** Keeps the tree at most two levels deep, which also makes cycles impossible. */
|
||||
/** Any depth is allowed; only cycles are rejected (a category under itself or its own descendant). */
|
||||
export async function validateParentCategory(
|
||||
userId: string,
|
||||
parentId: string | null | undefined,
|
||||
@@ -14,13 +14,20 @@ export async function validateParentCategory(
|
||||
if (!parent) throw new HttpError(400, "Parent category not found");
|
||||
if (categoryId && parentId === categoryId) throw new HttpError(400, "Category cannot be its own parent");
|
||||
|
||||
if (parent.parentId) {
|
||||
throw new HttpError(400, "Categories can only be nested two levels deep");
|
||||
}
|
||||
if (categoryId) {
|
||||
const childCount = await prisma.category.count({ where: { parentId: categoryId, userId } });
|
||||
if (childCount > 0) {
|
||||
throw new HttpError(400, "A category with subcategories cannot be moved under another category");
|
||||
// `visited` guards against a cycle already in the data.
|
||||
const visited = new Set<string>();
|
||||
let ancestorId = parent.parentId;
|
||||
while (ancestorId && !visited.has(ancestorId)) {
|
||||
if (ancestorId === categoryId) {
|
||||
throw new HttpError(400, "A category cannot be moved under one of its own subcategories");
|
||||
}
|
||||
visited.add(ancestorId);
|
||||
const ancestor = await prisma.category.findFirst({
|
||||
where: { id: ancestorId, userId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
ancestorId = ancestor?.parentId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,12 +89,31 @@ describe("POST /api/category/:id/move", () => {
|
||||
expect(await childNames(empty)).toEqual(["a1"]);
|
||||
});
|
||||
|
||||
it("keeps the tree two levels deep", async () => {
|
||||
// Under a subcategory: would be a third level.
|
||||
expect((await move(subs.a1, subs.b1, 0)).status).toBe(400);
|
||||
// A category with subcategories can't become one.
|
||||
expect((await move(rootA, rootB, 0)).status).toBe(400);
|
||||
it("nests to any depth, whole subtrees included", async () => {
|
||||
expect((await move(subs.a1, subs.b1, 0)).status).toBe(200);
|
||||
expect((await move(subs.b1, subs.a2, 0)).status).toBe(200);
|
||||
expect(await childNames(subs.a2)).toEqual(["b1"]);
|
||||
expect(await childNames(subs.b1)).toEqual(["a1"]);
|
||||
// A category with subcategories can itself become one.
|
||||
expect((await move(rootB, rootA, 0)).status).toBe(200);
|
||||
expect((await childNames(rootA))[0]).toMatch(/^Root B/);
|
||||
});
|
||||
|
||||
it("rejects cycles", async () => {
|
||||
expect((await move(subs.a1, subs.a1, 0)).status).toBe(400);
|
||||
// Under its own child, and under its own grandchild.
|
||||
expect((await move(rootA, subs.a1, 0)).status).toBe(400);
|
||||
const grandchild = await create("a1-child", subs.a1);
|
||||
expect((await move(rootA, grandchild, 0)).status).toBe(400);
|
||||
});
|
||||
|
||||
it("moves subcategories up one level when their category is deleted", async () => {
|
||||
await create("a2-child-1", subs.a2);
|
||||
await create("a2-child-2", subs.a2);
|
||||
expect((await request(app).delete(`/api/category/${subs.a2}`).set(auth(token))).status).toBe(200);
|
||||
const names = await childNames(rootA);
|
||||
expect(names.slice(0, 2)).toEqual(["a1", "a3"]);
|
||||
expect(names.slice(2).toSorted()).toEqual(["a2-child-1", "a2-child-2"]);
|
||||
});
|
||||
|
||||
it("only touches the user's own categories", async () => {
|
||||
|
||||
@@ -25,12 +25,6 @@ describe("DEFAULT_CATEGORIES", () => {
|
||||
expect(DEFAULT_CATEGORIES.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never nests more than two levels deep, matching validateParentCategory's limit", () => {
|
||||
for (const { depth } of all) {
|
||||
expect(depth).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every node a non-empty name", () => {
|
||||
for (const { node } of all) {
|
||||
expect(node.name.trim()).not.toBe("");
|
||||
|
||||
@@ -1441,7 +1441,7 @@
|
||||
"addSubcategory": "Add subcategory",
|
||||
"noCategories": "No categories yet. Add one to get started.",
|
||||
"confirmDeleteCategory": "Delete \"{{name}}\"? Any models in it move to Unassigned.",
|
||||
"confirmDeleteCategoryWithSub": "Delete \"{{name}}\"? Its subcategories become top-level categories, and any models in them move to Unassigned.",
|
||||
"confirmDeleteCategoryWithSub": "Delete \"{{name}}\"? Its subcategories move up one level, and any models directly in it move to Unassigned.",
|
||||
"confirmDeleteSubcategory": "Delete \"{{name}}\"? Any models in it move to Unassigned.",
|
||||
"addDetailsTooltip": "Add details",
|
||||
"editDetailsTooltip": "Edit details",
|
||||
@@ -1453,8 +1453,7 @@
|
||||
"printablesCatIdLabel": "Printables category IDs",
|
||||
"catIdsPlaceholder": "e.g. 800;71;1001",
|
||||
"catIdsHelp": "Separate multiple IDs with \";\" -- matches if an import's category is any of these",
|
||||
"dragToMove": "Drag to move",
|
||||
"dropHere": "Drop here"
|
||||
"dragToMove": "Drag to move"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1446,7 +1446,7 @@
|
||||
"addSubcategory": "Pridėti subkategoriją",
|
||||
"noCategories": "Kol kas nėra kategorijų. Pridėkite pirmąją.",
|
||||
"confirmDeleteCategory": "Ištrinti „{{name}}“? Joje esantys modeliai taps nepriskirti.",
|
||||
"confirmDeleteCategoryWithSub": "Ištrinti „{{name}}“? Jos subkategorijos taps pagrindinėmis kategorijomis, o jose esantys modeliai taps nepriskirti.",
|
||||
"confirmDeleteCategoryWithSub": "Ištrinti „{{name}}“? Jos subkategorijos pakils vienu lygiu aukščiau, o modeliai, esantys tiesiai joje, bus perkelti į „Nepriskirta“.",
|
||||
"confirmDeleteSubcategory": "Ištrinti „{{name}}“? Joje esantys modeliai taps nepriskirti.",
|
||||
"addDetailsTooltip": "Pridėti informaciją",
|
||||
"editDetailsTooltip": "Redaguoti informaciją",
|
||||
@@ -1458,8 +1458,7 @@
|
||||
"printablesCatIdLabel": "Printables kategorijų ID",
|
||||
"catIdsPlaceholder": "pvz. 800;71;1001",
|
||||
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų",
|
||||
"dragToMove": "Vilkite, kad perkeltumėte",
|
||||
"dropHere": "Numeskite čia"
|
||||
"dragToMove": "Vilkite, kad perkeltumėte"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import TagInput from "../../components/TagInput";
|
||||
import { translateCategoryDisplay } from "../../utils/translateCategoryDisplay";
|
||||
import { buildCategoryTree, flattenCategoryTree } from "../../utils/categoryTree";
|
||||
|
||||
type ImageItem =
|
||||
{ kind: "existing"; id: string; url: string } | { kind: "new"; localId: string; file: File; previewUrl: string };
|
||||
@@ -107,24 +108,7 @@ export default function EditModelModal({ print, onClose, onUnauthorized, onUpdat
|
||||
|
||||
const markDirty = () => setDirty(true);
|
||||
|
||||
const { roots, childrenByParent } = useMemo(() => {
|
||||
const list = categories ?? [];
|
||||
const childrenMap: Record<string, Category[]> = {};
|
||||
const rootList: Category[] = [];
|
||||
for (const c of list) {
|
||||
if (c.parent_id) {
|
||||
if (!childrenMap[c.parent_id]) childrenMap[c.parent_id] = [];
|
||||
childrenMap[c.parent_id].push(c);
|
||||
} else {
|
||||
rootList.push(c);
|
||||
}
|
||||
}
|
||||
const byPosition = (a: Category, b: Category) => a.position - b.position || a.name.localeCompare(b.name);
|
||||
Object.keys(childrenMap).forEach((key) => {
|
||||
childrenMap[key] = childrenMap[key].toSorted(byPosition);
|
||||
});
|
||||
return { roots: rootList.toSorted(byPosition), childrenByParent: childrenMap };
|
||||
}, [categories]);
|
||||
const flatCategories = useMemo(() => flattenCategoryTree(buildCategoryTree(categories ?? [])), [categories]);
|
||||
const categoryName = (c: Category) => translateCategoryDisplay(c, i18n).name;
|
||||
|
||||
const onAddImages = (fileList: FileList | null) => {
|
||||
@@ -323,19 +307,17 @@ export default function EditModelModal({ print, onClose, onUnauthorized, onUpdat
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">{t("models:edit.noCategory")}</MenuItem>
|
||||
{roots.flatMap((root) =>
|
||||
[
|
||||
// A disabled MenuItem, not ListSubheader: MUI's Select still handles clicks on the latter and
|
||||
// gets stuck open.
|
||||
<MenuItem key={`h-${root.id}`} disabled divider sx={{ fontWeight: 700, opacity: "1 !important" }}>
|
||||
{categoryName(root)}
|
||||
</MenuItem>,
|
||||
].concat(
|
||||
(childrenByParent[root.id] ?? []).map((child) => (
|
||||
<MenuItem key={child.id} value={child.id} sx={{ pl: 3 }}>
|
||||
{categoryName(child)}
|
||||
</MenuItem>
|
||||
)),
|
||||
{flatCategories.map(({ category, depth }) =>
|
||||
depth === 0 ? (
|
||||
// Top-level categories are headings. A disabled MenuItem, not ListSubheader: MUI's Select
|
||||
// still handles clicks on the latter and gets stuck open.
|
||||
<MenuItem key={category.id} disabled divider sx={{ fontWeight: 700, opacity: "1 !important" }}>
|
||||
{categoryName(category)}
|
||||
</MenuItem>
|
||||
) : (
|
||||
<MenuItem key={category.id} value={category.id} sx={{ pl: 1 + depth * 2 }}>
|
||||
{categoryName(category)}
|
||||
</MenuItem>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import Paper from "@mui/material/Paper";
|
||||
@@ -16,6 +16,7 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import type { Category, CategoryMetaInput } from "../../api/categories";
|
||||
import { translateCategoryDisplay } from "../../utils/translateCategoryDisplay";
|
||||
import { dividerBorderColor } from "../../theme";
|
||||
import { ancestorPath, buildCategoryTree } from "../../utils/categoryTree";
|
||||
import CategoryManagerModal from "./CategoryManagerModal";
|
||||
|
||||
type Props = {
|
||||
@@ -58,7 +59,7 @@ function rowTextSx(active: boolean, extra?: object) {
|
||||
};
|
||||
}
|
||||
|
||||
/** A two-level tree: a root selects all its subcategories' models; one root is expanded at a time. */
|
||||
/** Any depth; a category selects the models at every level beneath it. Only the selected path is open. */
|
||||
export default function CategoriesPanel({
|
||||
categories,
|
||||
loading,
|
||||
@@ -72,45 +73,58 @@ export default function CategoriesPanel({
|
||||
onUpdateMeta,
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation(["models", "common"]);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [managerOpen, setManagerOpen] = useState(false);
|
||||
const displayName = (category: Category) => translateCategoryDisplay(category, i18n).name;
|
||||
|
||||
const untitledLabel = t("models:categories.untitled");
|
||||
|
||||
// Expand the selected child's root when the selection comes from outside (URL, back/forward), or
|
||||
// the child never renders.
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
const selected = categories.find((c) => c.id === selectedId);
|
||||
if (!selected) return;
|
||||
setExpandedId(selected.parent_id || selected.id);
|
||||
}, [selectedId, categories]);
|
||||
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
|
||||
// The selected category and its ancestors are open, so a selection from the URL is always visible.
|
||||
const openIds = useMemo(
|
||||
() => new Set(selectedId && tree.byId.has(selectedId) ? ancestorPath(tree, selectedId) : []),
|
||||
[tree, selectedId],
|
||||
);
|
||||
|
||||
const { roots, childrenByParent } = useMemo(() => {
|
||||
const childrenMap: Record<string, Category[]> = {};
|
||||
const rootList: Category[] = [];
|
||||
categories.forEach((f) => {
|
||||
if (f.parent_id) {
|
||||
if (!childrenMap[f.parent_id]) childrenMap[f.parent_id] = [];
|
||||
childrenMap[f.parent_id].push(f);
|
||||
} else {
|
||||
rootList.push(f);
|
||||
}
|
||||
});
|
||||
const byPosition = (a: Category, b: Category) => a.position - b.position || a.name.localeCompare(b.name);
|
||||
Object.keys(childrenMap).forEach((key) => {
|
||||
childrenMap[key] = childrenMap[key].toSorted(byPosition);
|
||||
});
|
||||
return {
|
||||
roots: rootList.toSorted(byPosition),
|
||||
childrenByParent: childrenMap,
|
||||
};
|
||||
}, [categories]);
|
||||
|
||||
const handleRootClick = (id: string) => {
|
||||
setExpandedId(id);
|
||||
onSelect(id);
|
||||
const renderCategory = (category: Category, depth: number) => {
|
||||
const children = tree.childrenByParent[category.id] ?? [];
|
||||
const isRoot = depth === 0;
|
||||
const isOpen = openIds.has(category.id);
|
||||
const isSelected = selectedId === category.id;
|
||||
return (
|
||||
<Stack key={category.id}>
|
||||
<ListItemButton onClick={() => onSelect(category.id)} sx={{ pl: 1 + depth * 2, ...rowSx(isSelected) }}>
|
||||
<ListItemText
|
||||
primary={displayName(category) || untitledLabel}
|
||||
primaryTypographyProps={rowTextSx(
|
||||
isSelected,
|
||||
isRoot ? { fontWeight: 600 } : isSelected ? { fontWeight: 700 } : undefined,
|
||||
)}
|
||||
/>
|
||||
{(isRoot || children.length > 0) && (
|
||||
<ChevronRightIcon
|
||||
fontSize="small"
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
flexShrink: 0,
|
||||
transform: isOpen ? "rotate(90deg)" : "none",
|
||||
transition: "transform 0.15s",
|
||||
color: (theme) => (isSelected ? theme.thingport.selectedNavText : theme.thingport.navInactiveText),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{children.map((child) => renderCategory(child, depth + 1))}
|
||||
{isRoot && !children.length && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ pl: 4, display: "block", py: 0.5 }}>
|
||||
{t("models:categories.noSubcategories")}
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -156,62 +170,9 @@ export default function CategoriesPanel({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
roots.map((root) => {
|
||||
const children = childrenByParent[root.id] || [];
|
||||
const isOpen = expandedId === root.id;
|
||||
const isRootActive = isOpen && selectedId === root.id;
|
||||
return (
|
||||
<Stack key={root.id}>
|
||||
<ListItemButton onClick={() => handleRootClick(root.id)} sx={rowSx(isRootActive)}>
|
||||
<ListItemText
|
||||
primary={displayName(root) || untitledLabel}
|
||||
primaryTypographyProps={rowTextSx(isRootActive, { fontWeight: 600 })}
|
||||
/>
|
||||
<ChevronRightIcon
|
||||
fontSize="small"
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
flexShrink: 0,
|
||||
transform: isOpen ? "rotate(90deg)" : "none",
|
||||
transition: "transform 0.15s",
|
||||
color: (theme) =>
|
||||
isRootActive ? theme.thingport.selectedNavText : theme.thingport.navInactiveText,
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{children.map((child) => {
|
||||
const isChildSelected = selectedId === child.id;
|
||||
return (
|
||||
<ListItemButton
|
||||
key={child.id}
|
||||
onClick={() => onSelect(child.id)}
|
||||
sx={{ pl: 4, ...rowSx(isChildSelected) }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={displayName(child) || untitledLabel}
|
||||
primaryTypographyProps={rowTextSx(
|
||||
isChildSelected,
|
||||
isChildSelected ? { fontWeight: 700 } : undefined,
|
||||
)}
|
||||
/>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
{!children.length && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ pl: 4, display: "block", py: 0.5 }}>
|
||||
{t("models:categories.noSubcategories")}
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
{!loading && tree.roots.map((root) => renderCategory(root, 0))}
|
||||
|
||||
{!loading && !roots.length && (
|
||||
{!loading && !tree.roots.length && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
|
||||
{t("models:categories.empty")}
|
||||
</Typography>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Fragment, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
@@ -14,6 +14,7 @@ import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
@@ -24,20 +25,19 @@ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
closestCorners,
|
||||
useDroppable,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
@@ -49,8 +49,13 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { Category, CategoryMetaInput } from "../../api/categories";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import { buildCategoryTree, flattenCategoryTree } from "../../utils/categoryTree";
|
||||
import CategoryMetaDialog from "./CategoryMetaDialog";
|
||||
|
||||
// One nesting level. Also how far a row must be dragged sideways to change its level.
|
||||
const INDENT_PX = 24;
|
||||
const ACTION_BUTTON_PX = 30;
|
||||
|
||||
type Props = {
|
||||
categories: Category[];
|
||||
onClose: () => void;
|
||||
@@ -72,40 +77,67 @@ function hasMeta(category: Category): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Subcategory ids per top-level category id, in display order. */
|
||||
type Containers = Record<string, string[]>;
|
||||
type FlatItem = { id: string; parentId: string | null; depth: number };
|
||||
type Projection = { depth: number; parentId: string | null };
|
||||
|
||||
/**
|
||||
* Where the dragged row would land: its vertical position comes from the row it's over, its level
|
||||
* from how far it was dragged sideways, kept within what the neighbours allow. Null when there's
|
||||
* no valid level, e.g. above the first top-level category, since dragging never makes a top-level one.
|
||||
*/
|
||||
function getProjection(items: FlatItem[], activeId: string, overId: string, offsetX: number): Projection | null {
|
||||
const activeIndex = items.findIndex((i) => i.id === activeId);
|
||||
const overIndex = items.findIndex((i) => i.id === overId);
|
||||
if (activeIndex === -1 || overIndex === -1) return null;
|
||||
const newItems = arrayMove(items, activeIndex, overIndex);
|
||||
const previous = newItems[overIndex - 1];
|
||||
const next = newItems[overIndex + 1];
|
||||
const maxDepth = previous ? previous.depth + 1 : 0;
|
||||
// Can't slip in above `next` at a shallower level than it: that would steal it as a child.
|
||||
const minDepth = Math.max(next ? next.depth : 0, 1);
|
||||
if (!previous || maxDepth < minDepth) return null;
|
||||
const wanted = items[activeIndex].depth + Math.round(offsetX / INDENT_PX);
|
||||
const depth = Math.min(Math.max(wanted, minDepth), maxDepth);
|
||||
|
||||
let parentId: string | null;
|
||||
if (depth === previous.depth) parentId = previous.parentId;
|
||||
else if (depth > previous.depth) parentId = previous.id;
|
||||
else parentId = newItems.slice(0, overIndex).findLast((i) => i.depth === depth)?.parentId ?? null;
|
||||
return { depth, parentId };
|
||||
}
|
||||
|
||||
type MoveButtons = { canMoveUp: boolean; canMoveDown: boolean; onMoveUp: () => void; onMoveDown: () => void };
|
||||
|
||||
/** What a sortable row needs from useSortable. */
|
||||
type SortableBinding = { setNodeRef: (node: HTMLElement | null) => void; style: CSSProperties; handle: ReactNode };
|
||||
|
||||
function CategoryRow({
|
||||
name,
|
||||
indent,
|
||||
bold,
|
||||
depth,
|
||||
busy,
|
||||
move,
|
||||
sortable,
|
||||
dragHandle,
|
||||
nodeRef,
|
||||
style,
|
||||
metaTitle,
|
||||
metaDescription,
|
||||
hasDetails,
|
||||
onOpenMeta,
|
||||
onAddChild,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: {
|
||||
name: string;
|
||||
indent: number;
|
||||
bold?: boolean;
|
||||
depth: number;
|
||||
busy: boolean;
|
||||
/** Up/down arrows; top-level categories only. */
|
||||
move?: MoveButtons;
|
||||
/** Subcategories are dragged instead. */
|
||||
sortable?: SortableBinding;
|
||||
dragHandle?: ReactNode;
|
||||
nodeRef: (node: HTMLElement | null) => void;
|
||||
style: CSSProperties;
|
||||
metaTitle: string | null;
|
||||
metaDescription: string | null;
|
||||
hasDetails: boolean;
|
||||
onOpenMeta: () => void;
|
||||
onAddChild: () => void;
|
||||
onRename: (name: string) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
@@ -113,6 +145,7 @@ function CategoryRow({
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(name);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const indent = `${depth * INDENT_PX}px`;
|
||||
|
||||
const startEdit = () => {
|
||||
setValue(name);
|
||||
@@ -141,7 +174,7 @@ function CategoryRow({
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<ListItem ref={sortable?.setNodeRef} style={sortable?.style} disableGutters sx={{ pl: indent, py: 0.5 }}>
|
||||
<ListItem ref={nodeRef} style={style} disableGutters sx={{ pl: indent, py: 0.5 }}>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ width: "100%" }}>
|
||||
<TextField
|
||||
size="small"
|
||||
@@ -166,10 +199,11 @@ function CategoryRow({
|
||||
);
|
||||
}
|
||||
|
||||
const actionCount = move ? 6 : 4;
|
||||
return (
|
||||
<ListItem
|
||||
ref={sortable?.setNodeRef}
|
||||
style={sortable?.style}
|
||||
ref={nodeRef}
|
||||
style={style}
|
||||
disableGutters
|
||||
sx={{ pl: indent, py: 0.5 }}
|
||||
secondaryAction={
|
||||
@@ -194,6 +228,18 @@ function CategoryRow({
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<Tooltip title={t("models:categories.manager.addSubcategory")}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onAddChild}
|
||||
disabled={busy}
|
||||
aria-label={t("models:categories.manager.addSubcategory") ?? undefined}
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={
|
||||
hasDetails ? (
|
||||
@@ -244,8 +290,13 @@ function CategoryRow({
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{sortable?.handle}
|
||||
<Typography variant="body2" fontWeight={bold ? 600 : 400} noWrap sx={{ pr: move ? 19 : 12 }}>
|
||||
{dragHandle}
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={depth === 0 ? 600 : 400}
|
||||
noWrap
|
||||
sx={{ pr: `${actionCount * ACTION_BUTTON_PX + 8}px` }}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
@@ -280,72 +331,81 @@ function DragHandle({
|
||||
);
|
||||
}
|
||||
|
||||
function SortableSubcategoryRow({
|
||||
/** Every row is in the sortable list so the others can be placed around it; only subcategories drag. */
|
||||
function TreeRow({
|
||||
id,
|
||||
depth,
|
||||
...rowProps
|
||||
}: { id: string } & Omit<Parameters<typeof CategoryRow>[0], "sortable" | "move">) {
|
||||
}: { id: string; depth: number } & Omit<Parameters<typeof CategoryRow>[0], "nodeRef" | "style" | "dragHandle">) {
|
||||
const isRoot = depth === 0;
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
disabled: isRoot,
|
||||
});
|
||||
return (
|
||||
<CategoryRow
|
||||
{...rowProps}
|
||||
sortable={{
|
||||
setNodeRef,
|
||||
// The DragOverlay shows the dragged row; this one stays as a faint placeholder.
|
||||
style: { transform: CSS.Translate.toString(transform), transition, opacity: isDragging ? 0.35 : 1 },
|
||||
handle: <DragHandle setActivatorNodeRef={setActivatorNodeRef} attributes={attributes} listeners={listeners} />,
|
||||
}}
|
||||
depth={depth}
|
||||
nodeRef={setNodeRef}
|
||||
// The DragOverlay shows the dragged row; this one stays as a faint placeholder at the projected level.
|
||||
style={{ transform: CSS.Translate.toString(transform), transition, opacity: isDragging ? 0.35 : 1 }}
|
||||
dragHandle={
|
||||
isRoot ? undefined : (
|
||||
<DragHandle setActivatorNodeRef={setActivatorNodeRef} attributes={attributes} listeners={listeners} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A drop target even when empty, so subcategories can be dragged into a category without any. */
|
||||
function SubcategoryList({
|
||||
parentId,
|
||||
ids,
|
||||
dragging,
|
||||
children,
|
||||
/** Opened from a row's + button; sits after that category's existing subcategories. */
|
||||
function InlineAddRow({
|
||||
depth,
|
||||
busy,
|
||||
onAdd,
|
||||
onCancel,
|
||||
}: {
|
||||
parentId: string;
|
||||
ids: string[];
|
||||
dragging: boolean;
|
||||
children: ReactNode;
|
||||
depth: number;
|
||||
busy: boolean;
|
||||
onAdd: (name: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("models");
|
||||
const { setNodeRef } = useDroppable({ id: parentId });
|
||||
const [value, setValue] = useState("");
|
||||
const submit = async () => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) await onAdd(trimmed);
|
||||
};
|
||||
return (
|
||||
<SortableContext id={parentId} items={ids} strategy={verticalListSortingStrategy}>
|
||||
<List ref={setNodeRef} disablePadding>
|
||||
{children}
|
||||
{dragging && ids.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
ml: 3,
|
||||
py: 0.75,
|
||||
border: "1px dashed",
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("categories.manager.dropHere")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</List>
|
||||
</SortableContext>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ pl: `${depth * INDENT_PX}px`, py: 0.5 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder={t("categories.manager.addSubcategory")}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton size="small" onClick={submit} disabled={busy || !value.trim()}>
|
||||
{busy ? <CircularProgress size={16} /> : <CheckIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onCancel} disabled={busy}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AddRow({
|
||||
indent,
|
||||
placeholder,
|
||||
busy,
|
||||
onAdd,
|
||||
}: {
|
||||
indent: number;
|
||||
placeholder: string;
|
||||
busy: boolean;
|
||||
onAdd: (name: string) => Promise<void>;
|
||||
@@ -371,7 +431,6 @@ function AddRow({
|
||||
setAdding(true);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
sx={{ ml: `${indent * 8}px` }}
|
||||
>
|
||||
{placeholder}
|
||||
</Button>
|
||||
@@ -379,7 +438,7 @@ function AddRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ pl: `${indent * 8}px`, py: 0.5 }}>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ py: 0.5 }}>
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
size="small"
|
||||
@@ -426,86 +485,29 @@ export default function CategoryManagerModal({
|
||||
const confirmDialog = useConfirm();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [metaCategory, setMetaCategory] = useState<Category | null>(null);
|
||||
const [addingUnderId, setAddingUnderId] = useState<string | null>(null);
|
||||
|
||||
const { roots, childrenByParent } = useMemo(() => {
|
||||
const childrenMap: Record<string, Category[]> = {};
|
||||
const rootList: Category[] = [];
|
||||
categories.forEach((f) => {
|
||||
if (f.parent_id) {
|
||||
if (!childrenMap[f.parent_id]) childrenMap[f.parent_id] = [];
|
||||
childrenMap[f.parent_id].push(f);
|
||||
} else {
|
||||
rootList.push(f);
|
||||
}
|
||||
});
|
||||
const byPosition = (a: Category, b: Category) => a.position - b.position || a.name.localeCompare(b.name);
|
||||
Object.keys(childrenMap).forEach((key) => {
|
||||
childrenMap[key] = childrenMap[key].toSorted(byPosition);
|
||||
});
|
||||
return {
|
||||
roots: rootList.toSorted(byPosition),
|
||||
childrenByParent: childrenMap,
|
||||
};
|
||||
}, [categories]);
|
||||
// A drop shows right away and stays until `categories` is refetched (which happens on failure too).
|
||||
const [optimistic, setOptimistic] = useState<{ base: Category[]; list: Category[] } | null>(null);
|
||||
const shown = optimistic?.base === categories ? optimistic.list : categories;
|
||||
const tree = useMemo(() => buildCategoryTree(shown), [shown]);
|
||||
|
||||
const untitledLabel = t("models:categories.untitled");
|
||||
|
||||
const handleDeleteCategory = async (category: Category) => {
|
||||
const hasChildren = (childrenByParent[category.id] || []).length > 0;
|
||||
const message = hasChildren
|
||||
? t("models:categories.manager.confirmDeleteCategoryWithSub", { name: category.name || untitledLabel })
|
||||
: t("models:categories.manager.confirmDeleteCategory", { name: category.name || untitledLabel });
|
||||
if (!(await confirmDialog({ message, destructive: true }))) return;
|
||||
setBusyId(category.id);
|
||||
try {
|
||||
await onDelete(category.id);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSubcategory = async (category: Category) => {
|
||||
const message = t("models:categories.manager.confirmDeleteSubcategory", { name: category.name || untitledLabel });
|
||||
if (!(await confirmDialog({ message, destructive: true }))) return;
|
||||
setBusyId(category.id);
|
||||
try {
|
||||
await onDelete(category.id);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const swapAndReorder = async (siblingIds: string[], index: number, direction: -1 | 1, busyKey: string) => {
|
||||
const swapIndex = index + direction;
|
||||
if (swapIndex < 0 || swapIndex >= siblingIds.length) return;
|
||||
const reordered = siblingIds.slice();
|
||||
[reordered[index], reordered[swapIndex]] = [reordered[swapIndex], reordered[index]];
|
||||
setBusyId(busyKey);
|
||||
try {
|
||||
await onReorder(reordered);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const moveRoot = (index: number, direction: -1 | 1) =>
|
||||
swapAndReorder(
|
||||
roots.map((r) => r.id),
|
||||
index,
|
||||
direction,
|
||||
roots[index].id,
|
||||
);
|
||||
|
||||
const categoryById = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
|
||||
const propContainers = useMemo<Containers>(
|
||||
() => Object.fromEntries(roots.map((root) => [root.id, (childrenByParent[root.id] || []).map((c) => c.id)])),
|
||||
[roots, childrenByParent],
|
||||
);
|
||||
// The order mid-drag and after a drop, until `categories` is refetched (which happens on failure too).
|
||||
const [optimistic, setOptimistic] = useState<{ base: Category[]; containers: Containers } | null>(null);
|
||||
const containers = optimistic?.base === categories ? optimistic.containers : propContainers;
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const dragOrigin = useRef<{ parentId: string; ids: string[] } | null>(null);
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
|
||||
// While dragging, the dragged category's subtree is folded into it.
|
||||
const flat = useMemo(() => flattenCategoryTree(tree, activeId ? new Set([activeId]) : undefined), [tree, activeId]);
|
||||
const items = useMemo<FlatItem[]>(
|
||||
() =>
|
||||
flat.map(({ category, depth }) => ({
|
||||
id: category.id,
|
||||
parentId: category.parent_id && tree.byId.has(category.parent_id) ? category.parent_id : null,
|
||||
depth,
|
||||
})),
|
||||
[flat, tree],
|
||||
);
|
||||
const projection = activeId && overId ? getProjection(items, activeId, overId, offsetX) : null;
|
||||
|
||||
const sensors = useSensors(
|
||||
// A few pixels of travel before dragging, so clicks on the handle don't start one.
|
||||
@@ -513,80 +515,103 @@ export default function CategoryManagerModal({
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
/** Over ids are either a subcategory or a (possibly empty) top-level category's list. */
|
||||
const containerOf = (id: UniqueIdentifier): string | null => {
|
||||
const key = String(id);
|
||||
if (key in containers) return key;
|
||||
return Object.keys(containers).find((parentId) => containers[parentId].includes(key)) ?? null;
|
||||
};
|
||||
const untitledLabel = t("models:categories.untitled");
|
||||
|
||||
const handleDragStart = ({ active }: DragStartEvent) => {
|
||||
const parentId = containerOf(active.id);
|
||||
if (!parentId) return;
|
||||
setActiveId(String(active.id));
|
||||
dragOrigin.current = { parentId, ids: containers[parentId] };
|
||||
setOptimistic({ base: categories, containers });
|
||||
};
|
||||
|
||||
// Crossing into another category moves the row there right away, so that list opens a gap for it.
|
||||
const handleDragOver = ({ active, over }: DragOverEvent) => {
|
||||
if (!over) return;
|
||||
const from = containerOf(active.id);
|
||||
const to = containerOf(over.id);
|
||||
if (!from || !to || from === to) return;
|
||||
const id = String(active.id);
|
||||
const target = containers[to];
|
||||
const overIndex = target.indexOf(String(over.id));
|
||||
const insertAt = overIndex === -1 ? target.length : overIndex;
|
||||
setOptimistic({
|
||||
base: categories,
|
||||
containers: {
|
||||
...containers,
|
||||
[from]: containers[from].filter((c) => c !== id),
|
||||
[to]: [...target.slice(0, insertAt), id, ...target.slice(insertAt)],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resetDrag = () => {
|
||||
setActiveId(null);
|
||||
dragOrigin.current = null;
|
||||
};
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setOptimistic(null);
|
||||
resetDrag();
|
||||
};
|
||||
|
||||
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
|
||||
const origin = dragOrigin.current;
|
||||
const to = over ? containerOf(over.id) : null;
|
||||
resetDrag();
|
||||
if (!origin || !to) {
|
||||
setOptimistic(null);
|
||||
return;
|
||||
}
|
||||
const id = String(active.id);
|
||||
const fromIndex = containers[to].indexOf(id);
|
||||
const overIndex = containers[to].indexOf(String(over!.id));
|
||||
const ids = overIndex === -1 ? containers[to] : arrayMove(containers[to], fromIndex, overIndex);
|
||||
setOptimistic({ base: categories, containers: { ...containers, [to]: ids } });
|
||||
|
||||
setBusyId(id);
|
||||
const handleDelete = async (category: Category) => {
|
||||
const name = category.name || untitledLabel;
|
||||
const message = tree.childrenByParent[category.id]?.length
|
||||
? t("models:categories.manager.confirmDeleteCategoryWithSub", { name })
|
||||
: t("models:categories.manager.confirmDeleteCategory", { name });
|
||||
if (!(await confirmDialog({ message, destructive: true }))) return;
|
||||
setBusyId(category.id);
|
||||
try {
|
||||
if (to !== origin.parentId) {
|
||||
await onMove(id, to, ids.indexOf(id));
|
||||
} else if (ids.some((c, i) => c !== origin.ids[i])) {
|
||||
await onReorder(ids);
|
||||
} else {
|
||||
setOptimistic(null);
|
||||
}
|
||||
await onDelete(category.id);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const activeCategory = activeId ? categoryById.get(activeId) : undefined;
|
||||
const moveRoot = async (index: number, direction: -1 | 1) => {
|
||||
const ids = tree.roots.map((r) => r.id);
|
||||
const swapIndex = index + direction;
|
||||
if (swapIndex < 0 || swapIndex >= ids.length) return;
|
||||
[ids[index], ids[swapIndex]] = [ids[swapIndex], ids[index]];
|
||||
setBusyId(ids[swapIndex]);
|
||||
try {
|
||||
await onReorder(ids);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const resetDrag = () => {
|
||||
setActiveId(null);
|
||||
setOverId(null);
|
||||
setOffsetX(0);
|
||||
};
|
||||
|
||||
const handleDragStart = ({ active }: DragStartEvent) => {
|
||||
setActiveId(String(active.id));
|
||||
setOverId(String(active.id));
|
||||
setOffsetX(0);
|
||||
setAddingUnderId(null);
|
||||
};
|
||||
|
||||
const handleDragMove = ({ delta }: DragMoveEvent) => setOffsetX(delta.x);
|
||||
|
||||
const handleDragOver = ({ over }: DragOverEvent) => setOverId(over ? String(over.id) : null);
|
||||
|
||||
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
|
||||
const target = projection;
|
||||
resetDrag();
|
||||
if (!target || !over) return;
|
||||
const id = String(active.id);
|
||||
const moved = arrayMove(
|
||||
items,
|
||||
items.findIndex((i) => i.id === id),
|
||||
items.findIndex((i) => i.id === over.id),
|
||||
);
|
||||
const siblingIds = moved
|
||||
.filter((i) => (i.id === id ? target.parentId : i.parentId) === target.parentId)
|
||||
.map((i) => i.id);
|
||||
const position = siblingIds.indexOf(id);
|
||||
|
||||
const original = tree.byId.get(id);
|
||||
const originalParentId = original?.parent_id ?? null;
|
||||
const sameParent = originalParentId === target.parentId;
|
||||
const originalSiblingIds = (originalParentId ? tree.childrenByParent[originalParentId] : tree.roots)?.map(
|
||||
(c) => c.id,
|
||||
);
|
||||
if (sameParent && siblingIds.every((c, i) => c === originalSiblingIds?.[i])) return;
|
||||
|
||||
const list: Category[] = [];
|
||||
for (const c of shown) {
|
||||
const idx = siblingIds.indexOf(c.id);
|
||||
if (c.id === id) list.push({ ...c, parent_id: target.parentId, position });
|
||||
else list.push(idx === -1 ? c : { ...c, position: idx });
|
||||
}
|
||||
setOptimistic({ base: categories, list });
|
||||
setBusyId(id);
|
||||
try {
|
||||
if (sameParent) await onReorder(siblingIds);
|
||||
else await onMove(id, target.parentId, position);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// The add-subcategory input goes after the category's last descendant.
|
||||
const addAfterId = useMemo(() => {
|
||||
if (!addingUnderId) return null;
|
||||
const start = flat.findIndex((f) => f.category.id === addingUnderId);
|
||||
if (start === -1) return null;
|
||||
let end = start;
|
||||
while (flat[end + 1] && flat[end + 1].depth > flat[start].depth) end += 1;
|
||||
return flat[end].category.id;
|
||||
}, [flat, addingUnderId]);
|
||||
const addingDepth = addingUnderId ? (flat.find((f) => f.category.id === addingUnderId)?.depth ?? 0) + 1 : 0;
|
||||
|
||||
const activeCategory = activeId ? tree.byId.get(activeId) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -600,81 +625,72 @@ export default function CategoryManagerModal({
|
||||
<DialogContent dividers>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
collisionDetection={closestCenter}
|
||||
// Folding the dragged subtree shifts the rows below, so keep re-measuring.
|
||||
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragCancel={resetDrag}
|
||||
>
|
||||
<List disablePadding>
|
||||
{roots.map((root, rootIndex) => {
|
||||
const childIds = containers[root.id] || [];
|
||||
return (
|
||||
<Box key={root.id} sx={{ mb: 1.5 }}>
|
||||
<CategoryRow
|
||||
name={root.name || untitledLabel}
|
||||
indent={0}
|
||||
bold
|
||||
busy={busyId === root.id}
|
||||
move={{
|
||||
canMoveUp: rootIndex > 0,
|
||||
canMoveDown: rootIndex < roots.length - 1,
|
||||
onMoveUp: () => moveRoot(rootIndex, -1),
|
||||
onMoveDown: () => moveRoot(rootIndex, 1),
|
||||
}}
|
||||
metaTitle={root.meta_title}
|
||||
metaDescription={root.meta_description}
|
||||
hasDetails={hasMeta(root)}
|
||||
onOpenMeta={() => setMetaCategory(root)}
|
||||
onRename={(name) => onRename(root.id, name)}
|
||||
onDelete={() => handleDeleteCategory(root)}
|
||||
/>
|
||||
<SubcategoryList parentId={root.id} ids={childIds} dragging={activeId !== null}>
|
||||
{childIds.map((childId) => {
|
||||
const child = categoryById.get(childId);
|
||||
if (!child) return null;
|
||||
return (
|
||||
<SortableSubcategoryRow
|
||||
key={child.id}
|
||||
id={child.id}
|
||||
name={child.name || untitledLabel}
|
||||
indent={3}
|
||||
busy={busyId === child.id}
|
||||
metaTitle={child.meta_title}
|
||||
metaDescription={child.meta_description}
|
||||
hasDetails={hasMeta(child)}
|
||||
onOpenMeta={() => setMetaCategory(child)}
|
||||
onRename={(name) => onRename(child.id, name)}
|
||||
onDelete={() => handleDeleteSubcategory(child)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SubcategoryList>
|
||||
<Box sx={{ pl: 0.5 }}>
|
||||
<AddRow
|
||||
indent={3}
|
||||
placeholder={t("models:categories.manager.addSubcategory")}
|
||||
busy={busyId === `new-sub-${root.id}`}
|
||||
onAdd={async (name) => {
|
||||
setBusyId(`new-sub-${root.id}`);
|
||||
try {
|
||||
await onCreate(name, root.id);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}}
|
||||
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||
<List disablePadding>
|
||||
{flat.map(({ category, depth }) => {
|
||||
const rootIndex = depth === 0 ? tree.roots.findIndex((r) => r.id === category.id) : -1;
|
||||
return (
|
||||
<Fragment key={category.id}>
|
||||
{rootIndex > 0 && <Box sx={{ height: 12 }} />}
|
||||
<TreeRow
|
||||
id={category.id}
|
||||
depth={category.id === activeId && projection ? projection.depth : depth}
|
||||
name={category.name || untitledLabel}
|
||||
busy={busyId === category.id}
|
||||
move={
|
||||
depth === 0
|
||||
? {
|
||||
canMoveUp: rootIndex > 0,
|
||||
canMoveDown: rootIndex < tree.roots.length - 1,
|
||||
onMoveUp: () => moveRoot(rootIndex, -1),
|
||||
onMoveDown: () => moveRoot(rootIndex, 1),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
metaTitle={category.meta_title}
|
||||
metaDescription={category.meta_description}
|
||||
hasDetails={hasMeta(category)}
|
||||
onOpenMeta={() => setMetaCategory(category)}
|
||||
onAddChild={() => setAddingUnderId(category.id)}
|
||||
onRename={(name) => onRename(category.id, name)}
|
||||
onDelete={() => handleDelete(category)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{addAfterId === category.id && addingUnderId && (
|
||||
<InlineAddRow
|
||||
depth={addingDepth}
|
||||
busy={busyId === `new-sub-${addingUnderId}`}
|
||||
onCancel={() => setAddingUnderId(null)}
|
||||
onAdd={async (name) => {
|
||||
setBusyId(`new-sub-${addingUnderId}`);
|
||||
try {
|
||||
await onCreate(name, addingUnderId);
|
||||
setAddingUnderId(null);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{!roots.length && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
{t("models:categories.manager.noCategories")}
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
{!tree.roots.length && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
{t("models:categories.manager.noCategories")}
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeCategory && (
|
||||
<Paper
|
||||
@@ -693,7 +709,6 @@ export default function CategoryManagerModal({
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<AddRow
|
||||
indent={0}
|
||||
placeholder={t("models:categories.manager.addCategory")}
|
||||
busy={busyId === "new-root"}
|
||||
onAdd={async (name) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { type PreviewMode } from "../../api/settings";
|
||||
import type { AuthUser } from "../../api/auth";
|
||||
import { type ResolvedTheme } from "../../constants/settingsOptions";
|
||||
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
|
||||
import { buildCategoryTree, subtreeIds } from "../../utils/categoryTree";
|
||||
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
|
||||
import CategoriesPanel from "./CategoriesPanel";
|
||||
import CategoryBanner from "./CategoryBanner";
|
||||
@@ -95,13 +96,11 @@ export default function ModelsPage({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [categoryId]);
|
||||
|
||||
// A root category includes the models in all its subcategories.
|
||||
// A category includes the models at every level beneath it.
|
||||
const categoryIdFilter = useMemo(() => {
|
||||
if (!categoryId) return undefined;
|
||||
const isRoot = categories.some((f) => f.id === categoryId && !f.parent_id);
|
||||
if (!isRoot) return categoryId;
|
||||
const childIds = categories.filter((f) => f.parent_id === categoryId).map((f) => f.id);
|
||||
return [categoryId, ...childIds];
|
||||
const ids = subtreeIds(buildCategoryTree(categories), categoryId);
|
||||
return ids.length === 1 ? categoryId : ids;
|
||||
}, [categoryId, categories]);
|
||||
|
||||
const selectedCategory = categoryId ? (categories.find((f) => f.id === categoryId) ?? null) : null;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Category } from "../api/categories";
|
||||
|
||||
export type CategoryTree = {
|
||||
roots: Category[];
|
||||
/** Sorted children per parent id; leaves have no entry. */
|
||||
childrenByParent: Record<string, Category[]>;
|
||||
byId: Map<string, Category>;
|
||||
};
|
||||
|
||||
const byPosition = (a: Category, b: Category) => a.position - b.position || a.name.localeCompare(b.name);
|
||||
|
||||
/** A category whose parent is missing is treated as a root, so nothing disappears. */
|
||||
export function buildCategoryTree(categories: Category[]): CategoryTree {
|
||||
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||
const childrenByParent: Record<string, Category[]> = {};
|
||||
const roots: Category[] = [];
|
||||
for (const category of categories) {
|
||||
if (category.parent_id && byId.has(category.parent_id)) {
|
||||
(childrenByParent[category.parent_id] ??= []).push(category);
|
||||
} else {
|
||||
roots.push(category);
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(childrenByParent)) childrenByParent[key].sort(byPosition);
|
||||
return { roots: roots.toSorted(byPosition), childrenByParent, byId };
|
||||
}
|
||||
|
||||
/** The category and everything under it. */
|
||||
export function subtreeIds(tree: CategoryTree, id: string): string[] {
|
||||
const ids: string[] = [];
|
||||
const stack = [id];
|
||||
while (stack.length) {
|
||||
const current = stack.pop()!;
|
||||
ids.push(current);
|
||||
for (const child of tree.childrenByParent[current] ?? []) stack.push(child.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Root first, ending with the category itself. */
|
||||
export function ancestorPath(tree: CategoryTree, id: string): string[] {
|
||||
const path: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = tree.byId.get(id);
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
path.unshift(current.id);
|
||||
current = current.parent_id ? tree.byId.get(current.parent_id) : undefined;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export type FlatCategory = { category: Category; depth: number };
|
||||
|
||||
/** Depth-first display order. Children of ids in `skipChildrenOf` are left out. */
|
||||
export function flattenCategoryTree(tree: CategoryTree, skipChildrenOf?: ReadonlySet<string>): FlatCategory[] {
|
||||
const out: FlatCategory[] = [];
|
||||
const visit = (category: Category, depth: number) => {
|
||||
out.push({ category, depth });
|
||||
if (skipChildrenOf?.has(category.id)) return;
|
||||
for (const child of tree.childrenByParent[category.id] ?? []) visit(child, depth + 1);
|
||||
};
|
||||
for (const root of tree.roots) visit(root, 0);
|
||||
return out;
|
||||
}
|
||||
@@ -98,10 +98,19 @@ export async function uploadEntriesToCategory(
|
||||
const categoryCache = new Map<string, string>();
|
||||
const baseKey = parentCategoryId || "root";
|
||||
|
||||
// Folders reuse a same-named category in the same place, so uploading a tree twice doesn't duplicate it.
|
||||
let existingCategories: Awaited<ReturnType<typeof categoriesApi.list>> | null = null;
|
||||
|
||||
const getOrCreateCategory = async (parentId: string | null, parentKey: string, name: string) => {
|
||||
const key = `${parentKey}/${name}`;
|
||||
const existing = categoryCache.get(key);
|
||||
if (existing) return existing;
|
||||
const cached = categoryCache.get(key);
|
||||
if (cached) return cached;
|
||||
existingCategories ??= await categoriesApi.list();
|
||||
const match = existingCategories.find((c) => (c.parent_id ?? null) === parentId && c.name === name);
|
||||
if (match) {
|
||||
categoryCache.set(key, match.id);
|
||||
return match.id;
|
||||
}
|
||||
const created = await categoriesApi.create(name, [], parentId || undefined);
|
||||
const categoryId = (created as { id: string }).id;
|
||||
categoryCache.set(key, categoryId);
|
||||
|
||||
Reference in new issue
Block a user