feat(categories): add toggle between categories and folders

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-02 12:27:03 +02:00
1 parent 04f3106ce8
commit 33400fa128
21 files changed
+639 -96

No files matched your search

@@ -0,0 +1,28 @@
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'folder';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "categoriesView" TEXT;
-- Top-level starter categories (by their seeded name, or any site category ids) are categories;
-- everything else stays a folder.
UPDATE "Category"
SET "kind" = 'category'
WHERE "parentId" IS NULL
AND (
"name" IN (
'3D Printer', 'Art', 'Education', 'Fashion', 'Hobby & DIY', 'Household', 'Miniatures',
'Props & Cosplays', 'Tools', 'Toys & Games', 'Generative 3D Model'
)
OR cardinality("makerworldCatIds") > 0
OR cardinality("thingiverseCatIds") > 0
OR cardinality("printablesCatIds") > 0
);
-- Subcategories take their top-level ancestor's kind.
WITH RECURSIVE tree AS (
SELECT "id", "kind" FROM "Category" WHERE "parentId" IS NULL
UNION ALL
SELECT c."id", tree."kind" FROM "Category" c JOIN tree ON c."parentId" = tree."id"
)
UPDATE "Category" SET "kind" = tree."kind" FROM tree WHERE "Category"."id" = tree."id";
+5
View File
@@ -32,6 +32,8 @@ model User {
slicer String?
// "light" | "dark" | "system"; null means never set.
themePreference String?
// Which tree the models page shows: "categories" | "folders"; null means categories.
categoriesView String?
authorPreviewEnabled Boolean @default(true)
// Filled from the first linked Author with this data; later links never overwrite it.
bio String?
@@ -67,6 +69,9 @@ model Category {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
tags String[] @default([])
// "category" (the starter categories imports sort into) or "folder" (the user's own tree, e.g.
// from uploading folders). Always the same as the top-level ancestor's.
kind String @default("folder")
parentId String?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryTree")
+2
View File
@@ -142,6 +142,7 @@ export type CategoryOut = {
name: string;
tags: string[];
parent_id: string | null;
kind: string;
position: number;
meta_title: string | null;
meta_description: string | null;
@@ -360,6 +361,7 @@ export function toCategoryOut(category: Category): CategoryOut {
name: category.name,
tags: category.tags,
parent_id: category.parentId,
kind: category.kind,
position: category.position,
meta_title: category.metaTitle,
meta_description: category.metaDescription,
+17 -6
View File
@@ -6,7 +6,7 @@ import { HttpError } from "../utils/fileUtils";
import { normalizeTags } from "../utils/tagNormalization";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { validateParentCategory } from "../services/categoryService";
import { CATEGORY_KINDS, kindUnder, setSubtreeKind, validateParentCategory } from "../services/categoryService";
import { availableModelName, reorganizeManagedPrints } from "../services/printService";
import { toCategoryOut } from "../dto";
import { sendPrintsZip } from "../services/downloadZip";
@@ -19,6 +19,8 @@ const categorySchema = z.object({
name: z.string().min(1),
tags: z.array(z.string()).default([]),
parent_id: z.string().nullable().optional(),
// Only for a top-level category; a subcategory always takes its parent's kind.
kind: z.enum(CATEGORY_KINDS).optional(),
});
router.get(
@@ -48,8 +50,12 @@ router.post(
if (parentIds.size > 1) {
throw new HttpError(400, "category_ids must all share the same parent category");
}
const kinds = new Set(categories.map((f) => f.kind));
if (kinds.size > 1) throw new HttpError(400, "category_ids must all be the same kind");
const [parentId] = parentIds;
const siblings = await prisma.category.findMany({ where: { userId: req.userId, parentId } });
const [kind] = kinds;
// Categories and folders share the top level but are shown and ordered apart.
const siblings = await prisma.category.findMany({ where: { userId: req.userId, parentId, kind } });
if (siblings.length !== body.category_ids.length) {
throw new HttpError(400, "category_ids must contain exactly this category's current siblings");
}
@@ -75,6 +81,7 @@ router.post(
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 kind = await kindUnder(req.userId!, parentId, category.kind);
const moved = await prisma.$transaction(async (tx) => {
const renumber = async (ids: string[]) => {
@@ -86,14 +93,14 @@ router.post(
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 } },
where: { userId: req.userId, parentId: category.parentId, kind: category.kind, 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 } },
where: { userId: req.userId, parentId, kind, id: { not: category.id } },
orderBy: siblingOrder,
select: { id: true },
});
@@ -106,9 +113,10 @@ router.post(
});
});
if (kind !== category.kind) await setSubtreeKind(req.userId!, category.id, kind);
// Managed storage paths include the category path.
if (category.parentId !== parentId) await reorganizeManagedPrints(undefined, req.userId);
res.json(toCategoryOut(moved));
res.json(toCategoryOut({ ...moved, kind }));
}),
);
@@ -117,8 +125,9 @@ router.post(
asyncHandler(async (req, res) => {
const body = parseBody(categorySchema, req.body);
const parentId = await validateParentCategory(req.userId!, body.parent_id ?? null);
const kind = await kindUnder(req.userId!, parentId, body.kind ?? "folder");
const category = await prisma.category.create({
data: { userId: req.userId!, name: body.name, tags: normalizeTags(body.tags), parentId },
data: { userId: req.userId!, name: body.name, tags: normalizeTags(body.tags), parentId, kind },
});
res.json(toCategoryOut(category));
}),
@@ -135,6 +144,8 @@ router.patch(
where: { id: category.id },
data: { name: body.name, tags: normalizeTags(body.tags), parentId },
});
const kind = await kindUnder(req.userId!, parentId, category.kind);
if (kind !== category.kind) await setSubtreeKind(req.userId!, category.id, kind);
await reorganizeManagedPrints(undefined, req.userId);
res.json(toCategoryOut(updated));
}),
+17
View File
@@ -34,6 +34,7 @@ import { getUserMakerworldCookie, setUserMakerworldCookie } from "../services/ma
import { type MakerworldCookieCheck, verifyMakerworldCookie } from "../services/makerworldCloudApi";
import { verifyThingiverseAccessToken } from "../services/thingiverseApi";
import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPreferenceService";
import { CATEGORIES_VIEWS, getCategoriesView, setCategoriesView } from "../services/categoriesViewService";
import { THEME_SELECTIONS, getUserTheme, setUserTheme } from "../services/themePreferenceService";
import { getUserAuthorPreviewEnabled, setUserAuthorPreviewEnabled } from "../services/authorPreviewPreferenceService";
import { checkForUpdates } from "../services/versionService";
@@ -371,6 +372,22 @@ router.patch(
}),
);
router.get(
"/settings/categories-view",
asyncHandler(async (req, res) => {
res.json({ view: await getCategoriesView(req.userId!) });
}),
);
const categoriesViewSchema = z.object({ view: z.enum(CATEGORIES_VIEWS) });
router.patch(
"/settings/categories-view",
asyncHandler(async (req, res) => {
const body = parseBody(categoriesViewSchema, req.body);
res.json({ view: await setCategoriesView(req.userId!, body.view) });
}),
);
router.get(
"/settings/author-preview",
asyncHandler(async (req, res) => {
@@ -0,0 +1,16 @@
import { prisma } from "../db";
// Kept in sync by hand with the frontend's CategoriesView.
export const CATEGORIES_VIEWS = ["categories", "folders"] as const;
export type CategoriesView = (typeof CATEGORIES_VIEWS)[number];
/** Stored per user, not per browser, so every device opens the models page on the same tree. */
export async function getCategoriesView(userId: string): Promise<CategoriesView> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { categoriesView: true } });
return user?.categoriesView === "folders" ? "folders" : "categories";
}
export async function setCategoriesView(userId: string, view: CategoriesView): Promise<CategoriesView> {
await prisma.user.update({ where: { id: userId }, data: { categoriesView: view } });
return view;
}
+26
View File
@@ -3,6 +3,31 @@ import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { DEFAULT_CATEGORIES, type DefaultCategoryNode } from "../seedData/defaultCategories";
export const CATEGORY_KINDS = ["category", "folder"] as const;
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
/** A new or moved category takes its parent's kind; a top-level one keeps `fallback`. */
export async function kindUnder(userId: string, parentId: string | null, fallback: string): Promise<string> {
if (!parentId) return fallback;
const parent = await prisma.category.findFirst({ where: { id: parentId, userId }, select: { kind: true } });
return parent?.kind ?? fallback;
}
/** Keeps a moved subtree's kind in line with its new top-level ancestor. */
export async function setSubtreeKind(userId: string, rootId: string, kind: string): Promise<void> {
const ids: string[] = [];
let frontier = [rootId];
while (frontier.length) {
ids.push(...frontier);
const children = await prisma.category.findMany({
where: { userId, parentId: { in: frontier } },
select: { id: true },
});
frontier = children.map((c) => c.id);
}
await prisma.category.updateMany({ where: { userId, id: { in: ids } }, data: { kind } });
}
/** Any depth is allowed; only cycles are rejected (a category under itself or its own descendant). */
export async function validateParentCategory(
userId: string,
@@ -41,6 +66,7 @@ export async function seedDefaultCategories(tx: Prisma.TransactionClient, userId
data: {
userId,
name: node.name,
kind: "category",
tags: node.tags ?? [],
parentId,
position,
+5 -2
View File
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import { kindUnder } from "./categoryService";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
@@ -56,12 +57,14 @@ async function getOrCreateCategory(
const key = `${parentKey}/${name}`;
const cached = cache.get(key);
if (cached) return cached;
const existing = await prisma.category.findFirst({ where: { userId, parentId, name } });
// At the top level only a folder is reused: a zip's folders shouldn't merge into a starter category.
const kind = await kindUnder(userId, parentId, "folder");
const existing = await prisma.category.findFirst({ where: { userId, parentId, name, kind } });
if (existing) {
cache.set(key, existing.id);
return existing.id;
}
const category = await prisma.category.create({ data: { userId, name, parentId, tags: [] } });
const category = await prisma.category.create({ data: { userId, name, parentId, kind, tags: [] } });
cache.set(key, category.id);
return category.id;
}
+96
View File
@@ -0,0 +1,96 @@
import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
const app = createApp();
const stamp = Date.now();
let token: string;
type CategoryOut = { id: string; name: string; parent_id: string | null; kind: string };
const auth = () => ({ Authorization: `Bearer ${token}` });
async function create(name: string, parentId: string | null = null, kind?: string): Promise<CategoryOut> {
const res = await request(app).post("/api/categories").set(auth()).send({ name, parent_id: parentId, kind });
expect(res.status).toBe(200);
return res.body;
}
async function list(): Promise<CategoryOut[]> {
return (await request(app).get("/api/categories").set(auth())).body;
}
beforeAll(async () => {
token = (
await request(app)
.post("/api/register")
.send({ displayName: "Kinds", email: `kinds-${stamp}@example.com`, password: "password123" })
).body.token;
});
describe("category kinds", () => {
it("seeds the starter categories, subcategories included, as categories", async () => {
const seeded = await list();
expect(seeded.length).toBeGreaterThan(0);
expect(seeded.every((c) => c.kind === "category")).toBe(true);
});
it("makes new top-level ones folders unless asked otherwise", async () => {
expect((await create("My prints")).kind).toBe("folder");
expect((await create("My category", null, "category")).kind).toBe("category");
});
it("gives subcategories their parent's kind, whatever is asked", async () => {
const folder = await create("Folder root");
expect((await create("Sub", folder.id, "category")).kind).toBe("folder");
const category = await create("Category root", null, "category");
expect((await create("Sub", category.id)).kind).toBe("category");
});
it("carries a moved subtree over to its new tree's kind", async () => {
const folder = await create("Moving folder");
const child = await create("Moving child", folder.id);
const grandchild = await create("Moving grandchild", child.id);
const category = await create("Target category", null, "category");
expect(
(
await request(app)
.post(`/api/category/${child.id}/move`)
.set(auth())
.send({ parent_id: category.id, position: 0 })
).body.kind,
).toBe("category");
const after = await list();
expect(after.find((c) => c.id === child.id)?.kind).toBe("category");
expect(after.find((c) => c.id === grandchild.id)?.kind).toBe("category");
expect(after.find((c) => c.id === folder.id)?.kind).toBe("folder");
});
it("reorders top-level categories and folders apart", async () => {
const roots = (await list()).filter((c) => !c.parent_id);
const folders = roots.filter((c) => c.kind === "folder").map((c) => c.id);
const reorder = (ids: string[]) =>
request(app).post("/api/categories/reorder").set(auth()).send({ category_ids: ids });
expect((await reorder(folders.toReversed())).status).toBe(200);
const mixed = [folders[0], roots.find((c) => c.kind === "category")!.id];
expect((await reorder(mixed)).status).toBe(400);
});
it("rejects an unknown kind", async () => {
const res = await request(app).post("/api/categories").set(auth()).send({ name: "Bad", kind: "shelf" });
expect(res.status).toBe(400);
});
});
describe("categories view preference", () => {
it("defaults to categories and remembers a change", async () => {
expect((await request(app).get("/api/settings/categories-view").set(auth())).body.view).toBe("categories");
const set = await request(app).patch("/api/settings/categories-view").set(auth()).send({ view: "folders" });
expect(set.body.view).toBe("folders");
expect((await request(app).get("/api/settings/categories-view").set(auth())).body.view).toBe("folders");
expect(
(await request(app).patch("/api/settings/categories-view").set(auth()).send({ view: "shelves" })).status,
).toBe(400);
});
});
+8 -2
View File
@@ -1,11 +1,16 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk, readErrorMessage, UnauthorizedError } from "./client";
/** "category": the starter categories imports sort into. "folder": the user's own tree. */
export type CategoryKind = "category" | "folder";
export type Category = {
id: string;
name: string;
tags: string[];
parent_id?: string | null;
/** Always the same as the top-level ancestor's. */
kind: CategoryKind;
position: number;
meta_title: string | null;
meta_description: string | null;
@@ -30,11 +35,12 @@ export const categoriesApi = {
return res.json();
},
create: async (name: string, tags: string[] = [], parent_id?: string | null) => {
/** `kind` only applies at the top level; a subcategory takes its parent's. The server defaults to folder. */
create: async (name: string, tags: string[] = [], parent_id?: string | null, kind?: CategoryKind) => {
const res = await fetch(`${apiBase()}/categories`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ name, tags, parent_id }),
body: JSON.stringify({ name, tags, parent_id, kind }),
});
assertOk(res, "Create category failed");
return res.json();
+20
View File
@@ -11,6 +11,9 @@ export type StorageSettings = {
skipped: number;
};
/** Which tree the models page shows. Kept in sync by hand with the backend's CATEGORIES_VIEWS. */
export type CategoriesView = "categories" | "folders";
export type PreviewMode = "automatic" | "on-demand" | "disabled";
export type RenderingSettings = { simplify_previews: boolean };
@@ -282,6 +285,23 @@ export const settingsApi = {
return res.json();
},
getCategoriesView: async (): Promise<{ view: CategoriesView }> => {
const res = await fetch(`${apiBase()}/settings/categories-view`, { headers: authHeaders() });
assertOk(res, "Failed to load the categories view setting");
return res.json();
},
updateCategoriesView: async (view: CategoriesView): Promise<{ view: CategoriesView }> => {
const res = await fetch(`${apiBase()}/settings/categories-view`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ view }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update the categories view setting"));
return res.json();
},
getAuthorPreview: async (): Promise<{ enabled: boolean }> => {
const res = await fetch(`${apiBase()}/settings/author-preview`, { headers: authHeaders() });
assertOk(res, "Failed to load author preview setting");
@@ -16,6 +16,8 @@ export type ImportMode = "separate" | "multiplate";
type ImportModePromptConfig = {
label: string;
count: number;
/** "folders": an uploaded folder tree, where the choice is per folder rather than for the whole set. */
variant?: "files" | "folders";
onChoose: (mode: ImportMode) => Promise<void>;
};
@@ -30,6 +32,7 @@ function errorMessage(err: unknown, fallback: string) {
/**
* For a flat multi-file selection: import each file as its own print, or all as plates of one.
* For a folder tree: each file as its own print, or each folder of model files as one print.
*/
export function useImportModePrompt() {
const { t } = useTranslation("app");
@@ -77,6 +80,7 @@ export function useImportModePrompt() {
<ImportModeModal
label={state.config.label}
count={state.config.count}
variant={state.config.variant ?? "files"}
busy={busy}
error={error}
onChoose={choose}
@@ -90,6 +94,7 @@ export function useImportModePrompt() {
function ImportModeModal({
label,
count,
variant,
busy,
error,
onChoose,
@@ -97,18 +102,20 @@ function ImportModeModal({
}: {
label: string;
count: number;
variant: "files" | "folders";
busy: boolean;
error: string | null;
onChoose: (mode: ImportMode) => void;
onClose: () => void;
}) {
const { t } = useTranslation("app");
const text = variant === "folders" ? "importMode.folders" : "importMode";
return (
<Dialog open onClose={busy ? undefined : onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2 }}>
<Box>
<Typography variant="h6" component="div">
{t("importMode.title", { count })}
{t(`${text}.title`, { count })}
</Typography>
<Typography variant="body2" color="text.secondary">
{label}
@@ -120,7 +127,7 @@ function ImportModeModal({
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
<Typography variant="body2">{t("importMode.prompt")}</Typography>
<Typography variant="body2">{t(`${text}.prompt`)}</Typography>
<Stack spacing={1.5}>
<ButtonBase
onClick={() => onChoose("separate")}
@@ -136,9 +143,9 @@ function ImportModeModal({
"&:hover": { boxShadow: 1 },
}}
>
<Typography variant="subtitle2">{t("importMode.separateTitle")}</Typography>
<Typography variant="subtitle2">{t(`${text}.separateTitle`)}</Typography>
<Typography variant="body2" color="text.secondary">
{t("importMode.separateDesc")}
{t(`${text}.separateDesc`)}
</Typography>
</ButtonBase>
<ButtonBase
@@ -155,9 +162,9 @@ function ImportModeModal({
"&:hover": { boxShadow: 1 },
}}
>
<Typography variant="subtitle2">{t("importMode.multiplateTitle")}</Typography>
<Typography variant="subtitle2">{t(`${text}.multiplateTitle`)}</Typography>
<Typography variant="body2" color="text.secondary">
{t("importMode.multiplateDesc")}
{t(`${text}.multiplateDesc`)}
</Typography>
</ButtonBase>
</Stack>
@@ -5,7 +5,12 @@ import { useNavigate } from "react-router-dom";
import { UnauthorizedError } from "../../api/client";
import { importsApi, type ImportOutcome, type MakerworldProfileScope } from "../../api/imports";
import { printsApi, type Print } from "../../api/prints";
import { entriesFromFileList, uploadEntriesToCategory } from "../../utils/uploadTree";
import {
entriesFromFileList,
hasModelFolders,
uploadEntriesToCategory,
uploadFoldersAsModels,
} from "../../utils/uploadTree";
import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../utils/zipUtils";
import { useZipImportPrompt } from "./ZipImportModal";
import { useCollectionImportPrompt } from "./CollectionImportModal";
@@ -111,6 +116,18 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
}
},
});
} else if (hasModelFolders(normalEntries)) {
// Libraries often keep one model per folder, split into several files with its photos alongside.
const roots = [...new Set(normalEntries.map((entry) => entry.relativePath.split("/")[0]))];
await importModePrompt.prompt({
label: roots.join(", "),
count: normalEntries.length,
variant: "folders",
onChoose: async (mode: ImportMode) => {
const upload = mode === "multiplate" ? uploadFoldersAsModels : uploadEntriesToCategory;
applyResult(await upload(normalEntries, categoryId || null, onUnauthorized));
},
});
} else {
const result = await uploadEntriesToCategory(normalEntries, categoryId || null, onUnauthorized);
applyResult(result);
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { settingsApi, type CategoriesView } from "../api/settings";
// Saved on the account so every device and window opens on the same tree. Shared by every user of
// the hook so there's one request, and a switch shows everywhere at once.
let cached: CategoriesView | undefined;
let inFlight: Promise<CategoriesView> | null = null;
const listeners = new Set<(value: CategoriesView) => void>();
function load(): Promise<CategoriesView> {
if (cached !== undefined) return Promise.resolve(cached);
inFlight ??= settingsApi
.getCategoriesView()
.then((res) => (cached = res.view))
.catch((): CategoriesView => "categories")
.finally(() => {
inFlight = null;
});
return inFlight;
}
function publish(value: CategoriesView) {
cached = value;
listeners.forEach((listener) => listener(value));
}
/** Null until the saved choice is known, so the page doesn't flash the wrong tree. */
export function useCategoriesView(): [CategoriesView | null, (view: CategoriesView) => void] {
const [value, setValue] = useState<CategoriesView | null>(cached ?? null);
useEffect(() => {
let cancelled = false;
void load().then((v) => {
if (!cancelled) setValue(v);
});
listeners.add(setValue);
return () => {
cancelled = true;
listeners.delete(setValue);
};
}, []);
const update = (view: CategoriesView) => {
publish(view);
// The switch already happened here; a failed save only means other devices won't follow.
settingsApi.updateCategoriesView(view).catch(() => {});
};
return [value, update];
}
+27 -1
View File
@@ -140,7 +140,15 @@
"multiplateDesc": "All files are added to a single new model, in the order selected.",
"importing": "Importing…",
"close": "Close",
"importError": "Import failed"
"importError": "Import failed",
"folders": {
"title": "Import folders ({{count}} files)",
"prompt": "How should the models in these folders be imported?",
"separateTitle": "Each model file is a separate model",
"separateDesc": "Every STL, 3MF, STEP or OBJ file becomes its own model. Each folder becomes a folder.",
"multiplateTitle": "Each folder is one model",
"multiplateDesc": "A folder containing model files becomes a single model named after the folder. Its model files are added as plates, its images become preview images, and other files are attached."
}
},
"tagInput": {
"removeTag": "Remove {{tag}}"
@@ -1455,6 +1463,24 @@
"catIdsHelp": "Separate multiple IDs with \";\" -- matches if an import's category is any of these",
"dragToMove": "Drag to move"
}
},
"view": {
"label": "Show categories or folders",
"categories": "Categories",
"folders": "Folders"
},
"folders": {
"title": "Folders",
"subtitle": "Folder",
"empty": "No folders yet",
"noSubfolders": "No subfolders yet",
"manageTooltip": "Manage folders",
"manager": {
"title": "Manage Folders",
"addCategory": "Add folder",
"addSubcategory": "Add subfolder",
"noCategories": "No folders yet. Add one, or upload a folder of models."
}
}
}
}
+27 -1
View File
@@ -140,7 +140,15 @@
"multiplateDesc": "Visi failai bus pridėti prie vieno naujo modelio pasirinkta tvarka.",
"importing": "Importuojama…",
"close": "Uždaryti",
"importError": "Importuoti nepavyko"
"importError": "Importuoti nepavyko",
"folders": {
"title": "Importuoti aplankus ({{count}} failų)",
"prompt": "Kaip turėtų būti importuojami šiuose aplankuose esantys modeliai?",
"separateTitle": "Kiekvienas modelio failas – atskiras modelis",
"separateDesc": "Kiekvienas STL, 3MF, STEP ar OBJ failas taps atskiru modeliu. Kiekvienas aplankas taps aplanku.",
"multiplateTitle": "Kiekvienas aplankas – vienas modelis",
"multiplateDesc": "Aplankas su modelio failais taps vienu modeliu, pavadintu aplanko vardu. Jo modelio failai bus pridėti kaip plokštės, paveikslėliai taps peržiūros paveikslėliais, o kiti failai bus prisegti."
}
},
"tagInput": {
"removeTag": "Pašalinti {{tag}}"
@@ -1460,6 +1468,24 @@
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų",
"dragToMove": "Vilkite, kad perkeltumėte"
}
},
"view": {
"label": "Rodyti kategorijas arba aplankus",
"categories": "Kategorijos",
"folders": "Aplankai"
},
"folders": {
"title": "Aplankai",
"subtitle": "Aplankas",
"empty": "Kol kas nėra aplankų",
"noSubfolders": "Kol kas nėra poaplankių",
"manageTooltip": "Tvarkyti aplankus",
"manager": {
"title": "Tvarkyti aplankus",
"addCategory": "Pridėti aplanką",
"addSubcategory": "Pridėti poaplankį",
"noCategories": "Kol kas nėra aplankų. Pridėkite arba įkelkite modelių aplanką."
}
}
}
}
@@ -13,13 +13,15 @@ import Collapse from "@mui/material/Collapse";
import CircularProgress from "@mui/material/CircularProgress";
import SettingsIcon from "@mui/icons-material/Settings";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import type { Category, CategoryMetaInput } from "../../api/categories";
import type { Category, CategoryKind, 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 = {
/** Which box this is; `categories` holds only that kind. */
kind: CategoryKind;
categories: Category[];
loading: boolean;
selectedId: string | null;
@@ -61,6 +63,7 @@ function rowTextSx(active: boolean, extra?: object) {
/** Any depth; a category selects the models at every level beneath it. Only the selected path is open. */
export default function CategoriesPanel({
kind,
categories,
loading,
selectedId,
@@ -77,6 +80,7 @@ export default function CategoriesPanel({
const displayName = (category: Category) => translateCategoryDisplay(category, i18n).name;
const untitledLabel = t("models:categories.untitled");
const labels = kind === "folder" ? "models:folders" : "models:categories";
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
// The selected category and its ancestors are open, so a selection from the URL is always visible.
@@ -118,7 +122,7 @@ export default function CategoriesPanel({
{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")}
{t(kind === "folder" ? "models:folders.noSubfolders" : "models:categories.noSubcategories")}
</Typography>
)}
</List>
@@ -143,13 +147,13 @@ export default function CategoriesPanel({
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 0.5, pb: 1 }}>
<Typography variant="subtitle1" fontWeight={700}>
{t("models:categories.title")}
{t(`${labels}.title`)}
</Typography>
<Tooltip title={t("models:categories.manageTooltip") ?? ""}>
<Tooltip title={t(`${labels}.manageTooltip`) ?? ""}>
<IconButton
size="small"
onClick={() => setManagerOpen(true)}
aria-label={t("models:categories.manageTooltip") ?? undefined}
aria-label={t(`${labels}.manageTooltip`) ?? undefined}
>
<SettingsIcon fontSize="small" />
</IconButton>
@@ -174,7 +178,7 @@ export default function CategoriesPanel({
{!loading && !tree.roots.length && (
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
{t("models:categories.empty")}
{t(`${labels}.empty`)}
</Typography>
)}
</List>
@@ -182,6 +186,7 @@ export default function CategoriesPanel({
{managerOpen && (
<CategoryManagerModal
kind={kind}
categories={categories}
onClose={() => setManagerOpen(false)}
onCreate={onCreate}
@@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import CategoryOutlinedIcon from "@mui/icons-material/CategoryOutlined";
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import { type CategoriesView } from "../../api/settings";
type Props = {
value: CategoriesView | null;
onChange: (view: CategoriesView) => void;
};
/** Sits above the side box and matches its width. */
export default function CategoriesViewToggle({ value, onChange }: Props) {
const { t } = useTranslation("models");
return (
<ToggleButtonGroup
exclusive
size="small"
value={value}
onChange={(_, next: CategoriesView | null) => next && onChange(next)}
aria-label={t("view.label") ?? undefined}
sx={{ width: 260, flexShrink: 0, "& .MuiToggleButton-root": { flex: 1, gap: 0.75, textTransform: "none" } }}
>
<ToggleButton value="categories">
<CategoryOutlinedIcon fontSize="small" />
{t("view.categories")}
</ToggleButton>
<ToggleButton value="folders">
<FolderOutlinedIcon fontSize="small" />
{t("view.folders")}
</ToggleButton>
</ToggleButtonGroup>
);
}
@@ -47,7 +47,7 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Category, CategoryMetaInput } from "../../api/categories";
import type { Category, CategoryKind, CategoryMetaInput } from "../../api/categories";
import { useConfirm } from "../../components/ConfirmProvider";
import { buildCategoryTree, flattenCategoryTree } from "../../utils/categoryTree";
import CategoryMetaDialog from "./CategoryMetaDialog";
@@ -57,6 +57,8 @@ const INDENT_PX = 24;
const ACTION_BUTTON_PX = 30;
type Props = {
/** Only the trees of this kind; it also picks the labels. */
kind: CategoryKind;
categories: Category[];
onClose: () => void;
onCreate: (name: string, parentId: string | null) => Promise<void>;
@@ -121,6 +123,7 @@ function CategoryRow({
hasDetails,
onOpenMeta,
onAddChild,
addChildLabel,
onRename,
onDelete,
}: {
@@ -138,6 +141,7 @@ function CategoryRow({
hasDetails: boolean;
onOpenMeta: () => void;
onAddChild: () => void;
addChildLabel: string;
onRename: (name: string) => Promise<void>;
onDelete: () => void;
}) {
@@ -228,14 +232,9 @@ function CategoryRow({
</IconButton>
</>
)}
<Tooltip title={t("models:categories.manager.addSubcategory")}>
<Tooltip title={addChildLabel}>
<span>
<IconButton
size="small"
onClick={onAddChild}
disabled={busy}
aria-label={t("models:categories.manager.addSubcategory") ?? undefined}
>
<IconButton size="small" onClick={onAddChild} disabled={busy} aria-label={addChildLabel}>
<AddIcon fontSize="small" />
</IconButton>
</span>
@@ -362,15 +361,16 @@ function TreeRow({
function InlineAddRow({
depth,
busy,
placeholder,
onAdd,
onCancel,
}: {
depth: number;
busy: boolean;
placeholder: string;
onAdd: (name: string) => Promise<void>;
onCancel: () => void;
}) {
const { t } = useTranslation("models");
const [value, setValue] = useState("");
const submit = async () => {
const trimmed = value.trim();
@@ -381,7 +381,7 @@ function InlineAddRow({
<TextField
size="small"
fullWidth
placeholder={t("categories.manager.addSubcategory")}
placeholder={placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
@@ -472,6 +472,7 @@ function AddRow({
}
export default function CategoryManagerModal({
kind,
categories,
onClose,
onCreate,
@@ -516,6 +517,7 @@ export default function CategoryManagerModal({
);
const untitledLabel = t("models:categories.untitled");
const labels = kind === "folder" ? "models:folders.manager" : "models:categories.manager";
const handleDelete = async (category: Category) => {
const name = category.name || untitledLabel;
@@ -617,7 +619,7 @@ export default function CategoryManagerModal({
<>
<Dialog open onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
{t("models:categories.manager.title")}
{t(`${labels}.title`)}
<IconButton size="small" onClick={onClose} aria-label={t("common:close") ?? undefined}>
<CloseIcon fontSize="small" />
</IconButton>
@@ -661,6 +663,7 @@ export default function CategoryManagerModal({
hasDetails={hasMeta(category)}
onOpenMeta={() => setMetaCategory(category)}
onAddChild={() => setAddingUnderId(category.id)}
addChildLabel={t(`${labels}.addSubcategory`)}
onRename={(name) => onRename(category.id, name)}
onDelete={() => handleDelete(category)}
/>
@@ -668,6 +671,7 @@ export default function CategoryManagerModal({
<InlineAddRow
depth={addingDepth}
busy={busyId === `new-sub-${addingUnderId}`}
placeholder={t(`${labels}.addSubcategory`)}
onCancel={() => setAddingUnderId(null)}
onAdd={async (name) => {
setBusyId(`new-sub-${addingUnderId}`);
@@ -686,7 +690,7 @@ export default function CategoryManagerModal({
{!tree.roots.length && (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{t("models:categories.manager.noCategories")}
{t(`${labels}.noCategories`)}
</Typography>
)}
</List>
@@ -709,7 +713,7 @@ export default function CategoryManagerModal({
<Divider sx={{ my: 1.5 }} />
<AddRow
placeholder={t("models:categories.manager.addCategory")}
placeholder={t(`${labels}.addCategory`)}
busy={busyId === "new-root"}
onAdd={async (name) => {
setBusyId("new-root");
+23 -6
View File
@@ -8,13 +8,15 @@ import CircularProgress from "@mui/material/CircularProgress";
import { UnauthorizedError } from "../../api/client";
import { type Print, type PrintSortMode, printsApi } from "../../api/prints";
import { type Category, type CategoryMetaInput, categoriesApi } from "../../api/categories";
import { type PreviewMode } from "../../api/settings";
import { type CategoriesView, 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 { useCategoriesView } from "../../hooks/useCategoriesView";
import CategoriesPanel from "./CategoriesPanel";
import CategoriesViewToggle from "./CategoriesViewToggle";
import CategoryBanner from "./CategoryBanner";
import ModelCard from "./ModelCard";
import SortTabs from "./SortTabs";
@@ -54,6 +56,9 @@ export default function ModelsPage({
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [categoriesView, setCategoriesView] = useCategoriesView();
const panelKind = categoriesView === "folders" ? "folder" : "category";
const panelCategories = useMemo(() => categories.filter((c) => c.kind === panelKind), [categories, panelKind]);
const sortModeParam = searchParams.get("orderBy");
const sortMode: PrintSortMode =
sortModeParam === "popular" || sortModeParam === "downloads" ? sortModeParam : "newest";
@@ -106,7 +111,9 @@ export default function ModelsPage({
const selectedCategory = categoryId ? (categories.find((f) => f.id === categoryId) ?? null) : null;
usePageHeader({
title: selectedCategory ? selectedCategory.name || t("models:categories.untitled") : undefined,
subtitle: selectedCategory ? t("models:categories.subtitle") : undefined,
subtitle: selectedCategory
? t(selectedCategory.kind === "folder" ? "models:folders.subtitle" : "models:categories.subtitle")
: undefined,
onBack: categoryId ? () => onSelectCategory(null) : undefined,
});
@@ -180,7 +187,7 @@ export default function ModelsPage({
const createCategory = async (name: string, parentId: string | null) => {
try {
await categoriesApi.create(name, [], parentId || undefined);
await categoriesApi.create(name, [], parentId || undefined, panelKind);
onCategoriesChanged();
} catch (err) {
handleError(err, t("models:errors.createCategoryFailed"));
@@ -228,6 +235,14 @@ export default function ModelsPage({
}
};
// A selection from the other box would be invisible after the switch.
const switchCategoriesView = (view: CategoriesView) => {
setCategoriesView(view);
if (selectedCategory && selectedCategory.kind !== (view === "folders" ? "folder" : "category")) {
onSelectCategory(null);
}
};
const updateCategoryMeta = async (id: string, meta: CategoryMetaInput) => {
try {
await categoriesApi.updateMeta(id, meta);
@@ -241,13 +256,15 @@ export default function ModelsPage({
return (
<Stack spacing={2} sx={{ maxWidth: "1920px", mx: "auto" }}>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2 }}>
<CategoriesViewToggle value={categoriesView} onChange={switchCategoriesView} />
<SortTabs value={sortMode} onChange={setSortMode} />
</Box>
<Stack direction="row" spacing={2} alignItems="flex-start">
<CategoriesPanel
categories={categories}
loading={categoriesLoading}
kind={panelKind}
categories={panelCategories}
loading={categoriesLoading || categoriesView === null}
selectedId={categoryId}
onSelect={onSelectCategory}
onCreate={createCategory}
+178 -52
View File
@@ -1,6 +1,7 @@
import { UnauthorizedError } from "../api/client";
import { categoriesApi } from "../api/categories";
import { categoriesApi, type Category } from "../api/categories";
import { printsApi, type Print } from "../api/prints";
import { MODEL_EXTS } from "../constants/fileTypes";
export type UploadEntry = {
file: File;
@@ -85,79 +86,204 @@ export async function entriesFromDataTransfer(dataTransfer: DataTransfer): Promi
return output;
}
export async function uploadEntriesToCategory(
/** Finds or creates the category path under `baseCategoryId`, one segment per level. */
function categoryResolver(baseCategoryId: string | null) {
const cache = new Map<string, string>();
// Folders reuse a same-named category in the same place, so uploading a tree twice doesn't duplicate it.
let existing: Category[] | null = null;
const child = async (parentId: string | null, key: string, name: string) => {
const cached = cache.get(key);
if (cached) return cached;
existing ??= await categoriesApi.list();
// At the top level only a folder is reused: uploaded folders shouldn't merge into a starter category.
const match = existing.find(
(c) => (c.parent_id ?? null) === parentId && c.name === name && (parentId || c.kind === "folder"),
);
const id = match ? match.id : ((await categoriesApi.create(name, [], parentId || undefined)) as { id: string }).id;
cache.set(key, id);
return id;
};
return async (segments: string[]) => {
let categoryId = baseCategoryId;
let key = baseCategoryId || "root";
for (const segment of segments) {
key = `${key}/${segment}`;
categoryId = await child(categoryId, key, segment);
}
return categoryId;
};
}
function splitPath(entry: UploadEntry) {
const segments = normalizeRelativePath(entry.relativePath || entry.file.name)
.split("/")
.filter(Boolean);
segments.pop();
return segments;
}
function extOf(name: string) {
return name.split(".").pop()?.toLowerCase() ?? "";
}
function isModelFile(name: string) {
return MODEL_EXTS.has(extOf(name));
}
// Larger images than the preview-image endpoint takes are attached to the model as files instead.
const PREVIEW_IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "webp", "bmp"]);
const PREVIEW_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
function isPreviewImage(file: File) {
return PREVIEW_IMAGE_EXTS.has(extOf(file.name)) && file.size <= PREVIEW_IMAGE_MAX_BYTES;
}
/** True when some folder in the upload holds a model file, so the folder-as-model choice means something. */
export function hasModelFolders(entries: UploadEntry[]) {
return entries.some((entry) => splitPath(entry).length > 0 && isModelFile(entry.file.name));
}
const byName = (a: UploadEntry, b: UploadEntry) =>
a.file.name.localeCompare(b.file.name, undefined, { numeric: true, sensitivity: "base" });
function failureLabel(name: string, err: unknown) {
const message = err instanceof Error ? err.message.trim() : "";
return message && message !== "Upload failed" ? `${name} (${message})` : name;
}
export function uploadEntriesToCategory(
entries: UploadEntry[],
parentCategoryId: string | null,
onUnauthorized?: () => void,
) {
return uploadEach(entries, categoryResolver(parentCategoryId), onUnauthorized);
}
async function uploadEach(
entries: UploadEntry[],
resolveCategory: ReturnType<typeof categoryResolver>,
onUnauthorized?: () => void,
) {
const failed: string[] = [];
let uploaded = 0;
let aborted = false;
const uploadedEntries: UploadEntry[] = [];
const prints: Print[] = [];
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 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);
return categoryId;
};
for (const entry of entries) {
const normalized = normalizeRelativePath(entry.relativePath || entry.file.name);
const segments = normalized.split("/").filter(Boolean);
if (!segments.length) continue;
segments.pop();
let targetCategoryId = parentCategoryId;
let parentKey = baseKey;
for (const segment of segments) {
try {
targetCategoryId = await getOrCreateCategory(targetCategoryId, parentKey, segment);
parentKey = `${parentKey}/${segment}`;
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
aborted = true;
break;
}
console.error("Category creation failed for", segment, err);
failed.push(entry.file.name);
targetCategoryId = null;
break;
}
}
if (aborted) break;
if (targetCategoryId === null && segments.length) continue;
try {
const segments = splitPath(entry);
let categoryId: string | null;
try {
categoryId = await resolveCategory(segments);
} catch (err) {
if (err instanceof UnauthorizedError) throw err;
console.error("Category creation failed for", entry.relativePath, err);
failed.push(entry.file.name);
continue;
}
// Folder-tree leaves are always single-plate prints.
const result = await printsApi.upload([entry.file], { category_id: targetCategoryId || undefined });
const result = await printsApi.upload([entry.file], { category_id: categoryId || undefined });
uploaded += 1;
uploadedEntries.push(entry);
prints.push(...result.prints);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
aborted = true;
break;
}
console.error("Upload failed for", entry.file.name, err);
const message = err instanceof Error ? err.message.trim() : "";
failed.push(message && message !== "Upload failed" ? `${entry.file.name} (${message})` : entry.file.name);
failed.push(failureLabel(entry.file.name, err));
}
}
return { uploaded, failed, uploadedEntries, prints };
}
/**
* Each folder that directly holds model files becomes one model named after the folder: its model files
* are the plates, its images the preview images, anything else is attached. The folders above it become
* categories; files outside any model folder upload one model each, as usual.
*/
export async function uploadFoldersAsModels(
entries: UploadEntry[],
parentCategoryId: string | null,
onUnauthorized?: () => void,
) {
const groups = new Map<string, { segments: string[]; entries: UploadEntry[] }>();
for (const entry of entries) {
const segments = splitPath(entry);
const key = segments.join("/");
const group = groups.get(key) ?? { segments, entries: [] };
group.entries.push(entry);
groups.set(key, group);
}
const modelGroups = [...groups.values()].filter(
(g) => g.segments.length > 0 && g.entries.some((e) => isModelFile(e.file.name)),
);
const rest = [...groups.values()].filter((g) => !modelGroups.includes(g)).flatMap((g) => g.entries);
const failed: string[] = [];
let uploaded = 0;
const uploadedEntries: UploadEntry[] = [];
const prints: Print[] = [];
const resolveCategory = categoryResolver(parentCategoryId);
for (const group of modelGroups) {
const folderName = group.segments[group.segments.length - 1];
const sorted = group.entries.toSorted(byName);
const images = sorted.filter((e) => isPreviewImage(e.file));
const files = sorted.filter((e) => !isPreviewImage(e.file));
try {
let categoryId: string | null;
try {
categoryId = await resolveCategory(group.segments.slice(0, -1));
} catch (err) {
if (err instanceof UnauthorizedError) throw err;
console.error("Category creation failed for", group.segments.join("/"), err);
failed.push(folderName);
continue;
}
const result = await printsApi.upload(
files.map((e) => e.file),
{ title: folderName, category_id: categoryId || undefined, mode: "multiplate" },
);
uploaded += 1;
uploadedEntries.push(...files);
let print = result.prints[0];
if (print && images.length) {
try {
print = (
await printsApi.addPreviewImages(
print.id,
images.map((e) => e.file),
)
).print;
uploadedEntries.push(...images);
} catch (err) {
if (err instanceof UnauthorizedError) throw err;
console.error("Preview images failed for", folderName, err);
failed.push(failureLabel(`${folderName} (images)`, err));
}
}
if (print) prints.push(print);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return { uploaded, failed, uploadedEntries, prints };
}
console.error("Upload failed for", folderName, err);
failed.push(failureLabel(folderName, err));
}
}
if (rest.length) {
const result = await uploadEach(rest, resolveCategory, onUnauthorized);
uploaded += result.uploaded;
failed.push(...result.failed);
uploadedEntries.push(...result.uploadedEntries);
prints.push(...result.prints);
}
return { uploaded, failed, uploadedEntries, prints };
}