mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
feat(categories): allow moving subcategories between parent categories
This commit is contained in:
1 parent
3a05fae555
commit
172270fce8
11 files changed
+746
-195
No files matched your search
@@ -60,6 +60,58 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
const moveSchema = z.object({
|
||||
parent_id: z.string().nullable(),
|
||||
// Index among the new siblings; past the end appends.
|
||||
position: z.number().int().min(0),
|
||||
});
|
||||
|
||||
// Reparents and positions in one step, so dragging a subcategory into another category is one request.
|
||||
router.post(
|
||||
"/category/:id/move",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(moveSchema, req.body);
|
||||
const category = await prisma.category.findFirst({ where: { id: req.params.id, userId: req.userId } });
|
||||
if (!category) throw new HttpError(404, "Not found");
|
||||
const parentId = await validateParentCategory(req.userId!, body.parent_id, category.id);
|
||||
const siblingOrder: Prisma.CategoryOrderByWithRelationInput[] = [{ position: "asc" }, { name: "asc" }];
|
||||
|
||||
const moved = await prisma.$transaction(async (tx) => {
|
||||
const renumber = async (ids: string[]) => {
|
||||
for (const [idx, id] of ids.entries()) {
|
||||
if (id !== category.id) await tx.category.update({ where: { id }, data: { position: idx } });
|
||||
}
|
||||
};
|
||||
|
||||
if (category.parentId !== parentId) {
|
||||
// Close the gap left behind.
|
||||
const oldSiblings = await tx.category.findMany({
|
||||
where: { userId: req.userId, parentId: category.parentId, id: { not: category.id } },
|
||||
orderBy: siblingOrder,
|
||||
select: { id: true },
|
||||
});
|
||||
await renumber(oldSiblings.map((c) => c.id));
|
||||
}
|
||||
const siblings = await tx.category.findMany({
|
||||
where: { userId: req.userId, parentId, id: { not: category.id } },
|
||||
orderBy: siblingOrder,
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = siblings.map((c) => c.id);
|
||||
ids.splice(Math.min(body.position, ids.length), 0, category.id);
|
||||
await renumber(ids);
|
||||
return tx.category.update({
|
||||
where: { id: category.id },
|
||||
data: { parentId, position: ids.indexOf(category.id) },
|
||||
});
|
||||
});
|
||||
|
||||
// Managed storage paths include the category path.
|
||||
if (category.parentId !== parentId) await reorganizeManagedPrints(undefined, req.userId);
|
||||
res.json(toCategoryOut(moved));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/categories",
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
|
||||
const app = createApp();
|
||||
const stamp = Date.now();
|
||||
let token: string;
|
||||
let otherToken: string;
|
||||
|
||||
type CategoryOut = { id: string; name: string; parent_id: string | null; position: number };
|
||||
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
|
||||
async function create(name: string, parentId: string | null = null): Promise<string> {
|
||||
const res = await request(app).post("/api/categories").set(auth(token)).send({ name, parent_id: parentId });
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.id;
|
||||
}
|
||||
|
||||
const move = (id: string, parentId: string | null, position: number, as = token) =>
|
||||
request(app).post(`/api/category/${id}/move`).set(auth(as)).send({ parent_id: parentId, position });
|
||||
|
||||
/** Child names under `parentId`, in display order. */
|
||||
async function childNames(parentId: string): Promise<string[]> {
|
||||
const res = await request(app).get("/api/categories").set(auth(token));
|
||||
return (res.body as CategoryOut[])
|
||||
.filter((c) => c.parent_id === parentId)
|
||||
.toSorted((a, b) => a.position - b.position)
|
||||
.map((c) => c.name);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
token = (
|
||||
await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Mover", email: `mover-${stamp}@example.com`, password: "password123" })
|
||||
).body.token;
|
||||
otherToken = (
|
||||
await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Other", email: `mover-other-${stamp}@example.com`, password: "password123" })
|
||||
).body.token;
|
||||
});
|
||||
|
||||
describe("POST /api/category/:id/move", () => {
|
||||
let rootA: string;
|
||||
let rootB: string;
|
||||
const subs: Record<string, string> = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
rootA = await create(`Root A ${Math.random()}`);
|
||||
rootB = await create(`Root B ${Math.random()}`);
|
||||
for (const name of ["a1", "a2", "a3"]) subs[name] = await create(name, rootA);
|
||||
for (const name of ["b1", "b2"]) subs[name] = await create(name, rootB);
|
||||
// Fresh categories all start at position 0; give them a definite order first.
|
||||
await request(app)
|
||||
.post("/api/categories/reorder")
|
||||
.set(auth(token))
|
||||
.send({ category_ids: [subs.a1, subs.a2, subs.a3] });
|
||||
await request(app)
|
||||
.post("/api/categories/reorder")
|
||||
.set(auth(token))
|
||||
.send({ category_ids: [subs.b1, subs.b2] });
|
||||
});
|
||||
|
||||
it("reorders within the same category", async () => {
|
||||
expect((await move(subs.a3, rootA, 0)).status).toBe(200);
|
||||
expect(await childNames(rootA)).toEqual(["a3", "a1", "a2"]);
|
||||
expect((await move(subs.a3, rootA, 2)).status).toBe(200);
|
||||
expect(await childNames(rootA)).toEqual(["a1", "a2", "a3"]);
|
||||
});
|
||||
|
||||
it("moves into another category at the given position, closing the gap behind", async () => {
|
||||
const res = await move(subs.a2, rootB, 1);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.parent_id).toBe(rootB);
|
||||
expect(await childNames(rootB)).toEqual(["b1", "a2", "b2"]);
|
||||
expect(await childNames(rootA)).toEqual(["a1", "a3"]);
|
||||
});
|
||||
|
||||
it("appends when the position is past the end", async () => {
|
||||
expect((await move(subs.a1, rootB, 99)).status).toBe(200);
|
||||
expect(await childNames(rootB)).toEqual(["b1", "b2", "a1"]);
|
||||
});
|
||||
|
||||
it("moves into an empty category", async () => {
|
||||
const empty = await create(`Empty ${Math.random()}`);
|
||||
expect((await move(subs.a1, empty, 0)).status).toBe(200);
|
||||
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);
|
||||
expect((await move(subs.a1, subs.a1, 0)).status).toBe(400);
|
||||
});
|
||||
|
||||
it("only touches the user's own categories", async () => {
|
||||
expect((await move(subs.a1, rootB, 0, otherToken)).status).toBe(404);
|
||||
const otherRoot = (await request(app).post("/api/categories").set(auth(otherToken)).send({ name: "Other root" }))
|
||||
.body.id;
|
||||
expect((await move(subs.a1, otherRoot, 0)).status).toBe(400);
|
||||
});
|
||||
|
||||
it("validates the body", async () => {
|
||||
expect((await move(subs.a1, rootB, -1)).status).toBe(400);
|
||||
expect(
|
||||
(await request(app).post(`/api/category/${subs.a1}/move`).set(auth(token)).send({ position: 0 })).status,
|
||||
).toBe(400);
|
||||
});
|
||||
});
|
||||
Generated
+62
@@ -9,6 +9,9 @@
|
||||
"version": "0.0.1",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^6.1.0",
|
||||
@@ -172,6 +175,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
@@ -3004,6 +3060,12 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"verify": "npm run typecheck && npm run lint && npm run test:run && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^6.1.0",
|
||||
|
||||
@@ -87,6 +87,18 @@ export const categoriesApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Puts a category under `parentId` (null for top level) at `position` among its new siblings. */
|
||||
move: async (id: string, parentId: string | null, position: number) => {
|
||||
const res = await fetch(`${apiBase()}/category/${id}/move`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ parent_id: parentId, position }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Move category failed"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
downloadZip: async (category_id: string) => {
|
||||
const res = await fetch(`${apiBase()}/category/${category_id}/download`, { headers: authHeaders() });
|
||||
assertOk(res, "Category download failed");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
@@ -14,6 +14,7 @@ import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import SpaceDashboardIcon from "@mui/icons-material/SpaceDashboard";
|
||||
import ViewInArIcon from "@mui/icons-material/ViewInAr";
|
||||
import CollectionsIcon from "@mui/icons-material/Collections";
|
||||
@@ -24,6 +25,24 @@ import DownloadIcon from "@mui/icons-material/Download";
|
||||
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import Wordmark from "../../Wordmark";
|
||||
import { type BookmarkEntry, bookmarksApi } from "../../../api/bookmarks";
|
||||
|
||||
@@ -90,6 +109,49 @@ function bookmarkTarget(entry: BookmarkEntry): { href: string; label: string } {
|
||||
: { href: `/models/collections/${entry.collection_id}`, label: entry.name };
|
||||
}
|
||||
|
||||
// A click that ends a drag (pointer or Space key) must not also navigate.
|
||||
const POST_DRAG_CLICK_GUARD_MS = 250;
|
||||
|
||||
/** The whole row drags; a few pixels of travel tell a drag from a click. */
|
||||
function SortableBookmarkRow({
|
||||
id,
|
||||
label,
|
||||
selected,
|
||||
onNavigate,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onNavigate: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
return (
|
||||
<ListItemButton
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
selected={selected}
|
||||
onClick={onNavigate}
|
||||
style={{ transform: CSS.Translate.toString(transform), transition }}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
cursor: "grab",
|
||||
touchAction: "none",
|
||||
// The DragOverlay shows the dragged row; this one stays as a faint placeholder.
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
...navRowSx(selected),
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 30 }}>
|
||||
<BookmarkIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={label} primaryTypographyProps={{ variant: "body2", noWrap: true }} />
|
||||
<DragIndicatorIcon fontSize="small" sx={{ ml: "auto", flexShrink: 0, color: "text.disabled" }} />
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }: Props) {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const location = useLocation();
|
||||
@@ -100,8 +162,22 @@ export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }:
|
||||
});
|
||||
const [bookmarks, setBookmarks] = useState<BookmarkEntry[]>([]);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
// Tracked on dragover: dragenter/dragleave fire unreliably across child elements.
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const endDrag = () => {
|
||||
setDraggingId(null);
|
||||
suppressClickRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
suppressClickRef.current = false;
|
||||
}, POST_DRAG_CLICK_GUARD_MS);
|
||||
};
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
// Space only: Enter keeps opening the bookmark.
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
keyboardCodes: { start: ["Space"], cancel: ["Escape"], end: ["Space", "Enter"] },
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -120,52 +196,31 @@ export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }:
|
||||
}, [bookmarksVersion]);
|
||||
|
||||
// Optimistic reorder; a failed save refetches the server's order.
|
||||
const handleDrop = (draggedId: string, targetId: string) => {
|
||||
if (draggedId === targetId) return;
|
||||
setBookmarks((prev) => {
|
||||
const from = prev.findIndex((b) => b.id === draggedId);
|
||||
const to = prev.findIndex((b) => b.id === targetId);
|
||||
if (from === -1 || to === -1) return prev;
|
||||
const next = prev.slice();
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
bookmarksApi.reorder(next.map((b) => b.id)).catch(() => {
|
||||
bookmarksApi
|
||||
.list()
|
||||
.then(setBookmarks)
|
||||
.catch(() => {
|
||||
/* leave the optimistic order as-is */
|
||||
});
|
||||
});
|
||||
return next;
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
endDrag();
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = bookmarks.findIndex((b) => b.id === active.id);
|
||||
const to = bookmarks.findIndex((b) => b.id === over.id);
|
||||
if (from === -1 || to === -1) return;
|
||||
const next = arrayMove(bookmarks, from, to);
|
||||
setBookmarks(next);
|
||||
bookmarksApi.reorder(next.map((b) => b.id)).catch(() => {
|
||||
bookmarksApi
|
||||
.list()
|
||||
.then(setBookmarks)
|
||||
.catch(() => {
|
||||
/* leave the optimistic order as-is */
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// The default drag image is the whole full-width row, which looks like neighbours are moving
|
||||
// too, so use a small off-screen label instead. setDragImage snapshots it synchronously.
|
||||
const handleDragStart = (e: React.DragEvent<HTMLElement>, id: string, label: string) => {
|
||||
setDraggingId(id);
|
||||
const preview = document.createElement("div");
|
||||
preview.textContent = label;
|
||||
Object.assign(preview.style, {
|
||||
position: "fixed",
|
||||
top: "-1000px",
|
||||
left: "-1000px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "6px",
|
||||
background: "#1f2430",
|
||||
color: "#fff",
|
||||
fontSize: "13px",
|
||||
fontFamily: "inherit",
|
||||
whiteSpace: "nowrap",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,.25)",
|
||||
});
|
||||
document.body.appendChild(preview);
|
||||
e.dataTransfer.setDragImage(preview, 12, 14);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
requestAnimationFrame(() => preview.remove());
|
||||
const navigateUnlessDragged = (href: string) => {
|
||||
if (draggingId || suppressClickRef.current) return;
|
||||
navigate(href);
|
||||
};
|
||||
|
||||
const draggingBookmark = draggingId ? bookmarks.find((b) => b.id === draggingId) : undefined;
|
||||
|
||||
const onDashboard = location.pathname === "/";
|
||||
const onCollections = location.pathname.startsWith("/models/collections");
|
||||
const onTags = location.pathname.startsWith("/models/tags");
|
||||
@@ -354,57 +409,56 @@ export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }:
|
||||
{t("sidebar.bookmarks")}
|
||||
</Typography>
|
||||
)}
|
||||
{bookmarks.map((entry) => {
|
||||
const { href, label } = bookmarkTarget(entry);
|
||||
const selected = location.pathname === href;
|
||||
return collapsed ? (
|
||||
<CollapsedNavIcon
|
||||
key={entry.id}
|
||||
icon={<BookmarkIcon fontSize="small" />}
|
||||
label={label}
|
||||
selected={selected}
|
||||
onClick={() => navigate(href)}
|
||||
/>
|
||||
) : (
|
||||
<ListItemButton
|
||||
key={entry.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, entry.id, label)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggingId && draggingId !== entry.id) setDragOverId(entry.id);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggingId) handleDrop(draggingId, entry.id);
|
||||
setDraggingId(null);
|
||||
setDragOverId(null);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggingId(null);
|
||||
setDragOverId(null);
|
||||
}}
|
||||
selected={selected}
|
||||
onClick={() => navigate(href)}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
cursor: "grab",
|
||||
opacity: draggingId === entry.id ? 0.4 : 1,
|
||||
...(dragOverId === entry.id && draggingId !== entry.id
|
||||
? { outline: "2px solid", outlineColor: "primary.main", outlineOffset: "-2px" }
|
||||
: {}),
|
||||
...navRowSx(selected),
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 30 }}>
|
||||
<BookmarkIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={label} primaryTypographyProps={{ variant: "body2", noWrap: true }} />
|
||||
<DragIndicatorIcon fontSize="small" sx={{ ml: "auto", flexShrink: 0, color: "text.disabled" }} />
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
{collapsed ? (
|
||||
bookmarks.map((entry) => {
|
||||
const { href, label } = bookmarkTarget(entry);
|
||||
return (
|
||||
<CollapsedNavIcon
|
||||
key={entry.id}
|
||||
icon={<BookmarkIcon fontSize="small" />}
|
||||
label={label}
|
||||
selected={location.pathname === href}
|
||||
onClick={() => navigate(href)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={({ active }) => setDraggingId(String(active.id))}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={endDrag}
|
||||
>
|
||||
<SortableContext items={bookmarks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
|
||||
{bookmarks.map((entry) => {
|
||||
const { href, label } = bookmarkTarget(entry);
|
||||
return (
|
||||
<SortableBookmarkRow
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
label={label}
|
||||
selected={location.pathname === href}
|
||||
onNavigate={() => navigateUnlessDragged(href)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{draggingBookmark && (
|
||||
<Paper
|
||||
elevation={6}
|
||||
sx={{ display: "flex", alignItems: "center", gap: 1, px: 1.5, py: 0.75, cursor: "grabbing" }}
|
||||
>
|
||||
<BookmarkIcon fontSize="small" />
|
||||
<Typography variant="body2" noWrap>
|
||||
{bookmarkTarget(draggingBookmark).label}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1423,6 +1423,7 @@
|
||||
"renameCategoryFailed": "Failed to rename the category. Please try again.",
|
||||
"deleteCategoryFailed": "Failed to delete the category. Please try again.",
|
||||
"reorderCategoryFailed": "Failed to reorder categories. Please try again.",
|
||||
"moveCategoryFailed": "Failed to move the subcategory. Please try again.",
|
||||
"updateCategoryMetaFailed": "Failed to save category details. Please try again.",
|
||||
"notFound": "Model not found."
|
||||
},
|
||||
@@ -1451,7 +1452,9 @@
|
||||
"thingiverseCatIdLabel": "Thingiverse category IDs",
|
||||
"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"
|
||||
"catIdsHelp": "Separate multiple IDs with \";\" -- matches if an import's category is any of these",
|
||||
"dragToMove": "Drag to move",
|
||||
"dropHere": "Drop here"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1428,6 +1428,7 @@
|
||||
"renameCategoryFailed": "Nepavyko pervadinti kategorijos. Bandykite dar kartą.",
|
||||
"deleteCategoryFailed": "Nepavyko ištrinti kategorijos. Bandykite dar kartą.",
|
||||
"reorderCategoryFailed": "Nepavyko pakeisti kategorijų tvarkos. Bandykite dar kartą.",
|
||||
"moveCategoryFailed": "Nepavyko perkelti subkategorijos. Bandykite dar kartą.",
|
||||
"updateCategoryMetaFailed": "Nepavyko išsaugoti kategorijos informacijos. Bandykite dar kartą.",
|
||||
"notFound": "Modelis nerastas."
|
||||
},
|
||||
@@ -1456,7 +1457,9 @@
|
||||
"thingiverseCatIdLabel": "Thingiverse kategorijų ID",
|
||||
"printablesCatIdLabel": "Printables kategorijų ID",
|
||||
"catIdsPlaceholder": "pvz. 800;71;1001",
|
||||
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų"
|
||||
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų",
|
||||
"dragToMove": "Vilkite, kad perkeltumėte",
|
||||
"dropHere": "Numeskite čia"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type Props = {
|
||||
onRename: (id: string, name: string) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onReorder: (categoryIds: string[]) => Promise<void>;
|
||||
onMove: (id: string, parentId: string | null, position: number) => Promise<void>;
|
||||
onUpdateMeta: (id: string, meta: CategoryMetaInput) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -67,6 +68,7 @@ export default function CategoriesPanel({
|
||||
onRename,
|
||||
onDelete,
|
||||
onReorder,
|
||||
onMove,
|
||||
onUpdateMeta,
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation(["models", "common"]);
|
||||
@@ -225,6 +227,7 @@ export default function CategoriesPanel({
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onReorder={onReorder}
|
||||
onMove={onMove}
|
||||
onUpdateMeta={onUpdateMeta}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { 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";
|
||||
@@ -23,6 +23,30 @@ import CheckIcon from "@mui/icons-material/Check";
|
||||
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,
|
||||
PointerSensor,
|
||||
closestCorners,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { Category, CategoryMetaInput } from "../../api/categories";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import CategoryMetaDialog from "./CategoryMetaDialog";
|
||||
@@ -34,6 +58,7 @@ type Props = {
|
||||
onRename: (id: string, name: string) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onReorder: (categoryIds: string[]) => Promise<void>;
|
||||
onMove: (id: string, parentId: string | null, position: number) => Promise<void>;
|
||||
onUpdateMeta: (id: string, meta: CategoryMetaInput) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -47,15 +72,21 @@ function hasMeta(category: Category): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Subcategory ids per top-level category id, in display order. */
|
||||
type Containers = Record<string, string[]>;
|
||||
|
||||
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,
|
||||
busy,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
move,
|
||||
sortable,
|
||||
metaTitle,
|
||||
metaDescription,
|
||||
hasDetails,
|
||||
@@ -67,10 +98,10 @@ function CategoryRow({
|
||||
indent: number;
|
||||
bold?: boolean;
|
||||
busy: boolean;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
/** Up/down arrows; top-level categories only. */
|
||||
move?: MoveButtons;
|
||||
/** Subcategories are dragged instead. */
|
||||
sortable?: SortableBinding;
|
||||
metaTitle: string | null;
|
||||
metaDescription: string | null;
|
||||
hasDetails: boolean;
|
||||
@@ -110,7 +141,7 @@ function CategoryRow({
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<ListItem disableGutters sx={{ pl: indent, py: 0.5 }}>
|
||||
<ListItem ref={sortable?.setNodeRef} style={sortable?.style} disableGutters sx={{ pl: indent, py: 0.5 }}>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ width: "100%" }}>
|
||||
<TextField
|
||||
size="small"
|
||||
@@ -137,26 +168,32 @@ function CategoryRow({
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
ref={sortable?.setNodeRef}
|
||||
style={sortable?.style}
|
||||
disableGutters
|
||||
sx={{ pl: indent, py: 0.5 }}
|
||||
secondaryAction={
|
||||
<Stack direction="row" spacing={0.25}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onMoveUp}
|
||||
disabled={busy || !canMoveUp}
|
||||
aria-label={t("common:moveUp") ?? undefined}
|
||||
>
|
||||
<ArrowUpwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onMoveDown}
|
||||
disabled={busy || !canMoveDown}
|
||||
aria-label={t("common:moveDown") ?? undefined}
|
||||
>
|
||||
<ArrowDownwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{move && (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={move.onMoveUp}
|
||||
disabled={busy || !move.canMoveUp}
|
||||
aria-label={t("common:moveUp") ?? undefined}
|
||||
>
|
||||
<ArrowUpwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={move.onMoveDown}
|
||||
disabled={busy || !move.canMoveDown}
|
||||
aria-label={t("common:moveDown") ?? undefined}
|
||||
>
|
||||
<ArrowDownwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<Tooltip
|
||||
title={
|
||||
hasDetails ? (
|
||||
@@ -207,13 +244,101 @@ function CategoryRow({
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={bold ? 600 : 400} noWrap sx={{ pr: 19 }}>
|
||||
{sortable?.handle}
|
||||
<Typography variant="body2" fontWeight={bold ? 600 : 400} noWrap sx={{ pr: move ? 19 : 12 }}>
|
||||
{name}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DragHandle({
|
||||
setActivatorNodeRef,
|
||||
attributes,
|
||||
listeners,
|
||||
}: Pick<ReturnType<typeof useSortable>, "setActivatorNodeRef" | "attributes" | "listeners">) {
|
||||
const { t } = useTranslation("models");
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={t("categories.manager.dragToMove") ?? undefined}
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
mr: 0.75,
|
||||
color: "action.active",
|
||||
cursor: "grab",
|
||||
touchAction: "none",
|
||||
borderRadius: 0.5,
|
||||
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main" },
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableSubcategoryRow({
|
||||
id,
|
||||
...rowProps
|
||||
}: { id: string } & Omit<Parameters<typeof CategoryRow>[0], "sortable" | "move">) {
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
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} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A drop target even when empty, so subcategories can be dragged into a category without any. */
|
||||
function SubcategoryList({
|
||||
parentId,
|
||||
ids,
|
||||
dragging,
|
||||
children,
|
||||
}: {
|
||||
parentId: string;
|
||||
ids: string[];
|
||||
dragging: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("models");
|
||||
const { setNodeRef } = useDroppable({ id: parentId });
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function AddRow({
|
||||
indent,
|
||||
placeholder,
|
||||
@@ -294,6 +419,7 @@ export default function CategoryManagerModal({
|
||||
onRename,
|
||||
onDelete,
|
||||
onReorder,
|
||||
onMove,
|
||||
onUpdateMeta,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
@@ -370,16 +496,98 @@ export default function CategoryManagerModal({
|
||||
roots[index].id,
|
||||
);
|
||||
|
||||
const moveChild = (parentId: string, index: number, direction: -1 | 1) => {
|
||||
const children = childrenByParent[parentId] || [];
|
||||
return swapAndReorder(
|
||||
children.map((c) => c.id),
|
||||
index,
|
||||
direction,
|
||||
children[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 sensors = useSensors(
|
||||
// A few pixels of travel before dragging, so clicks on the handle don't start one.
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
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 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);
|
||||
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);
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const activeCategory = activeId ? categoryById.get(activeId) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onClose={onClose} fullWidth maxWidth="sm">
|
||||
@@ -390,72 +598,97 @@ export default function CategoryManagerModal({
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<List disablePadding>
|
||||
{roots.map((root, rootIndex) => {
|
||||
const children = childrenByParent[root.id] || [];
|
||||
return (
|
||||
<Box key={root.id} sx={{ mb: 1.5 }}>
|
||||
<CategoryRow
|
||||
name={root.name || untitledLabel}
|
||||
indent={0}
|
||||
bold
|
||||
busy={busyId === root.id}
|
||||
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)}
|
||||
/>
|
||||
<List disablePadding>
|
||||
{children.map((child, childIndex) => (
|
||||
<CategoryRow
|
||||
key={child.id}
|
||||
name={child.name || untitledLabel}
|
||||
indent={3}
|
||||
busy={busyId === child.id}
|
||||
canMoveUp={childIndex > 0}
|
||||
canMoveDown={childIndex < children.length - 1}
|
||||
onMoveUp={() => moveChild(root.id, childIndex, -1)}
|
||||
onMoveDown={() => moveChild(root.id, childIndex, 1)}
|
||||
metaTitle={child.meta_title}
|
||||
metaDescription={child.meta_description}
|
||||
hasDetails={hasMeta(child)}
|
||||
onOpenMeta={() => setMetaCategory(child)}
|
||||
onRename={(name) => onRename(child.id, name)}
|
||||
onDelete={() => handleDeleteSubcategory(child)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<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);
|
||||
}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
|
||||
{!roots.length && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
{t("models:categories.manager.noCategories")}
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
{!roots.length && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
{t("models:categories.manager.noCategories")}
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
<DragOverlay>
|
||||
{activeCategory && (
|
||||
<Paper
|
||||
elevation={6}
|
||||
sx={{ display: "flex", alignItems: "center", px: 1, py: 0.75, cursor: "grabbing" }}
|
||||
>
|
||||
<DragIndicatorIcon fontSize="small" sx={{ mr: 0.75, color: "action.active" }} />
|
||||
<Typography variant="body2" noWrap>
|
||||
{activeCategory.name || untitledLabel}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
|
||||
@@ -208,12 +208,24 @@ export default function ModelsPage({
|
||||
}
|
||||
};
|
||||
|
||||
// Refetch even on failure: the manager shows the drop optimistically until fresh categories arrive.
|
||||
const reorderCategories = async (categoryIds: string[]) => {
|
||||
try {
|
||||
await categoriesApi.reorder(categoryIds);
|
||||
onCategoriesChanged();
|
||||
} catch (err) {
|
||||
handleError(err, t("models:errors.reorderCategoryFailed"));
|
||||
} finally {
|
||||
onCategoriesChanged();
|
||||
}
|
||||
};
|
||||
|
||||
const moveCategory = async (id: string, parentId: string | null, position: number) => {
|
||||
try {
|
||||
await categoriesApi.move(id, parentId, position);
|
||||
} catch (err) {
|
||||
handleError(err, t("models:errors.moveCategoryFailed"));
|
||||
} finally {
|
||||
onCategoriesChanged();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -243,6 +255,7 @@ export default function ModelsPage({
|
||||
onRename={renameCategory}
|
||||
onDelete={deleteCategory}
|
||||
onReorder={reorderCategories}
|
||||
onMove={moveCategory}
|
||||
onUpdateMeta={updateCategoryMeta}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
Reference in new issue
Block a user