mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: add collections
This commit is contained in:
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");
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user