feat: add collections

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-08 13:09:51 +02:00
1 parent 7b1f718c74
commit b5568d35e6
26 files changed
+1637 -19

No files matched your search

@@ -0,0 +1,48 @@
-- CreateTable
CREATE TABLE "Collection" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameNormalized" TEXT NOT NULL,
"description" TEXT,
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Collection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CollectionItem" (
"id" TEXT NOT NULL,
"collectionId" TEXT NOT NULL,
"printId" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CollectionItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Collection_tags_idx" ON "Collection" USING GIN ("tags");
-- CreateIndex
CREATE UNIQUE INDEX "Collection_userId_nameNormalized_key" ON "Collection"("userId", "nameNormalized");
-- CreateIndex
CREATE INDEX "CollectionItem_collectionId_position_idx" ON "CollectionItem"("collectionId", "position");
-- CreateIndex
CREATE INDEX "CollectionItem_printId_idx" ON "CollectionItem"("printId");
-- CreateIndex
CREATE UNIQUE INDEX "CollectionItem_collectionId_printId_key" ON "CollectionItem"("collectionId", "printId");
-- AddForeignKey
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CollectionItem" ADD CONSTRAINT "CollectionItem_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CollectionItem" ADD CONSTRAINT "CollectionItem_printId_fkey" FOREIGN KEY ("printId") REFERENCES "Print"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "Print" ADD COLUMN "favoritedAt" TIMESTAMP(3),
ADD COLUMN "lastViewedAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "Print_userId_favoritedAt_idx" ON "Print"("userId", "favoritedAt");
-- CreateIndex
CREATE INDEX "Print_userId_lastViewedAt_idx" ON "Print"("userId", "lastViewedAt");
+52 -5
View File
@@ -13,15 +13,16 @@ enum Role {
}
model User {
id String @id @default(cuid())
email String @unique
id String @id @default(cuid())
email String @unique
passwordHash String
displayName String
role Role @default(MEMBER)
role Role @default(MEMBER)
folders Folder[]
prints Print[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
collections Collection[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Folder {
@@ -71,6 +72,12 @@ model Print {
tags String[] @default([])
viewCount Int @default(0)
printCount Int @default(0)
// Backs the two built-in "Favourites"/"Browsing History" pseudo-collections (see
// routes/collections.ts's SYSTEM_COLLECTIONS) -- not a real Collection/CollectionItem row.
// lastViewedAt is stamped on every GET /print/:id alongside viewCount, so "Browsing History"
// falls out of `orderBy: lastViewedAt desc` with no separate event log.
favoritedAt DateTime?
lastViewedAt DateTime?
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
authorId String?
@@ -82,6 +89,7 @@ model Print {
preparedFileId String? @unique
preparedFile PrintFile? @relation("PreparedFile", fields: [preparedFileId], references: [id], onDelete: SetNull)
preparedMetadata Json?
collectionItems CollectionItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -89,6 +97,45 @@ model Print {
@@index([userId, folderId, name])
@@index([tags], type: Gin)
@@index([authorId])
@@index([userId, favoritedAt])
@@index([userId, lastViewedAt])
}
/// A user-curated, named group of models -- acts like a "playlist" for prints. Populated
/// manually (via the create/edit modal) or automatically when importing a MakerWorld
/// collection by URL (see collectionService.findOrCreateCollectionByName), which matches
/// on nameNormalized so re-importing the same MakerWorld collection reuses the same row
/// instead of creating a duplicate.
model Collection {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
nameNormalized String
description String?
tags String[] @default([])
items CollectionItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, nameNormalized])
@@index([tags], type: Gin)
}
/// Join row between Collection and Print (many-to-many), with a position for cover/display
/// ordering.
model CollectionItem {
id String @id @default(cuid())
collectionId String
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
printId String
print Print @relation(fields: [printId], references: [id], onDelete: Cascade)
position Int @default(0)
createdAt DateTime @default(now())
@@unique([collectionId, printId])
@@index([collectionId, position])
@@index([printId])
}
/// A creator/designer on some source platform (MakerWorld today; Printables/Thingiverse etc.
+2
View File
@@ -11,6 +11,7 @@ import foldersRoutes from "./routes/folders";
import printFilesRoutes from "./routes/printFiles";
import settingsRoutes from "./routes/settings";
import importsRoutes from "./routes/imports";
import collectionsRoutes from "./routes/collections";
export function createApp(): Express {
const app = express();
@@ -36,6 +37,7 @@ export function createApp(): Express {
app.use("/api", foldersRoutes);
app.use("/api", printFilesRoutes);
app.use("/api", importsRoutes);
app.use("/api", collectionsRoutes);
// 404 fallback for unmatched routes.
app.use((_req: Request, res: Response) => {
+57 -1
View File
@@ -1,5 +1,5 @@
import fs from "node:fs";
import type { Author, Folder, Plate, PreviewImage, Print, PrintFile, User } from "@prisma/client";
import type { Author, Collection, Folder, Plate, PreviewImage, Print, PrintFile, User } from "@prisma/client";
import { plateThumbExists, plateThumbPath } from "./services/printService";
import { previewImageExists, previewImagePath } from "./services/previewImageService";
import { preparedFilename } from "./services/preparedPrint";
@@ -103,6 +103,7 @@ export type PrintOut = {
slicer_filename: string | null;
view_count: number;
print_count: number;
is_favorite: boolean;
};
export type FolderOut = {
@@ -244,6 +245,61 @@ export function toPrintOut(
slicer_filename: slicerFilename,
view_count: print.viewCount,
print_count: print.printCount,
is_favorite: print.favoritedAt !== null,
};
}
export type SystemCollectionKey = "favorites" | "history";
export type CollectionOut = {
id: string;
name: string;
description: string | null;
tags: string[];
item_count: number;
cover_items: PrintOut[];
created_at: string;
/** Set only for the two built-in "Favourites"/"Browsing History" pseudo-collections (see
* collectionService.ts's SYSTEM_COLLECTIONS) -- the frontend uses this to pick a translated
* display name instead of `name`, and to hide the edit/delete actions those can't support. */
system_key: SystemCollectionKey | null;
};
/** `coverPrints` should already be the up-to-4 cover PrintOuts (see collections.ts), ordered by
* the collection's item position ascending. */
export function toCollectionOut(collection: Collection, itemCount: number, coverPrints: PrintOut[]): CollectionOut {
return {
id: collection.id,
name: collection.name,
description: collection.description,
tags: collection.tags,
item_count: itemCount,
cover_items: coverPrints,
created_at: collection.createdAt.toISOString(),
system_key: null,
};
}
/** Builds the CollectionOut for a built-in pseudo-collection -- there's no backing Collection
* row, so this is assembled directly from the id/key plus the caller's computed item_count and
* cover prints rather than going through toCollectionOut. `name` is an untranslated fallback
* only; the frontend always prefers a translated label keyed off `system_key`. */
export function toSystemCollectionOut(
id: string,
key: SystemCollectionKey,
name: string,
itemCount: number,
coverPrints: PrintOut[],
): CollectionOut {
return {
id,
name,
description: null,
tags: [],
item_count: itemCount,
cover_items: coverPrints,
created_at: new Date(0).toISOString(),
system_key: key,
};
}
+131
View File
@@ -0,0 +1,131 @@
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../db";
import { requireAuth } from "../auth";
import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import {
assertCollectionNameAvailable,
isSystemCollectionId,
listSystemCollectionOuts,
loadSystemCollectionOut,
normalizeCollectionName,
systemCollectionKeyForId,
} from "../services/collectionService";
import { printOutsByIds } from "../services/printLoader";
import { toCollectionOut, type PrintOut } from "../dto";
const router = Router();
router.use(requireAuth);
const collectionSchema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(2000).nullable().optional(),
tags: z.array(z.string()).default([]),
});
const COVER_ITEM_LIMIT = 4;
router.get(
"/collections",
asyncHandler(async (req, res) => {
const [systemCollections, collections] = await Promise.all([
listSystemCollectionOuts(req.userId!),
prisma.collection.findMany({
where: { userId: req.userId },
orderBy: { createdAt: "desc" },
include: {
_count: { select: { items: true } },
items: { orderBy: { position: "asc" }, take: COVER_ITEM_LIMIT },
},
}),
]);
const coverPrintIds = collections.flatMap((c) => c.items.map((i) => i.printId));
const printOuts = await printOutsByIds(req.userId!, coverPrintIds);
res.json([
...systemCollections,
...collections.map((c) =>
toCollectionOut(
c,
c._count.items,
c.items.map((i) => printOuts.get(i.printId)).filter((p): p is PrintOut => Boolean(p)),
),
),
]);
}),
);
router.post(
"/collections",
asyncHandler(async (req, res) => {
const body = parseBody(collectionSchema, req.body);
await assertCollectionNameAvailable(req.userId!, body.name);
const collection = await prisma.collection.create({
data: {
userId: req.userId!,
name: body.name,
nameNormalized: normalizeCollectionName(body.name),
description: body.description?.trim() || null,
tags: body.tags.map((t) => t.trim()).filter(Boolean),
},
});
res.json(toCollectionOut(collection, 0, []));
}),
);
router.get(
"/collection/:id",
asyncHandler(async (req, res) => {
const systemKey = systemCollectionKeyForId(req.params.id);
if (systemKey) {
res.json(await loadSystemCollectionOut(req.userId!, systemKey));
return;
}
const collection = await prisma.collection.findFirst({
where: { id: req.params.id, userId: req.userId },
include: { _count: { select: { items: true } } },
});
if (!collection) throw new HttpError(404, "Collection not found");
res.json(toCollectionOut(collection, collection._count.items, []));
}),
);
router.patch(
"/collection/:id",
asyncHandler(async (req, res) => {
if (isSystemCollectionId(req.params.id)) {
throw new HttpError(400, "This collection can't be edited");
}
const body = parseBody(collectionSchema, req.body);
const collection = await prisma.collection.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!collection) throw new HttpError(404, "Collection not found");
await assertCollectionNameAvailable(req.userId!, body.name, collection.id);
const updated = await prisma.collection.update({
where: { id: collection.id },
data: {
name: body.name,
nameNormalized: normalizeCollectionName(body.name),
description: body.description?.trim() || null,
tags: body.tags.map((t) => t.trim()).filter(Boolean),
},
});
const itemCount = await prisma.collectionItem.count({ where: { collectionId: updated.id } });
res.json(toCollectionOut(updated, itemCount, []));
}),
);
router.delete(
"/collection/:id",
asyncHandler(async (req, res) => {
if (isSystemCollectionId(req.params.id)) {
throw new HttpError(400, "This collection can't be deleted");
}
const collection = await prisma.collection.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!collection) throw new HttpError(404, "Collection not found");
await prisma.collection.delete({ where: { id: collection.id } });
res.json({ ok: true });
}),
);
export default router;
+18
View File
@@ -16,6 +16,7 @@ import {
fetchMakerworldCollectionTitle,
parseMakerworldCollectionUrl,
} from "../services/makerworldCollections";
import { addPrintsToCollection, findOrCreateCollectionByName } from "../services/collectionService";
import { extractZipEntriesToPrints, listZipEntries } from "../services/zipService";
import { toPrintOut, type PrintOut } from "../dto";
@@ -171,6 +172,23 @@ router.post(
if (result.ok) prints.push(result.print);
else failed.push(result.designId);
}
// Re-derive the source MakerWorld collection's title from the same URL the client sent to
// /import/collection/entries, and file every successfully imported print under a Collection
// of that name -- reusing (rather than duplicating) it if one already exists for this user.
if (prints.length) {
const url = await normalizeImportUrl(body.url);
const parsed = parseMakerworldCollectionUrl(url);
if (parsed) {
const bearerToken = extractMakerworldBearerToken(resolveMakerworldCookie(body));
const title = await fetchMakerworldCollectionTitle(parsed.collectionId, bearerToken);
if (title) {
const collection = await findOrCreateCollectionByName(req.userId!, title);
await addPrintsToCollection(collection.id, prints.map((p) => p.id));
}
}
}
res.json({ prints, failed });
}),
);
+48 -2
View File
@@ -15,6 +15,7 @@ import { toPrintOut } from "../dto";
import { loadFullPrint, printOutById } from "../services/printLoader";
import { deleteAllPrintFiles } from "../services/printFileService";
import { sendPrintsZip } from "../services/downloadZip";
import { systemCollectionKeyForId } from "../services/collectionService";
import type { Prisma } from "@prisma/client";
const router = Router();
@@ -28,10 +29,17 @@ function buildPrintWhere(req: Request): Prisma.PrintWhereInput {
.split(",")
.map((id) => id.trim())
.filter(Boolean);
const collectionId = typeof req.query.collection_id === "string" ? req.query.collection_id.trim() : "";
const where: Prisma.PrintWhereInput = { userId: req.userId };
if (folderIds.length === 1) where.folderId = folderIds[0];
else if (folderIds.length > 1) where.folderId = { in: folderIds };
if (collectionId) {
const systemKey = systemCollectionKeyForId(collectionId);
if (systemKey === "favorites") where.favoritedAt = { not: null };
else if (systemKey === "history") where.lastViewedAt = { not: null };
else where.collectionItems = { some: { collectionId } };
}
const tagList = tagsParam
.split(",")
.map((t) => t.trim())
@@ -138,7 +146,18 @@ router.get(
filesByPrint.set(f.printId, list);
}
// The "Favourites"/"Browsing History" pseudo-collections are ordered most-recent-first (by
// when they were favorited/last viewed) rather than the default alphabetical grid order.
const collectionIdParam = typeof req.query.collection_id === "string" ? req.query.collection_id.trim() : "";
const systemKey = systemCollectionKeyForId(collectionIdParam);
const recencyField = systemKey === "favorites" ? "favoritedAt" : systemKey === "history" ? "lastViewedAt" : null;
const sorted = prints.toSorted((a, b) => {
if (recencyField) {
const at = a[recencyField]?.getTime() ?? 0;
const bt = b[recencyField]?.getTime() ?? 0;
if (at !== bt) return bt - at;
}
const nameCmp = a.name.localeCompare(b.name);
if (nameCmp !== 0) return nameCmp;
const af = a.plates[0]?.filename ?? "";
@@ -171,15 +190,42 @@ router.get(
router.get(
"/print/:id",
asyncHandler(async (req, res) => {
// Opening a model's detail page always counts as a view, even for the print's own owner.
// Opening a model's detail page always counts as a view, even for the print's own owner --
// lastViewedAt is what the "Browsing History" pseudo-collection sorts/filters by.
await prisma.print.updateMany({
where: { id: req.params.id, userId: req.userId },
data: { viewCount: { increment: 1 } },
data: { viewCount: { increment: 1 }, lastViewedAt: new Date() },
});
res.json(await printOutById(req.userId!, req.params.id));
}),
);
// ---- POST/DELETE /print/:id/favorite ---------------------------------------------------------
router.post(
"/print/:id/favorite",
asyncHandler(async (req, res) => {
const result = await prisma.print.updateMany({
where: { id: req.params.id, userId: req.userId },
data: { favoritedAt: new Date() },
});
if (result.count === 0) throw new HttpError(404, "Print not found");
res.json(await printOutById(req.userId!, req.params.id));
}),
);
router.delete(
"/print/:id/favorite",
asyncHandler(async (req, res) => {
const result = await prisma.print.updateMany({
where: { id: req.params.id, userId: req.userId },
data: { favoritedAt: null },
});
if (result.count === 0) throw new HttpError(404, "Print not found");
res.json(await printOutById(req.userId!, req.params.id));
}),
);
// ---- POST /print/:id/download --------------------------------------------------------------
router.post(
+124
View File
@@ -0,0 +1,124 @@
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { printOutsByIds } from "./printLoader";
import { toSystemCollectionOut, type CollectionOut, type SystemCollectionKey } from "../dto";
import type { Collection, Prisma } from "@prisma/client";
export function normalizeCollectionName(name: string): string {
return name.trim().toLowerCase();
}
/** Throws 409 if `name` matches one of the built-in pseudo-collections' names ("Favourites",
* "Browsing History") -- those ids are reserved, so a same-named real Collection would just be a
* confusing second card with the identical label. Checked before assertCollectionNameAvailable
* so the reserved-name message wins over a plain "already exists" for these two names. */
function assertCollectionNameNotReserved(name: string): void {
const normalized = normalizeCollectionName(name);
const reserved = Object.values(SYSTEM_COLLECTIONS).find((c) => normalizeCollectionName(c.name) === normalized);
if (reserved) throw new HttpError(409, `"${reserved.name}" is reserved for the built-in collection`);
}
/** Throws 409 if `name` is already taken by another of this user's collections (or is reserved
* for a built-in pseudo-collection). Used by the manual create/rename routes so a raw Prisma
* unique-constraint violation never reaches the client. */
export async function assertCollectionNameAvailable(
userId: string,
name: string,
excludeId?: string,
): Promise<void> {
assertCollectionNameNotReserved(name);
const existing = await prisma.collection.findFirst({
where: {
userId,
nameNormalized: normalizeCollectionName(name),
...(excludeId ? { id: { not: excludeId } } : {}),
},
});
if (existing) throw new HttpError(409, `A collection named "${name.trim()}" already exists`);
}
/** Looks up a user's collection by name (case-insensitive), creating it if it doesn't exist yet.
* Used by the MakerWorld collection import flow: re-importing the same MakerWorld collection
* (identified by its title) lands in the same Collection row instead of creating a duplicate. */
export async function findOrCreateCollectionByName(userId: string, name: string): Promise<Collection> {
const trimmed = name.trim();
const nameNormalized = normalizeCollectionName(trimmed);
const existing = await prisma.collection.findFirst({ where: { userId, nameNormalized } });
if (existing) return existing;
return prisma.collection.create({ data: { userId, name: trimmed, nameNormalized } });
}
/** Adds `printIds` to a collection, skipping any already present, continuing the display
* position from wherever the collection's items currently leave off. */
export async function addPrintsToCollection(collectionId: string, printIds: string[]): Promise<void> {
if (!printIds.length) return;
const last = await prisma.collectionItem.findFirst({
where: { collectionId },
orderBy: { position: "desc" },
});
let nextPosition = (last?.position ?? -1) + 1;
await prisma.collectionItem.createMany({
data: printIds.map((printId) => ({ collectionId, printId, position: nextPosition++ })),
skipDuplicates: true,
});
}
// ---- Built-in "Favourites" / "Browsing History" pseudo-collections ----------------------------
//
// These aren't real Collection rows -- they're synthesized on the fly from Print.favoritedAt /
// Print.lastViewedAt (a print is "in" one iff that column is non-null, ordered most-recent-first)
// so there's nothing to keep in sync, and no join-table membership to manage. Their ids ("favorites"
// / "history") are reserved and can never collide with a real Collection's cuid.
const SYSTEM_COLLECTION_COVER_LIMIT = 4;
type SystemCollectionMeta = { id: string; name: string; field: "favoritedAt" | "lastViewedAt" };
export const SYSTEM_COLLECTIONS: Record<SystemCollectionKey, SystemCollectionMeta> = {
favorites: { id: "favorites", name: "Favourites", field: "favoritedAt" },
history: { id: "history", name: "Browsing History", field: "lastViewedAt" },
};
const SYSTEM_COLLECTION_IDS = new Map<string, SystemCollectionKey>(
(Object.keys(SYSTEM_COLLECTIONS) as SystemCollectionKey[]).map((key) => [SYSTEM_COLLECTIONS[key].id, key]),
);
export function systemCollectionKeyForId(id: string): SystemCollectionKey | null {
return SYSTEM_COLLECTION_IDS.get(id) ?? null;
}
export function isSystemCollectionId(id: string): boolean {
return SYSTEM_COLLECTION_IDS.has(id);
}
function systemCollectionWhere(userId: string, field: "favoritedAt" | "lastViewedAt"): Prisma.PrintWhereInput {
return field === "favoritedAt" ? { userId, favoritedAt: { not: null } } : { userId, lastViewedAt: { not: null } };
}
function systemCollectionOrderBy(field: "favoritedAt" | "lastViewedAt"): Prisma.PrintOrderByWithRelationInput {
return field === "favoritedAt" ? { favoritedAt: "desc" } : { lastViewedAt: "desc" };
}
export async function loadSystemCollectionOut(userId: string, key: SystemCollectionKey): Promise<CollectionOut> {
const meta = SYSTEM_COLLECTIONS[key];
const where = systemCollectionWhere(userId, meta.field);
const [itemCount, coverRows] = await Promise.all([
prisma.print.count({ where }),
prisma.print.findMany({
where,
orderBy: systemCollectionOrderBy(meta.field),
take: SYSTEM_COLLECTION_COVER_LIMIT,
select: { id: true },
}),
]);
const coverIds = coverRows.map((r) => r.id);
const printOuts = await printOutsByIds(userId, coverIds);
const coverPrints = coverIds.map((id) => printOuts.get(id)).filter((p): p is NonNullable<typeof p> => Boolean(p));
return toSystemCollectionOut(meta.id, key, meta.name, itemCount, coverPrints);
}
export async function listSystemCollectionOuts(userId: string): Promise<CollectionOut[]> {
return Promise.all(
(Object.keys(SYSTEM_COLLECTIONS) as SystemCollectionKey[]).map((key) => loadSystemCollectionOut(userId, key)),
);
}
+38
View File
@@ -29,3 +29,41 @@ export async function printOutById(userId: string, printId: string): Promise<Pri
const full = await loadFullPrint(userId, printId);
return toPrintOut(full.print, full.plates, full.files, full.preparedFile, full.print.author, full.previewImages);
}
function groupByPrintId<T extends { printId: string }>(rows: T[]): Map<string, T[]> {
const map = new Map<string, T[]>();
for (const row of rows) {
const list = map.get(row.printId);
if (list) list.push(row);
else map.set(row.printId, [row]);
}
return map;
}
/** Batch-loads full PrintOuts for a set of print ids (e.g. a collection's cover thumbnails) in a
* handful of `IN`-scoped queries rather than one loadFullPrint call per id. Ids that don't belong
* to `userId` are silently omitted from the returned map. */
export async function printOutsByIds(userId: string, printIds: string[]): Promise<Map<string, PrintOut>> {
const out = new Map<string, PrintOut>();
if (!printIds.length) return out;
const [prints, plates, files, previewImages] = await Promise.all([
prisma.print.findMany({ where: { id: { in: printIds }, userId }, include: { author: true } }),
prisma.plate.findMany({ where: { printId: { in: printIds } }, orderBy: { position: "asc" } }),
prisma.printFile.findMany({ where: { printId: { in: printIds } } }),
prisma.previewImage.findMany({ where: { printId: { in: printIds } }, orderBy: { position: "asc" } }),
]);
const platesByPrint = groupByPrintId(plates);
const filesByPrint = groupByPrintId(files);
const previewsByPrint = groupByPrintId(previewImages);
for (const print of prints) {
const printFiles = filesByPrint.get(print.id) || [];
const preparedFile = print.preparedFileId
? printFiles.find((f) => f.id === print.preparedFileId) ?? null
: null;
out.set(
print.id,
toPrintOut(print, platesByPrint.get(print.id) || [], printFiles, preparedFile, print.author, previewsByPrint.get(print.id) || []),
);
}
return out;
}
+239
View File
@@ -0,0 +1,239 @@
import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createApp } from "../src/app";
import { addPrintsToCollection } from "../src/services/collectionService";
const app = createApp();
let token: string;
function tmpFile(name: string, contents: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "printstash-test-"));
const p = path.join(dir, name);
fs.writeFileSync(p, contents);
return p;
}
beforeAll(async () => {
const email = `collections-test-${Date.now()}@example.com`;
const res = await request(app)
.post("/api/register")
.send({ displayName: "Collections Test", email, password: "password123" });
if (res.status !== 200) {
throw new Error(`Failed to register during test setup: ${res.status} ${JSON.stringify(res.body)}`);
}
token = res.body.token;
});
function auth() {
return { Authorization: `Bearer ${token}` };
}
describe("collections routes", () => {
let id: string;
it("creates a collection", async () => {
const res = await request(app)
.post("/api/collections")
.set(auth())
.send({ name: "My Collection", description: "desc", tags: ["a", "b"] });
expect(res.status).toBe(200);
expect(res.body.name).toBe("My Collection");
expect(res.body.item_count).toBe(0);
id = res.body.id;
});
it("lists collections", async () => {
const res = await request(app).get("/api/collections").set(auth());
expect(res.status).toBe(200);
expect(res.body.some((c: { id: string }) => c.id === id)).toBe(true);
});
it("rejects a duplicate name with 409", async () => {
const res = await request(app).post("/api/collections").set(auth()).send({ name: "My Collection" });
expect(res.status).toBe(409);
});
it("gets a single collection", async () => {
const res = await request(app).get(`/api/collection/${id}`).set(auth());
expect(res.status).toBe(200);
expect(res.body.id).toBe(id);
});
it("updates a collection", async () => {
const res = await request(app)
.patch(`/api/collection/${id}`)
.set(auth())
.send({ name: "Renamed Collection", tags: ["c"] });
expect(res.status).toBe(200);
expect(res.body.name).toBe("Renamed Collection");
expect(res.body.tags).toEqual(["c"]);
});
it("filters /prints by collection_id (empty collection -> empty list)", async () => {
const res = await request(app).get("/api/prints").set(auth()).query({ collection_id: id });
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("deletes a collection", async () => {
const res = await request(app).delete(`/api/collection/${id}`).set(auth());
expect(res.status).toBe(200);
const after = await request(app).get(`/api/collection/${id}`).set(auth());
expect(after.status).toBe(404);
});
});
describe("collection/print deletion cascades", () => {
it("deleting a collection does not delete the models in it", async () => {
const uploadRes = await request(app)
.post("/api/upload")
.set(auth())
.attach("files", tmpFile("keep-me.stl", "solid keep endsolid"));
expect(uploadRes.status).toBe(200);
const printId = uploadRes.body.prints[0].id;
const collectionRes = await request(app)
.post("/api/collections")
.set(auth())
.send({ name: "Cascade Test A" });
expect(collectionRes.status).toBe(200);
const collectionId = collectionRes.body.id;
await addPrintsToCollection(collectionId, [printId]);
const deleteRes = await request(app).delete(`/api/collection/${collectionId}`).set(auth());
expect(deleteRes.status).toBe(200);
const printRes = await request(app).get(`/api/print/${printId}`).set(auth());
expect(printRes.status).toBe(200);
expect(printRes.body.id).toBe(printId);
await request(app).delete(`/api/print/${printId}`).set(auth());
});
it("deleting a model removes it from every category and collection it's assigned to", async () => {
const folderRes = await request(app)
.post("/api/folders")
.set(auth())
.send({ name: "Cascade Folder" });
expect(folderRes.status).toBe(200);
const folderId = folderRes.body.id;
const uploadRes = await request(app)
.post("/api/upload")
.set(auth())
.field("folder_id", folderId)
.attach("files", tmpFile("remove-me.stl", "solid remove endsolid"));
expect(uploadRes.status).toBe(200);
const printId = uploadRes.body.prints[0].id;
const collectionRes = await request(app)
.post("/api/collections")
.set(auth())
.send({ name: "Cascade Test B" });
expect(collectionRes.status).toBe(200);
const collectionId = collectionRes.body.id;
await addPrintsToCollection(collectionId, [printId]);
const beforeDelete = await request(app).get(`/api/collection/${collectionId}`).set(auth());
expect(beforeDelete.body.item_count).toBe(1);
const deleteRes = await request(app).delete(`/api/print/${printId}`).set(auth());
expect(deleteRes.status).toBe(200);
// Gone from the category it was filed under.
const folderPrints = await request(app).get("/api/prints").set(auth()).query({ folder_id: folderId });
expect(folderPrints.body.some((p: { id: string }) => p.id === printId)).toBe(false);
// Gone from the collection it was assigned to.
const afterDelete = await request(app).get(`/api/collection/${collectionId}`).set(auth());
expect(afterDelete.body.item_count).toBe(0);
const collectionPrints = await request(app).get("/api/prints").set(auth()).query({ collection_id: collectionId });
expect(collectionPrints.body).toEqual([]);
await request(app).delete(`/api/collection/${collectionId}`).set(auth());
await request(app).delete(`/api/folder/${folderId}`).set(auth());
});
});
describe("system collections (Favourites / Browsing History)", () => {
it("always lists both, even for a brand new user with no prints", async () => {
const res = await request(app).get("/api/collections").set(auth());
expect(res.status).toBe(200);
const favorites = res.body.find((c: { id: string }) => c.id === "favorites");
const history = res.body.find((c: { id: string }) => c.id === "history");
expect(favorites).toMatchObject({ system_key: "favorites" });
expect(history).toMatchObject({ system_key: "history" });
});
it("rejects creating a real collection with a reserved name", async () => {
const res = await request(app).post("/api/collections").set(auth()).send({ name: "Favourites" });
expect(res.status).toBe(409);
});
it("rejects editing or deleting either system collection", async () => {
const patchRes = await request(app).patch("/api/collection/favorites").set(auth()).send({ name: "Nope" });
expect(patchRes.status).toBe(400);
const deleteRes = await request(app).delete("/api/collection/history").set(auth());
expect(deleteRes.status).toBe(400);
});
it("favorite/unfavorite toggles a print's membership in Favourites", async () => {
const uploadRes = await request(app)
.post("/api/upload")
.set(auth())
.attach("files", tmpFile("fav-me.stl", "solid fav endsolid"));
const printId = uploadRes.body.prints[0].id;
const favorited = await request(app).post(`/api/print/${printId}/favorite`).set(auth());
expect(favorited.status).toBe(200);
expect(favorited.body.is_favorite).toBe(true);
const collection = await request(app).get("/api/collection/favorites").set(auth());
expect(collection.body.item_count).toBe(1);
const listed = await request(app).get("/api/prints").set(auth()).query({ collection_id: "favorites" });
expect(listed.body.map((p: { id: string }) => p.id)).toEqual([printId]);
const unfavorited = await request(app).delete(`/api/print/${printId}/favorite`).set(auth());
expect(unfavorited.status).toBe(200);
expect(unfavorited.body.is_favorite).toBe(false);
const collectionAfter = await request(app).get("/api/collection/favorites").set(auth());
expect(collectionAfter.body.item_count).toBe(0);
await request(app).delete(`/api/print/${printId}`).set(auth());
});
it("tracks Browsing History from most recently viewed to oldest", async () => {
const upload = await request(app)
.post("/api/upload")
.set(auth())
.field("mode", "separate")
.attach("files", tmpFile("history-a.stl", "solid a endsolid"))
.attach("files", tmpFile("history-b.stl", "solid b endsolid"));
expect(upload.status).toBe(200);
const [printA, printB] = upload.body.prints.map((p: { id: string }) => p.id);
// Viewing (GET /print/:id) is what records history -- listing/uploading does not.
await request(app).get(`/api/print/${printA}`).set(auth());
await new Promise((resolve) => setTimeout(resolve, 5));
await request(app).get(`/api/print/${printB}`).set(auth());
const listed = await request(app).get("/api/prints").set(auth()).query({ collection_id: "history" });
const ids = listed.body.map((p: { id: string }) => p.id);
expect(ids.indexOf(printB)).toBeLessThan(ids.indexOf(printA));
// Re-viewing an older entry bumps it back to the top.
await new Promise((resolve) => setTimeout(resolve, 5));
await request(app).get(`/api/print/${printA}`).set(auth());
const listedAgain = await request(app).get("/api/prints").set(auth()).query({ collection_id: "history" });
const idsAgain = listedAgain.body.map((p: { id: string }) => p.id);
expect(idsAgain.indexOf(printA)).toBeLessThan(idsAgain.indexOf(printB));
await request(app).delete(`/api/print/${printA}`).set(auth());
await request(app).delete(`/api/print/${printB}`).set(auth());
});
});
+10
View File
@@ -8,6 +8,8 @@ import AppLayout from "./components/Layout/AppLayout";
import DashboardPage from "./pages/DashboardPage";
import ModelsPage from "./pages/ModelsPage";
import ModelDetailPage from "./pages/ModelDetailPage";
import CollectionsPage from "./pages/CollectionsPage";
import CollectionDetailPage from "./pages/CollectionDetailPage";
import AuthorPage from "./pages/AuthorPage";
import AuthPage from "./pages/AuthPage";
import SettingsPage from "./pages/SettingsPage";
@@ -103,6 +105,14 @@ function AppShell({
/>
}
/>
<Route
path="/models/collections"
element={<CollectionsPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} />}
/>
<Route
path="/models/collections/:collectionId"
element={<CollectionDetailPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} />}
/>
<Route
path="/models/:printId"
element={<ModelDetailPage theme={resolvedTheme} onUnauthorized={onUnauthorized} />}
+64
View File
@@ -0,0 +1,64 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk, readErrorMessage } from "./client";
import type { Print } from "./prints";
export type SystemCollectionKey = "favorites" | "history";
export type Collection = {
id: string;
name: string;
description: string | null;
tags: string[];
item_count: number;
cover_items: Print[];
created_at: string;
/** Set only for the built-in "Favourites"/"Browsing History" pseudo-collections -- these can't
* be edited or deleted, and their card/detail title should come from a translated label keyed
* off this instead of `name`. */
system_key: SystemCollectionKey | null;
};
export type CollectionInput = {
name: string;
description?: string | null;
tags?: string[];
};
export const collectionsApi = {
list: async (): Promise<Collection[]> => {
const res = await fetch(`${apiBase()}/collections`, { headers: authHeaders() });
assertOk(res, "Failed to list collections");
return res.json();
},
get: async (id: string): Promise<Collection> => {
const res = await fetch(`${apiBase()}/collection/${id}`, { headers: authHeaders() });
assertOk(res, "Failed to load collection");
return res.json();
},
create: async (input: CollectionInput): Promise<Collection> => {
const res = await fetch(`${apiBase()}/collections`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(await readErrorMessage(res, "Create collection failed"));
return res.json();
},
update: async (id: string, input: CollectionInput): Promise<Collection> => {
const res = await fetch(`${apiBase()}/collection/${id}`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(await readErrorMessage(res, "Update collection failed"));
return res.json();
},
delete: async (id: string): Promise<void> => {
const res = await fetch(`${apiBase()}/collection/${id}`, { method: "DELETE", headers: authHeaders() });
assertOk(res, "Delete collection failed");
},
};
+19
View File
@@ -69,6 +69,7 @@ export type Print = {
slicer_filename?: string | null;
view_count: number;
print_count: number;
is_favorite: boolean;
};
export type ListPrintsResult = {
@@ -92,6 +93,7 @@ export const printsApi = {
q?: string;
tags?: string[];
folder_id?: string | string[];
collection_id?: string;
limit?: number;
offset?: number;
} = {}): Promise<ListPrintsResult> => {
@@ -101,6 +103,7 @@ export const printsApi = {
if (params.folder_id && params.folder_id.length) {
qs.set("folder_id", Array.isArray(params.folder_id) ? params.folder_id.join(",") : params.folder_id);
}
if (params.collection_id) qs.set("collection_id", params.collection_id);
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
if (typeof params.offset === "number") qs.set("offset", String(params.offset));
const res = await fetch(`${apiBase()}/prints?${qs.toString()}`, {
@@ -356,4 +359,20 @@ export const printsApi = {
assertOk(res, "Failed to record download");
return res.json();
},
/** Adds/removes this print from the built-in "Favourites" pseudo-collection -- toggled from
* the model detail page's header. */
favorite: async (id: string): Promise<Print> => {
const res = await fetch(`${apiBase()}/print/${id}/favorite`, { method: "POST", headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to favorite"));
return res.json();
},
unfavorite: async (id: string): Promise<Print> => {
const res = await fetch(`${apiBase()}/print/${id}/favorite`, { method: "DELETE", headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to unfavorite"));
return res.json();
},
};
@@ -42,6 +42,13 @@ function useRouteChrome() {
let showBack = false;
if (path === "/") {
title = t("sidebar.dashboard");
} else if (path === "/models/collections") {
title = t("models:collections.pageTitle");
showBack = true;
} else if (path.startsWith("/models/collections/")) {
// Overridden by CollectionDetailPage's usePageHeader once the collection loads.
title = t("models:collections.pageTitle");
showBack = true;
} else if (path.startsWith("/models/")) {
title = t("models:pageTitle");
showBack = true;
@@ -12,6 +12,7 @@ import Collapse from "@mui/material/Collapse";
import Tooltip from "@mui/material/Tooltip";
import SpaceDashboardIcon from "@mui/icons-material/SpaceDashboard";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import CollectionsIcon from "@mui/icons-material/Collections";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import SettingsIcon from "@mui/icons-material/Settings";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
@@ -65,6 +66,7 @@ export default function Sidebar({ isAdmin }: Props) {
const onDashboard = location.pathname === "/";
const onModels = location.pathname.startsWith("/models") || location.pathname.startsWith("/authors");
const onCollections = location.pathname.startsWith("/models/collections");
const onAdmin = location.pathname.startsWith("/admin-settings");
const adminOpen = adminExpanded || onAdmin;
@@ -140,12 +142,32 @@ export default function Sidebar({ isAdmin }: Props) {
onClick={() => navigate("/models")}
/>
) : (
<ListItemButton selected={onModels} onClick={() => navigate("/models")} sx={{ borderRadius: 1, mb: 0.5 }}>
<ListItemIcon sx={{ minWidth: 30 }}>
<ViewInArIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("sidebar.models")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
<>
<ListItemButton
selected={onModels && !onCollections}
onClick={() => navigate("/models")}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<ViewInArIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("sidebar.models")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
<Collapse in={onModels}>
<List disablePadding>
<ListItemButton
selected={onCollections}
onClick={() => navigate("/models/collections")}
sx={{ borderRadius: 1, mb: 0.5, pl: 4 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<CollectionsIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("sidebar.collections")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
</List>
</Collapse>
</>
)}
{isAdmin && (
+39 -2
View File
@@ -75,7 +75,8 @@
"expandSidebar": "Expand sidebar",
"collapseSidebar": "Collapse sidebar",
"foldersHeadingWithCount": "Folders ({{count}})",
"models": "Models"
"models": "Models",
"collections": "Collections"
},
"uploadBar": {
"uploading": "Uploading…",
@@ -485,12 +486,48 @@
"tags": "Tags",
"noTags": "No tags yet.",
"previousImage": "Previous image",
"nextImage": "Next image"
"nextImage": "Next image",
"addToFavorites": "Add to Favourites",
"removeFromFavorites": "Remove from Favourites",
"favoriteFailed": "Failed to update favourites. Please try again."
},
"author": {
"pageTitle": "Author",
"comingSoon": "Author pages are coming soon."
},
"collections": {
"pageTitle": "Collections",
"newCollection": "New Collection",
"empty": "No collections yet.",
"system": {
"favorites": "Favourites",
"history": "Browsing History"
},
"card": {
"empty": "No models yet",
"itemCount_one": "{{count}} model",
"itemCount_other": "{{count}} models"
},
"form": {
"createTitle": "New Collection",
"editTitle": "Edit Collection",
"nameLabel": "Name",
"descriptionLabel": "Description",
"tagsLabel": "Tags",
"tagsPlaceholder": "Add tags",
"create": "Create",
"saveFailed": "Failed to save the collection. Please try again."
},
"detail": {
"empty": "No models in this collection yet.",
"confirmDelete": "Delete \"{{name}}\"? Models in it are not deleted.",
"deleteFailed": "Failed to delete the collection."
},
"errors": {
"loadFailed": "Unable to load collections.",
"notFound": "Collection not found."
}
},
"errors": {
"loadFailed": "Unable to load models.",
"loadCategoriesFailed": "Unable to load categories.",
+39 -2
View File
@@ -75,7 +75,8 @@
"expandSidebar": "Išskleisti šoninę juostą",
"collapseSidebar": "Suskleisti šoninę juostą",
"foldersHeadingWithCount": "Aplankai ({{count}})",
"models": "Modeliai"
"models": "Modeliai",
"collections": "Kolekcijos"
},
"uploadBar": {
"uploading": "Įkeliama…",
@@ -486,12 +487,48 @@
"tags": "Žymos",
"noTags": "Kol kas nėra žymų.",
"previousImage": "Ankstesnis paveikslėlis",
"nextImage": "Kitas paveikslėlis"
"nextImage": "Kitas paveikslėlis",
"addToFavorites": "Pridėti prie mėgstamų",
"removeFromFavorites": "Pašalinti iš mėgstamų",
"favoriteFailed": "Nepavyko atnaujinti mėgstamų. Bandykite dar kartą."
},
"author": {
"pageTitle": "Autorius",
"comingSoon": "Autorių puslapiai netrukus bus pasiekiami."
},
"collections": {
"pageTitle": "Kolekcijos",
"newCollection": "Nauja kolekcija",
"empty": "Kol kas nėra kolekcijų.",
"system": {
"favorites": "Mėgstami",
"history": "Naršymo istorija"
},
"card": {
"empty": "Kol kas nėra modelių",
"itemCount_one": "{{count}} modelis",
"itemCount_other": "{{count}} modeliai"
},
"form": {
"createTitle": "Nauja kolekcija",
"editTitle": "Redaguoti kolekciją",
"nameLabel": "Pavadinimas",
"descriptionLabel": "Aprašymas",
"tagsLabel": "Žymos",
"tagsPlaceholder": "Pridėti žymų",
"create": "Sukurti",
"saveFailed": "Nepavyko išsaugoti kolekcijos. Bandykite dar kartą."
},
"detail": {
"empty": "Šioje kolekcijoje kol kas nėra modelių.",
"confirmDelete": "Ištrinti „{{name}}“? Joje esantys modeliai nebus ištrinti.",
"deleteFailed": "Nepavyko ištrinti kolekcijos."
},
"errors": {
"loadFailed": "Nepavyko įkelti kolekcijų.",
"notFound": "Kolekcija nerasta."
}
},
"errors": {
"loadFailed": "Nepavyko įkelti modelių.",
"loadCategoriesFailed": "Nepavyko įkelti kategorijų.",
@@ -0,0 +1,96 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import CircularProgress from "@mui/material/CircularProgress";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import { UnauthorizedError } from "../../api/client";
import { type Collection, type CollectionInput, collectionsApi } from "../../api/collections";
import { useConfirm } from "../../components/ConfirmProvider";
import CollectionFormModal from "../CollectionsPage/CollectionFormModal";
type Props = {
collection: Collection;
onUpdated: (collection: Collection) => void;
onUnauthorized?: () => void;
onDeleted: () => void;
};
/** The collection detail page's "..." header menu: Edit (opens the same create/edit modal used
* from the Collections grid, prefilled) and Delete (confirm, then delete + navigate back). */
export default function CollectionActionsMenu({ collection, onUpdated, onUnauthorized, onDeleted }: Props) {
const { t } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const closeMenu = () => setAnchorEl(null);
const handleEdit = async (input: CollectionInput) => {
try {
onUpdated(await collectionsApi.update(collection.id, input));
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
throw err;
}
};
const handleDelete = async () => {
closeMenu();
const confirmed = await confirmDialog({
message: t("models:collections.detail.confirmDelete", { name: collection.name }),
destructive: true,
});
if (!confirmed) return;
setDeleting(true);
try {
await collectionsApi.delete(collection.id);
onDeleted();
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
alert(t("models:collections.detail.deleteFailed"));
} finally {
setDeleting(false);
}
};
return (
<>
<IconButton
size="small"
onClick={e => setAnchorEl(e.currentTarget)}
aria-label={t("common:more") ?? undefined}
disabled={deleting}
>
{deleting ? <CircularProgress size={18} /> : <MoreVertIcon fontSize="small" />}
</IconButton>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
<MenuItem onClick={() => { closeMenu(); setEditOpen(true); }}>
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t("common:edit")}</ListItemText>
</MenuItem>
<MenuItem onClick={handleDelete}>
<ListItemIcon><DeleteIcon fontSize="small" color="error" /></ListItemIcon>
<ListItemText sx={{ color: "error.main" }}>{t("common:delete")}</ListItemText>
</MenuItem>
</Menu>
{editOpen && (
<CollectionFormModal collection={collection} onClose={() => setEditOpen(false)} onSubmit={handleEdit} />
)}
</>
);
}
@@ -0,0 +1,155 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import { UnauthorizedError } from "../../api/client";
import { type Collection, collectionsApi } from "../../api/collections";
import { type Print, printsApi } from "../../api/prints";
import { type PreviewMode } from "../../api/settings";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { collectionDisplayName } from "../../utils/collectionDisplay";
import ModelCard from "../ModelsPage/ModelCard";
import CollectionActionsMenu from "./CollectionActionsMenu";
const PAGE_SIZE = 24;
type Props = {
theme: ResolvedTheme;
previewMode: PreviewMode;
onUnauthorized?: () => void;
};
export default function CollectionDetailPage({ theme, previewMode, onUnauthorized }: Props) {
const { collectionId } = useParams<{ collectionId: string }>();
const navigate = useNavigate();
const { t } = useTranslation(["models", "common"]);
const [collection, setCollection] = useState<Collection | null>(null);
const [notFound, setNotFound] = useState(false);
const [items, setItems] = useState<Print[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const goBack = () => navigate("/models/collections");
usePageHeader({
title: collection ? collectionDisplayName(collection, t) : undefined,
actions: collection && !collection.system_key ? (
<CollectionActionsMenu
collection={collection}
onUpdated={setCollection}
onUnauthorized={onUnauthorized}
onDeleted={goBack}
/>
) : undefined,
});
const handleError = (err: unknown, message?: string) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return true;
}
console.error(err);
if (message) alert(message);
return false;
};
useEffect(() => {
if (!collectionId) return;
let cancelled = false;
setLoading(true);
setNotFound(false);
(async () => {
try {
const [collectionResult, printsResult] = await Promise.all([
collectionsApi.get(collectionId),
printsApi.list({ collection_id: collectionId, limit: PAGE_SIZE, offset: 0 }),
]);
if (cancelled) return;
setCollection(collectionResult);
setItems(printsResult.items);
setOffset(printsResult.items.length);
setHasMore(printsResult.hasMore);
} catch (err) {
if (cancelled) return;
if (handleError(err)) return;
setNotFound(true);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collectionId]);
const loadMore = async () => {
if (loadingMore || !hasMore || !collectionId) return;
setLoadingMore(true);
try {
const result = await printsApi.list({ collection_id: collectionId, limit: PAGE_SIZE, offset });
setItems(prev => [...prev, ...result.items]);
setOffset(offset + result.items.length);
setHasMore(result.hasMore);
} catch (err) {
handleError(err, t("models:errors.loadFailed"));
} finally {
setLoadingMore(false);
}
};
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
</Stack>
);
}
if (notFound || !collection) {
return (
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
<Typography variant="body2">{t("models:collections.errors.notFound")}</Typography>
</Stack>
);
}
return (
<Stack spacing={2}>
{collection.description && (
<Typography variant="body2" color="text.secondary">{collection.description}</Typography>
)}
{items.length ? (
<Stack spacing={2}>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", columnGap: "20px", rowGap: "20px" }}>
{items.map(item => (
<ModelCard key={item.id} item={item} theme={theme} previewMode={previewMode} />
))}
</Box>
{hasMore && (
<Stack direction="row" justifyContent="center">
<Button
variant="outlined"
size="small"
onClick={loadMore}
disabled={loadingMore}
startIcon={loadingMore ? <CircularProgress size={14} /> : undefined}
>
{loadingMore ? t("models:grid.loadingMore") : t("models:grid.loadMore")}
</Button>
</Stack>
)}
</Stack>
) : (
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
<Typography variant="body2">{t("models:collections.detail.empty")}</Typography>
</Stack>
)}
</Stack>
);
}
@@ -0,0 +1,150 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Inventory2OutlinedIcon from "@mui/icons-material/Inventory2Outlined";
import LockIcon from "@mui/icons-material/Lock";
import { type Collection } from "../../api/collections";
import { type PreviewMode } from "../../api/settings";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
import { collectionDisplayName } from "../../utils/collectionDisplay";
type Props = {
collection: Collection;
theme: ResolvedTheme;
previewMode: PreviewMode;
};
const COVER_TILE_LIMIT = 4;
/** One cell of the cover grid (or the single full-bleed cover when there's only one model) --
* clicking it goes straight to that model's detail page, independent of the card's own
* navigate-to-collection click target on the name bar below. */
function CoverTile({
print,
theme,
previewMode,
overlayCount,
}: {
print: Collection["cover_items"][number];
theme: ResolvedTheme;
previewMode: PreviewMode;
overlayCount?: number;
}) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
return (
<Box
onClick={e => {
e.stopPropagation();
navigate(`/models/${print.id}`);
}}
sx={{ position: "relative", width: "100%", height: "100%", cursor: "pointer", overflow: "hidden" }}
>
{renderPreviewContent(print, "card", theme, t, previewMode)}
{Boolean(overlayCount && overlayCount > 0) && (
<Box
sx={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "rgba(0, 0, 0, 0.55)",
}}
>
<Typography variant="subtitle1" fontWeight={700} sx={{ color: "#fff" }}>
+{overlayCount}
</Typography>
</Box>
)}
</Box>
);
}
export default function CollectionCard({ collection, theme, previewMode }: Props) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
const coverItems = collection.cover_items.slice(0, COVER_TILE_LIMIT);
const extraCount = collection.item_count > COVER_TILE_LIMIT ? collection.item_count - COVER_TILE_LIMIT : 0;
const displayName = collectionDisplayName(collection, t);
return (
<Paper
variant="outlined"
sx={{
overflow: "hidden",
borderRadius: "12px",
borderColor: "divider",
bgcolor: (muiTheme) => (muiTheme.palette.mode === "dark" ? muiTheme.palette.grey[800] : muiTheme.palette.grey[100]),
}}
>
<Box
sx={{
width: "100%",
aspectRatio: "4 / 3",
bgcolor: (muiTheme) => (muiTheme.palette.mode === "dark" ? muiTheme.palette.grey[900] : muiTheme.palette.grey[200]),
}}
>
{coverItems.length === 0 && (
<Stack alignItems="center" justifyContent="center" sx={{ width: "100%", height: "100%", color: "text.disabled" }}>
<Typography variant="caption">{t("models:collections.card.empty")}</Typography>
</Stack>
)}
{coverItems.length === 1 && (
<CoverTile print={coverItems[0]} theme={theme} previewMode={previewMode} />
)}
{coverItems.length > 1 && (
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gridTemplateRows: "repeat(2, 1fr)",
gap: "2px",
width: "100%",
height: "100%",
}}
>
{coverItems.map((item, idx) => (
<CoverTile
key={item.id}
print={item}
theme={theme}
previewMode={previewMode}
overlayCount={idx === coverItems.length - 1 ? extraCount : undefined}
/>
))}
</Box>
)}
</Box>
<Box
onClick={() => navigate(`/models/collections/${collection.id}`)}
sx={{
p: 1.5,
cursor: "pointer",
transition: "background-color .15s ease",
"&:hover": { bgcolor: "background.paper" },
}}
>
<Stack direction="row" alignItems="center" spacing={0.5} minWidth={0}>
{collection.system_key && (
<LockIcon sx={{ fontSize: 14, color: "text.disabled", flexShrink: 0 }} />
)}
<Typography variant="body2" fontWeight={600} noWrap title={displayName}>
{displayName}
</Typography>
</Stack>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.5, color: "#858585" }}>
<Inventory2OutlinedIcon sx={{ fontSize: 14 }} />
<Typography variant="caption">
{t("models:collections.card.itemCount", { count: collection.item_count })}
</Typography>
</Stack>
</Box>
</Paper>
);
}
@@ -0,0 +1,98 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import Alert from "@mui/material/Alert";
import CircularProgress from "@mui/material/CircularProgress";
import type { Collection, CollectionInput } from "../../api/collections";
import TagInput from "../../components/TagInput";
type Props = {
collection?: Collection | null;
onClose: () => void;
onSubmit: (input: CollectionInput) => Promise<void>;
};
/** Shared create/edit dialog for a Collection -- name, description, tags. Edit mode is just
* create mode prefilled from `collection`, per the spec ("Edit brings the creation modal"). */
export default function CollectionFormModal({ collection, onClose, onSubmit }: Props) {
const { t } = useTranslation(["models", "common"]);
const [name, setName] = useState(collection?.name || "");
const [description, setDescription] = useState(collection?.description || "");
const [tags, setTags] = useState<string[]>(collection?.tags || []);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = Boolean(collection);
const trimmedName = name.trim();
const handleSubmit = async () => {
if (!trimmedName || saving) return;
setSaving(true);
setError(null);
try {
await onSubmit({ name: trimmedName, description: description.trim() || null, tags });
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : t("models:collections.form.saveFailed"));
} finally {
setSaving(false);
}
};
return (
<Dialog open onClose={saving ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle>
{isEdit ? t("models:collections.form.editTitle") : t("models:collections.form.createTitle")}
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 0.5 }}>
{error && <Alert severity="error">{error}</Alert>}
<TextField
label={t("models:collections.form.nameLabel")}
value={name}
onChange={e => setName(e.target.value)}
disabled={saving}
fullWidth
// Deliberate: focus the name field the moment the dialog opens.
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus
required
/>
<TextField
label={t("models:collections.form.descriptionLabel")}
value={description}
onChange={e => setDescription(e.target.value)}
disabled={saving}
fullWidth
multiline
minRows={3}
/>
<Stack spacing={0.5}>
<Typography variant="body2" color="text.secondary">
{t("models:collections.form.tagsLabel")}
</Typography>
<TagInput value={tags} onChange={setTags} placeholder={t("models:collections.form.tagsPlaceholder") ?? undefined} />
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={saving}>{t("common:cancel")}</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={saving || !trimmedName}
startIcon={saving ? <CircularProgress size={14} color="inherit" /> : undefined}
>
{isEdit ? t("common:save") : t("models:collections.form.create")}
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import AddIcon from "@mui/icons-material/Add";
import { UnauthorizedError } from "../../api/client";
import { type Collection, type CollectionInput, collectionsApi } from "../../api/collections";
import { type PreviewMode } from "../../api/settings";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import CollectionCard from "./CollectionCard";
import CollectionFormModal from "./CollectionFormModal";
type Props = {
onUnauthorized?: () => void;
theme: ResolvedTheme;
previewMode: PreviewMode;
};
export default function CollectionsPage({ onUnauthorized, theme, previewMode }: Props) {
const { t } = useTranslation(["models", "common"]);
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const handleError = (err: unknown, message?: string) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return true;
}
console.error(err);
if (message) alert(message);
return false;
};
const load = async () => {
setLoading(true);
try {
setCollections(await collectionsApi.list());
} catch (err) {
handleError(err, t("models:collections.errors.loadFailed"));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const createCollection = async (input: CollectionInput) => {
await collectionsApi.create(input);
await load();
};
return (
<Stack spacing={2}>
<Stack direction="row" justifyContent="flex-end">
<Button
variant="contained"
size="small"
startIcon={<AddIcon fontSize="small" />}
onClick={() => setFormOpen(true)}
>
{t("models:collections.newCollection")}
</Button>
</Stack>
{loading ? (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
</Stack>
) : collections.length ? (
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", columnGap: "20px", rowGap: "20px" }}>
{collections.map(collection => (
<CollectionCard key={collection.id} collection={collection} theme={theme} previewMode={previewMode} />
))}
</Box>
) : (
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
<Typography variant="body2">{t("models:collections.empty")}</Typography>
</Stack>
)}
{formOpen && (
<CollectionFormModal onClose={() => setFormOpen(false)} onSubmit={createCollection} />
)}
</Stack>
);
}
@@ -0,0 +1,58 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import Tooltip from "@mui/material/Tooltip";
import FavoriteIcon from "@mui/icons-material/Favorite";
import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder";
import { UnauthorizedError } from "../../api/client";
import { type Print, printsApi } from "../../api/prints";
type Props = {
print: Print;
onUpdated: (print: Print) => void;
onUnauthorized?: () => void;
};
/** The model detail page's header favourite toggle -- adds/removes the print from the built-in
* "Favourites" pseudo-collection (see backend/src/services/collectionService.ts). */
export default function FavoriteButton({ print, onUpdated, onUnauthorized }: Props) {
const { t } = useTranslation(["models", "common"]);
const [busy, setBusy] = useState(false);
const toggle = async () => {
if (busy) return;
setBusy(true);
try {
const updated = print.is_favorite ? await printsApi.unfavorite(print.id) : await printsApi.favorite(print.id);
onUpdated(updated);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
alert(t("models:detail.favoriteFailed"));
} finally {
setBusy(false);
}
};
const label = print.is_favorite ? t("models:detail.removeFromFavorites") : t("models:detail.addToFavorites");
return (
<Tooltip title={label}>
<span>
<IconButton size="small" onClick={toggle} disabled={busy} aria-label={label ?? undefined}>
{busy ? (
<CircularProgress size={18} />
) : print.is_favorite ? (
<FavoriteIcon fontSize="small" color="error" />
) : (
<FavoriteBorderIcon fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
);
}
+5 -1
View File
@@ -24,6 +24,7 @@ import { ModelSnapshot } from "../../components/media/ModelViewer/ModelSnapshot"
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import Model3DPreviewModal from "./Model3DPreviewModal";
import ModelActionsMenu from "./ModelActionsMenu";
import FavoriteButton from "./FavoriteButton";
type Props = {
theme: ResolvedTheme;
@@ -48,7 +49,10 @@ export default function ModelDetailPage({ theme, onUnauthorized }: Props) {
usePageHeader({
title: print ? (print.title || print.name) : undefined,
actions: print ? (
<ModelActionsMenu print={print} onUnauthorized={onUnauthorized} onDeleted={goBack} />
<Stack direction="row" alignItems="center" spacing={0.5}>
<FavoriteButton print={print} onUpdated={setPrint} onUnauthorized={onUnauthorized} />
<ModelActionsMenu print={print} onUnauthorized={onUnauthorized} onDeleted={goBack} />
</Stack>
) : undefined,
});
+10
View File
@@ -0,0 +1,10 @@
import type { TFunction } from "i18next";
import type { Collection } from "../api/collections";
/** System collections (Favourites / Browsing History) carry an untranslated server-side `name`
* fallback -- always prefer the translated label keyed off `system_key` instead. */
export function collectionDisplayName(collection: Collection, t: TFunction): string {
if (collection.system_key === "favorites") return t("models:collections.system.favorites");
if (collection.system_key === "history") return t("models:collections.system.history");
return collection.name;
}