mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
feat(categories): add toggle between categories and folders
This commit is contained in:
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";
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user