feat: bulk feature changes

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-08 15:03:10 +02:00
1 parent b5568d35e6
commit 7d185395a3
44 files changed
+1939 -319

No files matched your search

@@ -0,0 +1,63 @@
-- CreateEnum
CREATE TYPE "ImportJobType" AS ENUM ('COLLECTION', 'ZIP');
-- CreateEnum
CREATE TYPE "ImportJobStatus" AS ENUM ('RUNNING', 'DONE', 'ERROR');
-- AlterTable
ALTER TABLE "Print" ADD COLUMN "sourceExternalId" TEXT,
ADD COLUMN "sourceProvider" TEXT;
-- CreateTable
CREATE TABLE "ImportJob" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "ImportJobType" NOT NULL,
"status" "ImportJobStatus" NOT NULL DEFAULT 'RUNNING',
"sourceUrl" TEXT NOT NULL,
"sourceLabel" TEXT,
"provider" TEXT,
"total" INTEGER NOT NULL DEFAULT 0,
"processed" INTEGER NOT NULL DEFAULT 0,
"imported" INTEGER NOT NULL DEFAULT 0,
"alreadyInLibrary" INTEGER NOT NULL DEFAULT 0,
"failedCount" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT,
"resultCollectionId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ImportJob_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT,
"externalUrl" TEXT,
"internalPath" TEXT,
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ImportJob_userId_status_idx" ON "ImportJob"("userId", "status");
-- CreateIndex
CREATE INDEX "Notification_userId_readAt_idx" ON "Notification"("userId", "readAt");
-- CreateIndex
CREATE INDEX "Notification_userId_createdAt_idx" ON "Notification"("userId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "Print_userId_sourceProvider_sourceExternalId_key" ON "Print"("userId", "sourceProvider", "sourceExternalId");
-- AddForeignKey
ALTER TABLE "ImportJob" ADD CONSTRAINT "ImportJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+76 -10
View File
@@ -13,16 +13,18 @@ enum Role {
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
displayName String
role Role @default(MEMBER)
folders Folder[]
prints Print[]
collections Collection[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
email String @unique
passwordHash String
displayName String
role Role @default(MEMBER)
folders Folder[]
prints Print[]
collections Collection[]
importJobs ImportJob[]
notifications Notification[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Folder {
@@ -78,6 +80,12 @@ model Print {
// falls out of `orderBy: lastViewedAt desc` with no separate event log.
favoritedAt DateTime?
lastViewedAt DateTime?
// Identifies the source design this print was imported from (MakerWorld today), so a
// re-import of the same design -- whether via a plain link paste or as part of a different
// collection's batch import -- reuses this row instead of re-downloading a duplicate. Null
// for uploads, zip/folder-scan imports, and anything not resolvable to a stable provider id.
sourceProvider String?
sourceExternalId String?
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
authorId String?
@@ -94,6 +102,7 @@ model Print {
updatedAt DateTime @updatedAt
@@unique([userId, folderId, nameNormalized])
@@unique([userId, sourceProvider, sourceExternalId])
@@index([userId, folderId, name])
@@index([tags], type: Gin)
@@index([authorId])
@@ -138,6 +147,63 @@ model CollectionItem {
@@index([printId])
}
enum ImportJobType {
COLLECTION
ZIP
}
enum ImportJobStatus {
RUNNING
DONE
ERROR
}
/// Tracks one background batch import (a MakerWorld collection, or a remote zip's selected
/// entries) so the triggering request can return immediately instead of blocking on however
/// long the whole batch takes -- see services/importJobRunner.ts. At most one RUNNING job per
/// user is ever allowed (enforced in routes/imports.ts); that single row doubles as the "an
/// import is already in progress" lock, and survives a page refresh since the frontend can just
/// ask for the user's active job again.
model ImportJob {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type ImportJobType
status ImportJobStatus @default(RUNNING)
sourceUrl String
sourceLabel String?
provider String?
total Int @default(0)
processed Int @default(0)
imported Int @default(0)
alreadyInLibrary Int @default(0)
failedCount Int @default(0)
errorMessage String?
resultCollectionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, status])
}
/// A generic per-user notification -- today only ever created by importJobRunner.ts when a
/// batch import finishes, but the shape (title/body + an optional in-app and/or external link)
/// is deliberately not import-specific so a future notification source can reuse it.
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
body String?
externalUrl String?
internalPath String?
readAt DateTime?
createdAt DateTime @default(now())
@@index([userId, readAt])
@@index([userId, createdAt])
}
/// A creator/designer on some source platform (MakerWorld today; Printables/Thingiverse etc.
/// later). `id` is provider-prefixed ("makerworld:3582000380") rather than a generated cuid so
/// re-importing a model from the same author upserts the same row instead of creating a
+4
View File
@@ -12,6 +12,8 @@ import printFilesRoutes from "./routes/printFiles";
import settingsRoutes from "./routes/settings";
import importsRoutes from "./routes/imports";
import collectionsRoutes from "./routes/collections";
import notificationsRoutes from "./routes/notifications";
import adminRoutes from "./routes/admin";
export function createApp(): Express {
const app = express();
@@ -38,6 +40,8 @@ export function createApp(): Express {
app.use("/api", printFilesRoutes);
app.use("/api", importsRoutes);
app.use("/api", collectionsRoutes);
app.use("/api", notificationsRoutes);
app.use("/api", adminRoutes);
// 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, Collection, Folder, Plate, PreviewImage, Print, PrintFile, User } from "@prisma/client";
import type { Author, Collection, Folder, ImportJob, Notification, 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";
@@ -317,3 +317,59 @@ export function toFolderOut(folder: Folder): FolderOut {
printables_cat_id: folder.printablesCatId,
};
}
export type ImportJobOut = {
id: string;
type: "COLLECTION" | "ZIP";
status: "RUNNING" | "DONE" | "ERROR";
source_url: string;
source_label: string | null;
provider: string | null;
total: number;
processed: number;
imported: number;
already_in_library: number;
failed_count: number;
error_message: string | null;
result_collection_id: string | null;
};
export function toImportJobOut(job: ImportJob): ImportJobOut {
return {
id: job.id,
type: job.type,
status: job.status,
source_url: job.sourceUrl,
source_label: job.sourceLabel,
provider: job.provider,
total: job.total,
processed: job.processed,
imported: job.imported,
already_in_library: job.alreadyInLibrary,
failed_count: job.failedCount,
error_message: job.errorMessage,
result_collection_id: job.resultCollectionId,
};
}
export type NotificationOut = {
id: string;
title: string;
body: string | null;
external_url: string | null;
internal_path: string | null;
read: boolean;
created_at: string;
};
export function toNotificationOut(notification: Notification): NotificationOut {
return {
id: notification.id,
title: notification.title,
body: notification.body,
external_url: notification.externalUrl,
internal_path: notification.internalPath,
read: notification.readAt !== null,
created_at: notification.createdAt.toISOString(),
};
}
+41
View File
@@ -0,0 +1,41 @@
import { Router } from "express";
import { prisma } from "../db";
import { requireAdmin, requireAuth } from "../auth";
import { HttpError } from "../utils/fileUtils";
import { asyncHandler } from "../utils/asyncHandler";
import { deleteAllPrintsForUser, listUsersWithPrintCounts } from "../services/adminService";
const router = Router();
router.use(requireAuth);
router.use(requireAdmin);
router.get(
"/admin/users",
asyncHandler(async (_req, res) => {
const users = await listUsersWithPrintCounts();
res.json(
users.map((u) => ({
id: u.id,
email: u.email,
display_name: u.displayName,
role: u.role,
print_count: u.printCount,
})),
);
}),
);
// The two-part "select a user + type their email to confirm" flow lives entirely on the
// frontend (TriggersSection.tsx) -- this endpoint just does the (irreversible) deletion once
// asked, trusting the UI already got explicit confirmation from the admin.
router.post(
"/admin/users/:id/delete-all-prints",
asyncHandler(async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) throw new HttpError(404, "User not found");
const deleted = await deleteAllPrintsForUser(user.id);
res.json({ ok: true, deleted });
}),
);
export default router;
+57 -93
View File
@@ -7,18 +7,14 @@ import { HttpError } from "../utils/fileUtils";
import { normalizeImportUrl } from "../utils/urlUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { downloadImportToTemp, importPrintFromUrl, inspectImportLink, type ImportRequestBody } from "../services/importService";
import { downloadImportToTemp, importPrintFromUrl, inspectImportLink } from "../services/importService";
import { resolveMakerworldCookie } from "../services/importResolvers";
import { upsertAuthorFromImport } from "../services/authorService";
import { extractMakerworldBearerToken } from "../services/makerworldCloudApi";
import {
fetchMakerworldCollectionEntries,
fetchMakerworldCollectionTitle,
parseMakerworldCollectionUrl,
} from "../services/makerworldCollections";
import { addPrintsToCollection, findOrCreateCollectionByName } from "../services/collectionService";
import { extractZipEntriesToPrints, listZipEntries } from "../services/zipService";
import { toPrintOut, type PrintOut } from "../dto";
import { fetchMakerworldCollectionEntries, fetchMakerworldCollectionTitle, parseMakerworldCollectionUrl } from "../services/makerworldCollections";
import { listZipEntries } from "../services/zipService";
import { createJob, getActiveJob, getJob } from "../services/importJobService";
import { runCollectionImportJob, runZipImportJob } from "../services/importJobRunner";
import { toImportJobOut, toPrintOut } from "../dto";
const router = Router();
router.use(requireAuth);
@@ -71,33 +67,6 @@ router.post(
}),
);
const zipExtractRequestSchema = importRequestSchema.extend({ entries: z.array(z.string()) });
router.post(
"/import/zip",
asyncHandler(async (req, res) => {
const body = parseBody(zipExtractRequestSchema, req.body);
const url = await normalizeImportUrl(body.url);
const { tempPath, filename, meta } = await downloadImportToTemp(url, body);
try {
if (path.extname(filename).toLowerCase() !== ".zip") throw new HttpError(415, "Imported file is not a zip");
const author = await upsertAuthorFromImport(meta.author);
const { prints, failed } = await extractZipEntriesToPrints(req.userId!, tempPath, body.entries, {
title: body.title ?? meta.title,
notes: body.notes ?? meta.description,
tags: body.tags && body.tags.length ? body.tags : meta.tags,
folderId: body.folder_id,
creator: meta.creator,
authorId: author?.id ?? null,
previewImageUrl: meta.previewImageUrl,
galleryImages: meta.galleryImages,
});
res.json({ prints: prints.map((p) => toPrintOut(p, p.plates, [], null, author, p.previewImages)), failed });
} finally {
await fs.rm(tempPath, { force: true }).catch(() => undefined);
}
}),
);
router.post(
"/import/collection/entries",
asyncHandler(async (req, res) => {
@@ -122,74 +91,69 @@ router.post(
}),
);
/** Runs `worker` over `items` with at most `limit` in flight at once, preserving item order
* in the returned results. Used to import a batch of MakerWorld designs without either running
* hundreds of downloads fully sequentially (slow) or firing them all at once (hammers the
* upstream API right after we specifically built cool-off handling to avoid that). */
async function mapWithConcurrency<T, R>(items: T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = Array.from({ length: items.length });
let next = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
for (;;) {
const index = next++;
if (index >= items.length) return;
results[index] = await worker(items[index]);
}
});
await Promise.all(runners);
return results;
// ---- Background batch imports (MakerWorld collection / remote zip) ---------------------------
//
// Both of these can involve downloading dozens to hundreds of files, which used to happen
// synchronously inside the request -- long enough to run past reverse-proxy read timeouts with
// no feedback. They now just register an ImportJob and return immediately; the actual work runs
// in the background (importJobRunner.ts) and is polled via GET /import/jobs/:id. At most one
// job may be RUNNING per user at a time -- that's both the "already in progress" guard and what
// lets the frontend restore its progress bar after a page refresh via GET /import/jobs/active.
async function assertNoActiveJob(userId: string): Promise<void> {
const active = await getActiveJob(userId);
if (active) throw new HttpError(409, "An import is already in progress");
}
const COLLECTION_IMPORT_CONCURRENCY = 3;
const collectionImportRequestSchema = importRequestSchema.extend({ design_ids: z.array(z.string()).min(1) });
router.post(
"/import/collection",
asyncHandler(async (req, res) => {
const body = parseBody(collectionImportRequestSchema, req.body);
const results = await mapWithConcurrency(body.design_ids, COLLECTION_IMPORT_CONCURRENCY, async (designId) => {
const modelUrl = `https://makerworld.com/en/models/${designId}`;
const itemBody: ImportRequestBody = {
url: modelUrl,
notes: body.notes ?? null,
tags: body.tags ?? [],
folder_id: body.folder_id ?? null,
makerworld_cookie: body.makerworld_cookie,
thingiverse_cookie: body.thingiverse_cookie,
};
try {
const { print, plates, author, previewImages } = await importPrintFromUrl(req.userId!, modelUrl, itemBody);
return { ok: true as const, print: toPrintOut(print, plates, [], null, author, previewImages) };
} catch {
return { ok: false as const, designId };
}
await assertNoActiveJob(req.userId!);
const url = await normalizeImportUrl(body.url);
const job = await createJob(req.userId!, "COLLECTION", {
sourceUrl: url,
provider: "makerworld",
total: body.design_ids.length,
});
void runCollectionImportJob(job.id, req.userId!, { ...body, url });
res.status(202).json({ job_id: job.id });
}),
);
const prints: PrintOut[] = [];
const failed: string[] = [];
for (const result of results) {
if (result.ok) prints.push(result.print);
else failed.push(result.designId);
}
const zipExtractRequestSchema = importRequestSchema.extend({ entries: z.array(z.string()) });
// 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));
}
}
}
router.post(
"/import/zip",
asyncHandler(async (req, res) => {
const body = parseBody(zipExtractRequestSchema, req.body);
await assertNoActiveJob(req.userId!);
const url = await normalizeImportUrl(body.url);
const job = await createJob(req.userId!, "ZIP", {
sourceUrl: url,
total: body.entries.length,
});
void runZipImportJob(job.id, req.userId!, { ...body, url });
res.status(202).json({ job_id: job.id });
}),
);
res.json({ prints, failed });
router.get(
"/import/jobs/active",
asyncHandler(async (req, res) => {
const job = await getActiveJob(req.userId!);
res.json(job ? toImportJobOut(job) : null);
}),
);
router.get(
"/import/jobs/:id",
asyncHandler(async (req, res) => {
const job = await getJob(req.params.id, req.userId!);
if (!job) throw new HttpError(404, "Import job not found");
res.json(toImportJobOut(job));
}),
);
+26
View File
@@ -0,0 +1,26 @@
import { Router } from "express";
import { requireAuth } from "../auth";
import { asyncHandler } from "../utils/asyncHandler";
import { listNotifications, markAllRead } from "../services/notificationService";
import { toNotificationOut } from "../dto";
const router = Router();
router.use(requireAuth);
router.get(
"/notifications",
asyncHandler(async (req, res) => {
const { items, unreadCount } = await listNotifications(req.userId!);
res.json({ items: items.map(toNotificationOut), unread_count: unreadCount });
}),
);
router.post(
"/notifications/read-all",
asyncHandler(async (req, res) => {
await markAllRead(req.userId!);
res.json({ ok: true });
}),
);
export default router;
+11 -1
View File
@@ -1,9 +1,19 @@
import { createApp } from "./app";
import { API_PORT } from "./config";
import "./db"; // ensures storage directories exist before we start serving
import { prisma } from "./db"; // also ensures storage directories exist before we start serving
const app = createApp();
// A job left RUNNING can only mean the previous process died mid-import (crash/redeploy) --
// there's no way anything is still working on it. Clear the lock on startup so an interrupted
// import doesn't wedge every future import behind it forever.
prisma.importJob
.updateMany({
where: { status: "RUNNING" },
data: { status: "ERROR", errorMessage: "Interrupted by server restart" },
})
.catch((err) => console.error("Failed to recover stale import jobs on startup:", err));
app.listen(API_PORT, () => {
console.log(`PrintStash API listening on port ${API_PORT}`);
});
+53
View File
@@ -0,0 +1,53 @@
import fs from "node:fs/promises";
import { prisma } from "../db";
import { deleteAllPrintFiles } from "./printFileService";
import { deleteAllPreviewImages } from "./previewImageService";
import { deletePlateFiles } from "./printCreation";
import { plateThumbPath } from "./printService";
import { loadFullPrint } from "./printLoader";
export type UserWithPrintCount = {
id: string;
email: string;
displayName: string;
role: "ADMIN" | "MEMBER";
printCount: number;
};
export async function listUsersWithPrintCounts(): Promise<UserWithPrintCount[]> {
const users = await prisma.user.findMany({
orderBy: { createdAt: "asc" },
select: {
id: true,
email: true,
displayName: true,
role: true,
_count: { select: { prints: true } },
},
});
return users.map((u) => ({
id: u.id,
email: u.email,
displayName: u.displayName,
role: u.role,
printCount: u._count.prints,
}));
}
/** Deletes every print (and its on-disk files) belonging to `userId` -- the same per-print
* cleanup DELETE /print/:id already does, just looped across the whole user's library. Backs
* the admin "delete all models for a user" trigger. */
export async function deleteAllPrintsForUser(userId: string): Promise<number> {
const prints = await prisma.print.findMany({ where: { userId }, select: { id: true } });
for (const { id } of prints) {
const full = await loadFullPrint(userId, id);
await deleteAllPrintFiles(id);
await deleteAllPreviewImages(id);
await prisma.print.delete({ where: { id } });
for (const plate of full.plates) {
await deletePlateFiles(plate);
await fs.rm(plateThumbPath(plate.id), { force: true }).catch(() => undefined);
}
}
return prints.length;
}
+153
View File
@@ -0,0 +1,153 @@
import fs from "node:fs/promises";
import path from "node:path";
import { mapWithConcurrency } from "../utils/concurrency";
import { updateJob } from "./importJobService";
import { createNotification } from "./notificationService";
import { addPrintsToCollection, findOrCreateCollectionByName } from "./collectionService";
import { resolveMakerworldCookie } from "./importResolvers";
import { extractMakerworldBearerToken } from "./makerworldCloudApi";
import { fetchMakerworldCollectionTitle, parseMakerworldCollectionUrl } from "./makerworldCollections";
import { downloadImportToTemp, importPrintFromUrl, type ImportRequestBody } from "./importService";
import { upsertAuthorFromImport } from "./authorService";
import { extractZipEntriesToPrints } from "./zipService";
import { HttpError } from "../utils/fileUtils";
const COLLECTION_IMPORT_CONCURRENCY = 3;
type CollectionImportJobBody = ImportRequestBody & { design_ids: string[] };
type ZipImportJobBody = ImportRequestBody & { entries: string[] };
async function markJobFailed(jobId: string, err: unknown): Promise<void> {
const message = err instanceof Error ? err.message : "Import failed";
console.error(`Import job ${jobId} failed:`, err);
await updateJob(jobId, { status: "ERROR", errorMessage: message }).catch(() => undefined);
}
/** Runs a MakerWorld collection's batch import in the background -- see routes/imports.ts's
* POST /import/collection, which creates the ImportJob row and kicks this off without awaiting
* it. Mirrors the per-design import logic that used to live inline in that route handler. */
export async function runCollectionImportJob(jobId: string, userId: string, body: CollectionImportJobBody): Promise<void> {
try {
let imported = 0;
let alreadyInLibrary = 0;
let processed = 0;
const failed: string[] = [];
const successPrintIds: string[] = [];
await mapWithConcurrency(body.design_ids, COLLECTION_IMPORT_CONCURRENCY, async (designId) => {
const modelUrl = `https://makerworld.com/en/models/${designId}`;
const itemBody: ImportRequestBody = {
url: modelUrl,
notes: body.notes ?? null,
tags: body.tags ?? [],
folder_id: body.folder_id ?? null,
makerworld_cookie: body.makerworld_cookie,
thingiverse_cookie: body.thingiverse_cookie,
};
try {
const { print, alreadyImported } = await importPrintFromUrl(userId, modelUrl, itemBody);
successPrintIds.push(print.id);
if (alreadyImported) alreadyInLibrary++;
else imported++;
} catch {
failed.push(designId);
} finally {
processed++;
await updateJob(jobId, { processed, imported, alreadyInLibrary, failedCount: failed.length });
}
});
let resultCollectionId: string | null = null;
let collectionTitle: string | null = null;
if (successPrintIds.length) {
const url = body.url;
const parsed = parseMakerworldCollectionUrl(url);
if (parsed) {
const bearerToken = extractMakerworldBearerToken(resolveMakerworldCookie(body));
collectionTitle = await fetchMakerworldCollectionTitle(parsed.collectionId, bearerToken);
if (collectionTitle) {
const collection = await findOrCreateCollectionByName(userId, collectionTitle);
await addPrintsToCollection(collection.id, successPrintIds);
resultCollectionId = collection.id;
}
}
}
await updateJob(jobId, {
status: "DONE",
sourceLabel: collectionTitle,
resultCollectionId,
processed,
imported,
alreadyInLibrary,
failedCount: failed.length,
});
const label = collectionTitle ? `"${collectionTitle}"` : "a MakerWorld collection";
const bodyParts: string[] = [];
if (alreadyInLibrary) bodyParts.push(`${alreadyInLibrary} already in your library`);
if (failed.length) bodyParts.push(`${failed.length} failed`);
await createNotification(userId, {
title: `Imported ${imported} of ${body.design_ids.length} models from MakerWorld`,
body: bodyParts.length ? `From ${label} — ${bodyParts.join(", ")}.` : `From ${label}.`,
externalUrl: body.url,
internalPath: resultCollectionId ? `/models/collections/${resultCollectionId}` : null,
});
} catch (err) {
await markJobFailed(jobId, err);
}
}
/** Runs a remote zip's selected-entries batch import in the background -- see
* routes/imports.ts's POST /import/zip. */
export async function runZipImportJob(jobId: string, userId: string, body: ZipImportJobBody): Promise<void> {
let tempPath: string | null = null;
try {
const downloaded = await downloadImportToTemp(body.url, body);
tempPath = downloaded.tempPath;
const { filename, meta } = downloaded;
if (path.extname(filename).toLowerCase() !== ".zip") throw new HttpError(415, "Imported file is not a zip");
await updateJob(jobId, { sourceLabel: filename, total: body.entries.length });
const author = await upsertAuthorFromImport(meta.author);
const { prints, failed } = await extractZipEntriesToPrints(
userId,
tempPath,
body.entries,
{
title: body.title ?? meta.title,
notes: body.notes ?? meta.description,
tags: body.tags && body.tags.length ? body.tags : meta.tags,
folderId: body.folder_id,
creator: meta.creator,
authorId: author?.id ?? null,
previewImageUrl: meta.previewImageUrl,
galleryImages: meta.galleryImages,
},
(processed, total, imported, failedSoFar) => {
void updateJob(jobId, { processed, total, imported, failedCount: failedSoFar });
},
);
await updateJob(jobId, {
status: "DONE",
processed: body.entries.length,
imported: prints.length,
failedCount: failed.length,
});
const bodyParts: string[] = [];
if (failed.length) bodyParts.push(`${failed.length} failed`);
await createNotification(userId, {
title: `Imported ${prints.length} of ${body.entries.length} models from ${filename}`,
body: bodyParts.length ? `${bodyParts.join(", ")}.` : null,
externalUrl: body.url,
internalPath: null,
});
} catch (err) {
await markJobFailed(jobId, err);
} finally {
if (tempPath) await fs.rm(tempPath, { force: true }).catch(() => undefined);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { prisma } from "../db";
import type { ImportJob, ImportJobType, Prisma } from "@prisma/client";
export async function getActiveJob(userId: string): Promise<ImportJob | null> {
return prisma.importJob.findFirst({ where: { userId, status: "RUNNING" } });
}
export async function getJob(id: string, userId: string): Promise<ImportJob | null> {
return prisma.importJob.findFirst({ where: { id, userId } });
}
export async function createJob(
userId: string,
type: ImportJobType,
data: { sourceUrl: string; sourceLabel?: string | null; provider?: string | null; total?: number },
): Promise<ImportJob> {
return prisma.importJob.create({
data: {
userId,
type,
sourceUrl: data.sourceUrl,
sourceLabel: data.sourceLabel ?? null,
provider: data.provider ?? null,
total: data.total ?? 0,
},
});
}
export async function updateJob(id: string, data: Prisma.ImportJobUpdateInput): Promise<void> {
await prisma.importJob.update({ where: { id }, data });
}
+60 -6
View File
@@ -41,8 +41,13 @@ import { createPrint, type PrintMetaInput } from "./printCreation";
import { plateThumbExists, saveThumbFromBytes } from "./printService";
import { addPreviewImage } from "./previewImageService";
import { prisma } from "../db";
import { Prisma } from "@prisma/client";
import type { Author, Plate, PreviewImage, Print } from "@prisma/client";
function isUniqueConstraintError(err: unknown): boolean {
return err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
}
export type ImportRequestBody = ImportCookies & {
url: string;
title?: string | null;
@@ -415,12 +420,48 @@ async function resolveFolderIdByCategory(
return folder?.id ?? null;
}
/** Downloads a URL and creates a single-plate Print from it (POST /import). */
/** Identifies a provider + stable external id for a model URL, when possible -- used to dedup
* imports (see importPrintFromUrl below) so re-importing the same design, whether pasted again
* directly or pulled in as part of a different collection's batch import, reuses the existing
* Print instead of re-downloading a duplicate. MakerWorld only for now; a URL that doesn't match
* any known provider (or isn't from one at all -- a generic file host, say) returns null and is
* simply never deduped, same as today. */
export function identifySourceModel(url: string): { provider: string; externalId: string } | null {
const makerworld = parseMakerworldModelUrl(url);
if (makerworld) return { provider: "makerworld", externalId: makerworld.designId };
return null;
}
async function findExistingImportedPrint(
userId: string,
source: { provider: string; externalId: string },
): Promise<{ print: Print; plates: Plate[]; author: Author | null; previewImages: PreviewImage[] } | null> {
const print = await prisma.print.findFirst({
where: { userId, sourceProvider: source.provider, sourceExternalId: source.externalId },
include: { author: true },
});
if (!print) return null;
const [plates, previewImages] = await Promise.all([
prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } }),
prisma.previewImage.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } }),
]);
return { print, plates, author: print.author, previewImages };
}
/** Downloads a URL and creates a single-plate Print from it (POST /import). Returns
* `alreadyImported: true` (and the existing print, left untouched) instead of re-downloading
* when this exact source model has already been imported by this user. */
export async function importPrintFromUrl(
userId: string,
url: string,
body: ImportRequestBody,
): Promise<{ print: Print; plates: Plate[]; author: Author | null; previewImages: PreviewImage[] }> {
): Promise<{ print: Print; plates: Plate[]; author: Author | null; previewImages: PreviewImage[]; alreadyImported: boolean }> {
const source = identifySourceModel(url);
if (source) {
const existing = await findExistingImportedPrint(userId, source);
if (existing) return { ...existing, alreadyImported: true };
}
const { tempPath, filename, mime, meta } = await downloadImportToTemp(url, body);
const author = await upsertAuthorFromImport(meta.author);
const folderId =
@@ -432,17 +473,30 @@ export async function importPrintFromUrl(
folderId,
creator: meta.creator ?? null,
authorId: author?.id ?? null,
sourceProvider: source?.provider ?? null,
sourceExternalId: source?.externalId ?? null,
};
try {
const result = await createPrint(userId, printMeta, path.parse(filename).name, [
{ filename, mime, tempFilePath: tempPath },
]);
let result: { print: Print; plates: Plate[] };
try {
result = await createPrint(userId, printMeta, path.parse(filename).name, [
{ filename, mime, tempFilePath: tempPath },
]);
} catch (err) {
// Race guard: another concurrent import of the same source model won between our
// dedup check above and this create -- treat it the same as finding it up front.
if (source && isUniqueConstraintError(err)) {
const existing = await findExistingImportedPrint(userId, source);
if (existing) return { ...existing, alreadyImported: true };
}
throw err;
}
await attachImportedPreviewImages(result.print.id, result.plates[0]?.id, meta.previewImageUrl, meta.galleryImages);
const previewImages = await prisma.previewImage.findMany({
where: { printId: result.print.id },
orderBy: { position: "asc" },
});
return { ...result, author, previewImages };
return { ...result, author, previewImages, alreadyImported: false };
} finally {
if (fsSync.existsSync(tempPath)) await fs.rm(tempPath, { force: true }).catch(() => undefined);
}
@@ -0,0 +1,37 @@
import { prisma } from "../db";
const LIST_LIMIT = 50;
export async function createNotification(
userId: string,
data: { title: string; body?: string | null; externalUrl?: string | null; internalPath?: string | null },
): Promise<void> {
await prisma.notification.create({
data: {
userId,
title: data.title,
body: data.body ?? null,
externalUrl: data.externalUrl ?? null,
internalPath: data.internalPath ?? null,
},
});
}
export async function listNotifications(userId: string) {
const [items, unreadCount] = await Promise.all([
prisma.notification.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: LIST_LIMIT,
}),
prisma.notification.count({ where: { userId, readAt: null } }),
]);
return { items, unreadCount };
}
export async function markAllRead(userId: string): Promise<void> {
await prisma.notification.updateMany({
where: { userId, readAt: null },
data: { readAt: new Date() },
});
}
+7
View File
@@ -39,6 +39,11 @@ export type PrintMetaInput = {
collection?: string | null;
/** Id of an already-upserted Author row (see authorService.ts), e.g. "makerworld:12345". */
authorId?: string | null;
/** Set when this print was resolved from a known provider's model URL (see
* importService.ts's identifySourceModel) -- backs de-duplication of future imports of the
* same source model. Null for uploads, zip/folder-scan imports, and unrecognized sources. */
sourceProvider?: string | null;
sourceExternalId?: string | null;
};
async function placeFile(input: NewPlateInput, destAbsPath: string): Promise<string | null> {
@@ -171,6 +176,8 @@ export async function createPrint(
tags: (meta.tags || []).map((t) => t.trim()).filter(Boolean),
folderId: meta.folderId ?? null,
authorId: meta.authorId ?? null,
sourceProvider: meta.sourceProvider ?? null,
sourceExternalId: meta.sourceExternalId ?? null,
},
});
+3
View File
@@ -109,6 +109,7 @@ export async function extractZipEntriesToPrints(
zipPath: string,
selections: string[],
options: ZipExtractOptions,
onProgress?: (processed: number, total: number, imported: number, failed: number) => void,
): Promise<{ prints: (Print & { plates: Plate[]; previewImages: PreviewImage[] })[]; failed: string[] }> {
const normalized = selections.map((s) => normalizeZipEntryPath(s));
const ordered: string[] = [];
@@ -135,6 +136,7 @@ export async function extractZipEntriesToPrints(
const prints: (Print & { plates: Plate[]; previewImages: PreviewImage[] })[] = [];
const failed: string[] = [];
const folderCache: FolderCache = new Map();
let processedCount = 0;
for (const entryName of ordered) {
const info = entryMap.get(entryName);
@@ -178,6 +180,7 @@ export async function extractZipEntriesToPrints(
if (tempPath) await fs.rm(tempPath, { force: true }).catch(() => undefined);
failed.push(entryName);
}
onProgress?.(++processedCount, ordered.length, prints.length, failed.length);
}
return { prints, failed };
+20
View File
@@ -0,0 +1,20 @@
/** Runs `worker` over `items` with at most `limit` in flight at once, preserving item order in
* the returned results. Used by the batch import job runner to avoid either running hundreds of
* downloads fully sequentially (slow) or firing them all at once (hammers the upstream host). */
export async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results: R[] = Array.from({ length: items.length });
let next = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
for (;;) {
const index = next++;
if (index >= items.length) return;
results[index] = await worker(items[index], index);
}
});
await Promise.all(runners);
return results;
}
+93
View File
@@ -0,0 +1,93 @@
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 { prisma } from "../src/db";
const app = createApp();
let adminToken: string;
let memberToken: string;
let memberUserId: 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 adminEmail = `admin-test-${Date.now()}@example.com`;
const adminRegister = await request(app)
.post("/api/register")
.send({ displayName: "Admin Test", email: adminEmail, password: "password123" });
if (adminRegister.status !== 200) {
throw new Error(`Failed to register admin during test setup: ${adminRegister.status} ${JSON.stringify(adminRegister.body)}`);
}
await prisma.user.update({ where: { id: adminRegister.body.user.id }, data: { role: "ADMIN" } });
// Tokens embed the role at issue time, so a promoted account needs a fresh login to pick it up.
const adminLogin = await request(app).post("/api/login").send({ email: adminEmail, password: "password123" });
adminToken = adminLogin.body.token;
const memberEmail = `member-test-${Date.now()}@example.com`;
const memberRegister = await request(app)
.post("/api/register")
.send({ displayName: "Member Test", email: memberEmail, password: "password123" });
memberToken = memberRegister.body.token;
memberUserId = memberRegister.body.user.id;
});
function auth(token: string) {
return { Authorization: `Bearer ${token}` };
}
describe("admin user management", () => {
it("rejects a non-admin from listing users", async () => {
const res = await request(app).get("/api/admin/users").set(auth(memberToken));
expect(res.status).toBe(403);
});
it("rejects a non-admin from triggering a deletion", async () => {
const res = await request(app).post(`/api/admin/users/${memberUserId}/delete-all-prints`).set(auth(memberToken));
expect(res.status).toBe(403);
});
it("lists users with their print counts", async () => {
await request(app)
.post("/api/upload")
.set(auth(memberToken))
.attach("files", tmpFile("admin-test-a.stl", "solid a endsolid"));
await request(app)
.post("/api/upload")
.set(auth(memberToken))
.attach("files", tmpFile("admin-test-b.stl", "solid b endsolid"));
const res = await request(app).get("/api/admin/users").set(auth(adminToken));
expect(res.status).toBe(200);
const member = res.body.find((u: { id: string }) => u.id === memberUserId);
expect(member).toBeTruthy();
expect(member.print_count).toBe(2);
});
it("404s for deleting prints of a nonexistent user", async () => {
const res = await request(app).post("/api/admin/users/does-not-exist/delete-all-prints").set(auth(adminToken));
expect(res.status).toBe(404);
});
it("deletes every print belonging to the target user, leaving other users untouched", async () => {
const deleteRes = await request(app)
.post(`/api/admin/users/${memberUserId}/delete-all-prints`)
.set(auth(adminToken));
expect(deleteRes.status).toBe(200);
expect(deleteRes.body.deleted).toBe(2);
const printsRes = await request(app).get("/api/prints").set(auth(memberToken));
expect(printsRes.body).toEqual([]);
const usersRes = await request(app).get("/api/admin/users").set(auth(adminToken));
const member = usersRes.body.find((u: { id: string }) => u.id === memberUserId);
expect(member.print_count).toBe(0);
});
});
+143
View File
@@ -0,0 +1,143 @@
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 { prisma } from "../src/db";
import { identifySourceModel, importPrintFromUrl } from "../src/services/importService";
import { createJob, getActiveJob, updateJob } from "../src/services/importJobService";
import { createNotification, listNotifications, markAllRead } from "../src/services/notificationService";
const app = createApp();
let token: string;
let userId: 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 = `imports-test-${Date.now()}@example.com`;
const res = await request(app)
.post("/api/register")
.send({ displayName: "Imports 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;
userId = res.body.user.id;
});
function auth() {
return { Authorization: `Bearer ${token}` };
}
describe("identifySourceModel", () => {
it("extracts a provider + design id from a MakerWorld model URL", () => {
expect(identifySourceModel("https://makerworld.com/en/models/681234-some-slug")).toEqual({
provider: "makerworld",
externalId: "681234",
});
});
it("returns null for a non-MakerWorld URL", () => {
expect(identifySourceModel("https://example.com/models/681234")).toBeNull();
});
it("returns null for a MakerWorld URL that isn't a model page", () => {
expect(identifySourceModel("https://makerworld.com/en/collections/12345-name")).toBeNull();
});
});
describe("import dedup", () => {
it("reuses an existing print instead of re-downloading when it was already imported from this source", async () => {
const uploadRes = await request(app)
.post("/api/upload")
.set(auth())
.attach("files", tmpFile("dedup-me.stl", "solid dedup endsolid"));
expect(uploadRes.status).toBe(200);
const printId = uploadRes.body.prints[0].id;
// Uploads don't go through importPrintFromUrl, so backfill the source identity directly --
// equivalent to what a real MakerWorld import would have stamped on create.
await prisma.print.update({
where: { id: printId },
data: { sourceProvider: "makerworld", sourceExternalId: "999111" },
});
const countBefore = await prisma.print.count({ where: { userId } });
const result = await importPrintFromUrl(userId, "https://makerworld.com/en/models/999111-dedup-slug", {
url: "https://makerworld.com/en/models/999111-dedup-slug",
tags: [],
});
expect(result.alreadyImported).toBe(true);
expect(result.print.id).toBe(printId);
const countAfter = await prisma.print.count({ where: { userId } });
expect(countAfter).toBe(countBefore);
await request(app).delete(`/api/print/${printId}`).set(auth());
});
});
describe("import job lock", () => {
it("blocks a new batch import while one is already running, and reports it via the active/job endpoints", async () => {
const job = await createJob(userId, "COLLECTION", {
sourceUrl: "https://makerworld.com/en/collections/1-test",
provider: "makerworld",
total: 5,
});
const collectionRes = await request(app)
.post("/api/import/collection")
.set(auth())
.send({ url: "https://makerworld.com/en/collections/1-test", design_ids: ["1"] });
expect(collectionRes.status).toBe(409);
const zipRes = await request(app)
.post("/api/import/zip")
.set(auth())
.send({ url: "https://example.com/some.zip", entries: ["a.stl"] });
expect(zipRes.status).toBe(409);
const activeRes = await request(app).get("/api/import/jobs/active").set(auth());
expect(activeRes.status).toBe(200);
expect(activeRes.body.id).toBe(job.id);
expect(activeRes.body.status).toBe("RUNNING");
const jobRes = await request(app).get(`/api/import/jobs/${job.id}`).set(auth());
expect(jobRes.status).toBe(200);
expect(jobRes.body.total).toBe(5);
await updateJob(job.id, { status: "DONE" });
expect(await getActiveJob(userId)).toBeNull();
});
});
describe("notifications", () => {
it("lists notifications with an accurate unread count, clearable via mark-all-read", async () => {
await createNotification(userId, { title: "Test notification A", externalUrl: "https://example.com/a" });
await createNotification(userId, { title: "Test notification B", internalPath: "/models/collections/xyz" });
const before = await listNotifications(userId);
expect(before.unreadCount).toBeGreaterThanOrEqual(2);
expect(before.items.some((n) => n.title === "Test notification A")).toBe(true);
const listRes = await request(app).get("/api/notifications").set(auth());
expect(listRes.status).toBe(200);
expect(listRes.body.unread_count).toBeGreaterThanOrEqual(2);
const readRes = await request(app).post("/api/notifications/read-all").set(auth());
expect(readRes.status).toBe(200);
const after = await listNotifications(userId);
expect(after.unreadCount).toBe(0);
await markAllRead(userId); // idempotent no-op, just exercising the direct service path too
});
});
+5
View File
@@ -4,7 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PrintStash</title>
<!-- SVG favicon for browsers that support it; Safari (no SVG favicon support) and other
legacy/PWA contexts fall back to the rasterized PNGs below. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
-14
View File
@@ -21,20 +21,6 @@ server {
client_max_body_size 512m;
}
# Importing a MakerWorld collection downloads each selected model in turn (a few at a
# time) rather than one request per model, so a batch of any real size can run past the
# general API timeout above. Longer prefix wins over the block above regardless of order.
location ^~ /api/import/collection {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
client_max_body_size 512m;
}
# SPA fallback — serve index.html for all non-file requests
location / {
try_files $uri $uri/ /index.html;
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

+27
View File
@@ -0,0 +1,27 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk, readErrorMessage } from "./client";
export type AdminUser = {
id: string;
email: string;
display_name: string;
role: "ADMIN" | "MEMBER";
print_count: number;
};
export const adminApi = {
listUsers: async (): Promise<AdminUser[]> => {
const res = await fetch(`${apiBase()}/admin/users`, { headers: authHeaders() });
assertOk(res, "Failed to load users");
return res.json();
},
deleteAllPrintsForUser: async (userId: string): Promise<{ deleted: number }> => {
const res = await fetch(`${apiBase()}/admin/users/${userId}/delete-all-prints`, {
method: "POST",
headers: authHeaders(),
});
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to delete models"));
return res.json();
},
};
+40 -11
View File
@@ -1,6 +1,5 @@
import { authHeaders } from "../utils/auth";
import { apiBase, readErrorMessage, UnauthorizedError } from "./client";
import type { Print } from "./prints";
export type ImportInspectInfo = {
filename: string;
@@ -13,11 +12,6 @@ export type ZipEntryInfo = {
size: number;
};
export type ImportZipResult = {
prints: Print[];
failed: string[];
};
export type ImportCollectionEntry = {
design_id: string;
title: string;
@@ -31,9 +25,26 @@ export type ImportCollectionEntriesResult = {
entries: ImportCollectionEntry[];
};
export type ImportCollectionResult = {
prints: Print[];
failed: string[];
export type ImportJobType = "COLLECTION" | "ZIP";
export type ImportJobStatus = "RUNNING" | "DONE" | "ERROR";
/** A batch import (MakerWorld collection, or a remote zip's selected entries) running in the
* background -- see ImportJobContext, which polls GET /import/jobs/:id for this shape until
* status leaves RUNNING. */
export type ImportJob = {
id: string;
type: ImportJobType;
status: ImportJobStatus;
source_url: string;
source_label: string | null;
provider: string | null;
total: number;
processed: number;
imported: number;
already_in_library: number;
failed_count: number;
error_message: string | null;
result_collection_id: string | null;
};
type ImportLinkPayload = {
@@ -102,7 +113,9 @@ export const importsApi = {
return res.json();
},
zipFromLink: async (payload: ImportLinkPayload & { entries: string[] }): Promise<ImportZipResult> => {
/** Registers a background job for the selected zip entries and returns immediately -- see
* ImportJobContext.startZipImport, which follows up with the actual polling. */
zipFromLink: async (payload: ImportLinkPayload & { entries: string[] }): Promise<{ job_id: string }> => {
const res = await fetch(`${apiBase()}/import/zip`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
@@ -134,7 +147,9 @@ export const importsApi = {
return res.json();
},
fromCollection: async (payload: ImportLinkPayload & { design_ids: string[] }): Promise<ImportCollectionResult> => {
/** Registers a background job for the selected designs and returns immediately -- see
* ImportJobContext.startCollectionImport, which follows up with the actual polling. */
fromCollection: async (payload: ImportLinkPayload & { design_ids: string[] }): Promise<{ job_id: string }> => {
const res = await fetch(`${apiBase()}/import/collection`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
@@ -149,4 +164,18 @@ export const importsApi = {
}
return res.json();
},
getActiveImportJob: async (): Promise<ImportJob | null> => {
const res = await fetch(`${apiBase()}/import/jobs/active`, { headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to check for an active import"));
return res.json();
},
getImportJob: async (jobId: string): Promise<ImportJob> => {
const res = await fetch(`${apiBase()}/import/jobs/${jobId}`, { headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to load import progress"));
return res.json();
},
};
+33
View File
@@ -0,0 +1,33 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
export type Notification = {
id: string;
title: string;
body: string | null;
external_url: string | null;
internal_path: string | null;
read: boolean;
created_at: string;
};
export type NotificationsListResult = {
items: Notification[];
unread_count: number;
};
export const notificationsApi = {
list: async (): Promise<NotificationsListResult> => {
const res = await fetch(`${apiBase()}/notifications`, { headers: authHeaders() });
assertOk(res, "Failed to load notifications");
return res.json();
},
markAllRead: async (): Promise<void> => {
const res = await fetch(`${apiBase()}/notifications/read-all`, {
method: "POST",
headers: authHeaders(),
});
assertOk(res, "Failed to mark notifications as read");
},
};
+16 -9
View File
@@ -11,10 +11,12 @@ import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import TextField from "@mui/material/TextField";
import CircularProgress from "@mui/material/CircularProgress";
import Tooltip from "@mui/material/Tooltip";
import AddIcon from "@mui/icons-material/Add";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import LinkIcon from "@mui/icons-material/Link";
import { useUploadImport } from "../uploads/useUploadImport";
import { useImportJob } from "./ImportJobContext";
type Props = {
folderId?: string | null;
@@ -32,6 +34,7 @@ export default function AddMenu({ folderId, makerworldCookie, thingiverseCookie,
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const [importOpen, setImportOpen] = React.useState(false);
const [linkValue, setLinkValue] = React.useState("");
const { isImporting } = useImportJob();
const upload = useUploadImport({ folderId, makerworldCookie, thingiverseCookie, onUploaded, onUnauthorized });
const closeMenu = () => setAnchorEl(null);
@@ -61,15 +64,19 @@ export default function AddMenu({ folderId, makerworldCookie, thingiverseCookie,
return (
<>
{upload.fileInput}
<Button
variant="contained"
size="small"
startIcon={<AddIcon fontSize="small" />}
disabled={upload.isBusy}
onClick={e => setAnchorEl(e.currentTarget)}
>
{upload.uploading ? t("uploadBar.uploading") : t("common:add")}
</Button>
<Tooltip title={isImporting ? t("addMenu.disabledWhileImporting") : ""}>
<span>
<Button
variant="contained"
size="small"
startIcon={<AddIcon fontSize="small" />}
disabled={upload.isBusy || isImporting}
onClick={e => setAnchorEl(e.currentTarget)}
>
{upload.uploading ? t("uploadBar.uploading") : t("common:add")}
</Button>
</span>
</Tooltip>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
<MenuItem onClick={handleUpload}>
<ListItemIcon><UploadFileIcon fontSize="small" /></ListItemIcon>
+80 -50
View File
@@ -7,8 +7,11 @@ import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import Sidebar from "./Sidebar";
import TopBar from "./TopBar";
import ImportProgressBar from "./ImportProgressBar";
import { ConfirmProvider } from "../ConfirmProvider";
import { PageHeaderContext, type PageHeader } from "./PageHeaderContext";
import { ImportJobProvider } from "./ImportJobContext";
import { NotificationsProvider, useNotifications } from "./NotificationsContext";
import type { ResolvedTheme, ThemeSelection } from "../../constants/settingsOptions";
import type { AuthUser } from "../../api/auth";
@@ -29,9 +32,13 @@ type AppLayoutProps = {
children: React.ReactNode;
};
/** Derives the header title + whether a back button is shown from the current route -- there's
/** Derives the header title + default back-button destination from the current route -- there's
* no per-route config table beyond this since the set of routes is small and each is distinct
* chrome-wise. */
* chrome-wise. Most routes just retrace browser history (wherever the user drilled in from);
* the two top-level list routes (Models, Collections) instead go to a fixed destination since
* they're reachable directly from the sidebar with no meaningful "came from" page. A page can
* still override this default via usePageHeader's `onBack` -- see ModelsPage, whose back
* button clears an active category filter instead of leaving the page while one's selected. */
function useRouteChrome() {
const { t } = useTranslation(["app", "models", "common"]);
const location = useLocation();
@@ -39,39 +46,43 @@ function useRouteChrome() {
const path = location.pathname;
let title = t("sidebar.dashboard");
let showBack = false;
let onBack: (() => void) | undefined;
if (path === "/") {
title = t("sidebar.dashboard");
} else if (path === "/models/collections") {
title = t("models:collections.pageTitle");
showBack = true;
onBack = () => navigate("/");
} else if (path.startsWith("/models/collections/")) {
// Overridden by CollectionDetailPage's usePageHeader once the collection loads.
title = t("models:collections.pageTitle");
showBack = true;
onBack = () => navigate(-1);
} else if (path.startsWith("/models/")) {
title = t("models:pageTitle");
showBack = true;
onBack = () => navigate(-1);
} else if (path === "/models") {
title = t("models:pageTitle");
onBack = () => navigate("/");
} else if (path.startsWith("/authors/")) {
title = t("models:author.pageTitle");
showBack = true;
onBack = () => navigate(-1);
} else if (path === "/settings") {
title = t("common:settings");
showBack = true;
onBack = () => navigate(-1);
} else if (path.startsWith("/admin-settings")) {
title = t("adminSettings.pageTitle");
showBack = true;
onBack = () => navigate(-1);
}
return { title, onBack: showBack ? () => navigate(-1) : undefined };
return { title, onBack };
}
/** The persistent app shell: theming, the nav sidebar, and the header row (back button + title,
* then the global Add/Notifications/User cluster). `children` is the routed page content. */
export default function AppLayout({
muiTheme,
type ShellProps = Omit<AppLayoutProps, "muiTheme">;
/** Sits inside both NotificationsProvider and ImportJobProvider so it can wire a finished
* import job to a grid refresh + notification refresh, then renders the actual shell chrome.
* Split out from AppLayout because that wiring needs useNotifications(), which only works
* below the provider AppLayout itself renders. */
function AppLayoutShell({
resolvedTheme,
apiUp,
folderId,
@@ -85,49 +96,68 @@ export default function AppLayout({
user,
onThemeChange,
children,
}: AppLayoutProps) {
}: ShellProps) {
const { t } = useTranslation(["app", "common"]);
const { title: routeTitle, onBack } = useRouteChrome();
const { title: routeTitle, onBack: routeOnBack } = useRouteChrome();
const [pageHeader, setPageHeader] = React.useState<PageHeader>(null);
const { refresh: refreshNotifications } = useNotifications();
const handleJobCompleted = React.useCallback(() => {
onPrintsChanged();
void refreshNotifications();
}, [onPrintsChanged, refreshNotifications]);
return (
<ImportJobProvider onUnauthorized={onUnauthorized} onJobCompleted={handleJobCompleted}>
<Box
sx={{
background: (theme) => theme.printstash.pageBackground,
minHeight: "100vh",
display: "flex",
}}
>
<Sidebar isAdmin={isAdmin} />
<Box component="main" sx={{ flex: 1, p: 2, overflow: "auto" }}>
{apiUp === false && (
<Alert severity="error" sx={{ mb: 1.5 }}>
{t("shell.apiUnreachable")}
</Alert>
)}
<TopBar
title={pageHeader?.title || routeTitle}
onBack={pageHeader?.onBack ?? routeOnBack}
actions={pageHeader?.actions}
folderId={folderId}
makerworldCookie={makerworldCookie}
thingiverseCookie={thingiverseCookie}
onUploaded={onPrintsChanged}
onUnauthorized={onUnauthorized}
user={user}
theme={resolvedTheme}
onThemeChange={onThemeChange}
onOpenSettings={onOpenSettings}
onLogout={onLogout}
/>
<PageHeaderContext.Provider value={setPageHeader}>
{children}
</PageHeaderContext.Provider>
</Box>
</Box>
<ImportProgressBar />
</ImportJobProvider>
);
}
/** The persistent app shell: theming, the nav sidebar, and the header row (back button + title,
* then the global Add/Notifications/User cluster). `children` is the routed page content. */
export default function AppLayout({ muiTheme, onUnauthorized, ...shellProps }: AppLayoutProps) {
return (
<ThemeProvider theme={muiTheme}>
<CssBaseline />
<ConfirmProvider>
<Box
sx={{
background: (theme) => theme.printstash.pageBackground,
minHeight: "100vh",
display: "flex",
}}
>
<Sidebar isAdmin={isAdmin} />
<Box component="main" sx={{ flex: 1, p: 2, overflow: "auto" }}>
{apiUp === false && (
<Alert severity="error" sx={{ mb: 1.5 }}>
{t("shell.apiUnreachable")}
</Alert>
)}
<TopBar
title={pageHeader?.title || routeTitle}
onBack={onBack}
actions={pageHeader?.actions}
folderId={folderId}
makerworldCookie={makerworldCookie}
thingiverseCookie={thingiverseCookie}
onUploaded={onPrintsChanged}
onUnauthorized={onUnauthorized}
user={user}
theme={resolvedTheme}
onThemeChange={onThemeChange}
onOpenSettings={onOpenSettings}
onLogout={onLogout}
/>
<PageHeaderContext.Provider value={setPageHeader}>
{children}
</PageHeaderContext.Provider>
</Box>
</Box>
<NotificationsProvider onUnauthorized={onUnauthorized}>
<AppLayoutShell onUnauthorized={onUnauthorized} {...shellProps} />
</NotificationsProvider>
</ConfirmProvider>
</ThemeProvider>
);
@@ -0,0 +1,114 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { importsApi, type ImportJob } from "../../api/imports";
import { UnauthorizedError } from "../../api/client";
type StartCollectionImportPayload = Parameters<typeof importsApi.fromCollection>[0];
type StartZipImportPayload = Parameters<typeof importsApi.zipFromLink>[0];
type ImportJobContextValue = {
/** Non-null exactly while a batch import (MakerWorld collection or remote-zip) is running --
* drives the global progress bar and disables the Add/Import/Upload menu app-wide. */
activeJob: ImportJob | null;
isImporting: boolean;
startCollectionImport: (payload: StartCollectionImportPayload) => Promise<void>;
startZipImport: (payload: StartZipImportPayload) => Promise<void>;
};
const ImportJobContext = createContext<ImportJobContextValue | null>(null);
const POLL_INTERVAL_MS = 1000;
export function ImportJobProvider({
onUnauthorized,
onJobCompleted,
children,
}: {
onUnauthorized?: () => void;
/** Called once, right after a job leaves RUNNING -- refreshes the current grid and the
* notification bell (the job's own completion is what created the notification). */
onJobCompleted?: () => void;
children: React.ReactNode;
}) {
const [activeJob, setActiveJob] = useState<ImportJob | null>(null);
const pollRef = useRef<number | null>(null);
const onJobCompletedRef = useRef(onJobCompleted);
onJobCompletedRef.current = onJobCompleted;
const stopPolling = useCallback(() => {
if (pollRef.current !== null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
const pollJob = useCallback(async (jobId: string) => {
try {
const job = await importsApi.getImportJob(jobId);
if (job.status === "RUNNING") {
setActiveJob(job);
return;
}
stopPolling();
setActiveJob(null);
onJobCompletedRef.current?.();
} catch (err) {
stopPolling();
setActiveJob(null);
if (err instanceof UnauthorizedError) onUnauthorized?.();
}
}, [onUnauthorized, stopPolling]);
const startPolling = useCallback((jobId: string) => {
stopPolling();
pollRef.current = window.setInterval(() => { void pollJob(jobId); }, POLL_INTERVAL_MS);
}, [pollJob, stopPolling]);
// Restores the progress bar / lock after a page refresh mid-import.
useEffect(() => {
(async () => {
try {
const job = await importsApi.getActiveImportJob();
if (job && job.status === "RUNNING") {
setActiveJob(job);
startPolling(job.id);
}
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
}
})();
return () => stopPolling();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startCollectionImport = useCallback(async (payload: StartCollectionImportPayload) => {
const { job_id } = await importsApi.fromCollection(payload);
const job = await importsApi.getImportJob(job_id);
setActiveJob(job);
startPolling(job_id);
}, [startPolling]);
const startZipImport = useCallback(async (payload: StartZipImportPayload) => {
const { job_id } = await importsApi.zipFromLink(payload);
const job = await importsApi.getImportJob(job_id);
setActiveJob(job);
startPolling(job_id);
}, [startPolling]);
const value = useMemo(
() => ({ activeJob, isImporting: activeJob !== null, startCollectionImport, startZipImport }),
[activeJob, startCollectionImport, startZipImport],
);
return (
<ImportJobContext.Provider value={value}>
{children}
</ImportJobContext.Provider>
);
}
export function useImportJob() {
const ctx = useContext(ImportJobContext);
if (!ctx) throw new Error("useImportJob must be used within an ImportJobProvider");
return ctx;
}
@@ -0,0 +1,53 @@
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import LinearProgress from "@mui/material/LinearProgress";
import Tooltip from "@mui/material/Tooltip";
import { useImportJob } from "./ImportJobContext";
/** A fixed bar pinned to the bottom of the viewport, shown app-wide for as long as a batch
* import (MakerWorld collection or remote zip) is running -- hovering it shows the full
* imported/already-in-library/failed breakdown. */
export default function ImportProgressBar() {
const { t } = useTranslation("app");
const { activeJob } = useImportJob();
if (!activeJob) return null;
const { total, processed, imported, already_in_library: alreadyInLibrary, failed_count: failedCount } = activeJob;
const progressValue = total > 0 ? Math.min(100, (processed / total) * 100) : 0;
const label = total > 0
? t("importProgress.label", { processed, total })
: t("importProgress.starting");
const tooltip = t("importProgress.tooltip", { imported, alreadyInLibrary, failedCount });
return (
<Tooltip title={tooltip} placement="top">
<Box
sx={{
position: "fixed",
left: 0,
right: 0,
bottom: 0,
zIndex: (theme) => theme.zIndex.snackbar,
bgcolor: "background.paper",
borderTop: "1px solid",
borderColor: "divider",
px: 2,
py: 0.75,
}}
>
<Stack spacing={0.5} sx={{ maxWidth: 480, mx: "auto" }}>
<Typography variant="caption" color="text.secondary" textAlign="center">
{label}
</Typography>
<LinearProgress
variant={total > 0 ? "determinate" : "indeterminate"}
value={progressValue}
sx={{ borderRadius: 1, height: 6 }}
/>
</Stack>
</Box>
</Tooltip>
);
}
@@ -1,19 +1,109 @@
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import NotificationsIcon from "@mui/icons-material/Notifications";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import IconButton from "@mui/material/IconButton";
import Badge from "@mui/material/Badge";
import Tooltip from "@mui/material/Tooltip";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
import Link from "@mui/material/Link";
import NotificationsIcon from "@mui/icons-material/Notifications";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import { useNotifications } from "./NotificationsContext";
import type { Notification } from "../../api/notifications";
function relativeTime(iso: string, t: (key: string, opts?: Record<string, unknown>) => string): string {
const diffMs = Date.now() - new Date(iso).getTime();
const minutes = Math.round(diffMs / 60_000);
if (minutes < 1) return t("notifications.justNow");
if (minutes < 60) return t("notifications.minutesAgo", { count: minutes });
const hours = Math.round(minutes / 60);
if (hours < 24) return t("notifications.hoursAgo", { count: hours });
const days = Math.round(hours / 24);
return t("notifications.daysAgo", { count: days });
}
/** Placeholder for the app-wide notification center -- there's nothing to notify about yet,
* so this is just the bell with no menu behind it. */
export default function NotificationBell() {
const { t } = useTranslation("app");
const navigate = useNavigate();
const { items, unreadCount, markAllRead } = useNotifications();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const handleOpen = (e: React.MouseEvent<HTMLElement>) => {
setAnchorEl(e.currentTarget);
if (unreadCount > 0) void markAllRead();
};
const handleClose = () => setAnchorEl(null);
const handleItemClick = (notification: Notification) => {
handleClose();
if (notification.internal_path) navigate(notification.internal_path);
};
return (
<Tooltip title={t("notifications.label")}>
<span>
<IconButton size="small" disabled>
<NotificationsIcon fontSize="small" />
<>
<Tooltip title={t("notifications.label")}>
<IconButton size="small" onClick={handleOpen}>
<Badge badgeContent={unreadCount} color="error" max={99}>
<NotificationsIcon fontSize="small" />
</Badge>
</IconButton>
</span>
</Tooltip>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{ paper: { sx: { width: 360, maxHeight: 420 } } }}
>
{items.length === 0 && (
<Stack alignItems="center" spacing={0.5} sx={{ py: 3, color: "text.secondary" }}>
<Typography variant="body2">{t("notifications.empty")}</Typography>
</Stack>
)}
{items.map((notification, idx) => (
<div key={notification.id}>
{idx > 0 && <Divider />}
<MenuItem
onClick={() => handleItemClick(notification)}
sx={{ whiteSpace: "normal", alignItems: "flex-start", py: 1 }}
>
<Stack spacing={0.25} sx={{ minWidth: 0, width: "100%" }}>
<Typography variant="body2" fontWeight={notification.read ? 400 : 700}>
{notification.title}
</Typography>
{notification.body && (
<Typography variant="caption" color="text.secondary">
{notification.body}
</Typography>
)}
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.25 }}>
<Typography variant="caption" color="text.disabled">
{relativeTime(notification.created_at, t)}
</Typography>
{notification.external_url && (
<Link
href={notification.external_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
sx={{ display: "inline-flex", alignItems: "center", gap: 0.25, fontSize: 12 }}
>
{t("notifications.viewSource")}
<OpenInNewIcon sx={{ fontSize: 12 }} />
</Link>
)}
</Stack>
</Stack>
</MenuItem>
</div>
))}
</Menu>
</>
);
}
@@ -0,0 +1,70 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type React from "react";
import { notificationsApi, type Notification } from "../../api/notifications";
import { UnauthorizedError } from "../../api/client";
type NotificationsContextValue = {
items: Notification[];
unreadCount: number;
refresh: () => Promise<void>;
markAllRead: () => Promise<void>;
};
const NotificationsContext = createContext<NotificationsContextValue | null>(null);
// Not urgent -- the primary trigger for a new notification is the current tab's own tracked
// import job finishing (ImportJobContext refreshes this directly when that happens). This
// interval only catches a notification created elsewhere (another tab/device).
const POLL_INTERVAL_MS = 45_000;
export function NotificationsProvider({
onUnauthorized,
children,
}: {
onUnauthorized?: () => void;
children: React.ReactNode;
}) {
const [items, setItems] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const refresh = useCallback(async () => {
try {
const result = await notificationsApi.list();
setItems(result.items);
setUnreadCount(result.unread_count);
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
}
}, [onUnauthorized]);
const markAllRead = useCallback(async () => {
try {
await notificationsApi.markAllRead();
setUnreadCount(0);
setItems(prev => prev.map(n => ({ ...n, read: true })));
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
}
}, [onUnauthorized]);
useEffect(() => {
void refresh();
const interval = window.setInterval(() => { void refresh(); }, POLL_INTERVAL_MS);
return () => window.clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const value = useMemo(() => ({ items, unreadCount, refresh, markAllRead }), [items, unreadCount, refresh, markAllRead]);
return (
<NotificationsContext.Provider value={value}>
{children}
</NotificationsContext.Provider>
);
}
export function useNotifications() {
const ctx = useContext(NotificationsContext);
if (!ctx) throw new Error("useNotifications must be used within a NotificationsProvider");
return ctx;
}
@@ -1,24 +1,26 @@
import { createContext, useContext, useEffect } from "react";
import type React from "react";
export type PageHeader = { title?: string; actions?: React.ReactNode } | null;
export type PageHeader = { title?: string; actions?: React.ReactNode; onBack?: () => void } | null;
/** Default no-op setter so usePageHeader is a harmless no-op for any page rendered outside
* AppLayout (e.g. tests) rather than throwing. */
export const PageHeaderContext = createContext<(header: PageHeader) => void>(() => {});
/** Lets a routed page override the shared TopBar's title/actions for as long as it's mounted --
* cleared automatically on unmount so the next route falls back to its own default chrome
* (see AppLayout's useRouteChrome). Pass `undefined` fields while data is still loading; the
* route's default title is used until a real title is available. */
/** Lets a routed page override the shared TopBar's title/actions/back-target for as long as
* it's mounted -- cleared automatically on unmount so the next route falls back to its own
* default chrome (see AppLayout's useRouteChrome). Pass `undefined` fields while data is still
* loading, or to fall back to the route's default -- e.g. ModelsPage only overrides `onBack`
* while a category filter is active, so clicking back clears it instead of leaving the page. */
export function usePageHeader(header: PageHeader) {
const setHeader = useContext(PageHeaderContext);
const title = header?.title;
const actions = header?.actions;
const onBack = header?.onBack;
useEffect(() => {
setHeader({ title, actions });
setHeader({ title, actions, onBack });
return () => setHeader(null);
// title/actions are the only meaningful inputs; setHeader is stable from AppLayout's state setter.
// title/actions/onBack are the only meaningful inputs; setHeader is stable from AppLayout's state setter.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title, actions]);
}, [title, actions, onBack]);
}
@@ -9,6 +9,7 @@ import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Collapse from "@mui/material/Collapse";
import Divider from "@mui/material/Divider";
import Tooltip from "@mui/material/Tooltip";
import SpaceDashboardIcon from "@mui/icons-material/SpaceDashboard";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
@@ -52,8 +53,8 @@ type Props = {
isAdmin: boolean;
};
/** The persistent app-wide navigation rail: Dashboard, Models, and (for admins) Administration.
* Folder browsing lives inside the Models page itself, not here. */
/** The persistent app-wide navigation rail: Dashboard, Models, Collections, and (for admins)
* Administration. Folder browsing lives inside the Models page itself, not here. */
export default function Sidebar({ isAdmin }: Props) {
const { t } = useTranslation(["app", "common"]);
const location = useLocation();
@@ -65,8 +66,8 @@ export default function Sidebar({ isAdmin }: Props) {
const [adminExpanded, setAdminExpanded] = useState(false);
const onDashboard = location.pathname === "/";
const onModels = location.pathname.startsWith("/models") || location.pathname.startsWith("/authors");
const onCollections = location.pathname.startsWith("/models/collections");
const onModels = (location.pathname.startsWith("/models") && !onCollections) || location.pathname.startsWith("/authors");
const onAdmin = location.pathname.startsWith("/admin-settings");
const adminOpen = adminExpanded || onAdmin;
@@ -106,7 +107,7 @@ export default function Sidebar({ isAdmin }: Props) {
direction="row"
alignItems="center"
justifyContent={collapsed ? "center" : "space-between"}
sx={{ px: collapsed ? 1 : 2, pt: 2, pb: 1.5 }}
sx={{ px: collapsed ? 1 : 2, pt: "20px", pb: "20px" }}
>
{!collapsed && <Wordmark size="sm" />}
<Tooltip title={collapsed ? t("sidebar.expandSidebar") : t("sidebar.collapseSidebar")}>
@@ -142,71 +143,78 @@ export default function Sidebar({ isAdmin }: Props) {
onClick={() => navigate("/models")}
/>
) : (
<>
<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>
</>
<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>
)}
{collapsed ? (
<CollapsedNavIcon
icon={<CollectionsIcon fontSize="small" />}
label={t("sidebar.collections")}
selected={onCollections}
onClick={() => navigate("/models/collections")}
/>
) : (
<ListItemButton
selected={onCollections}
onClick={() => navigate("/models/collections")}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<CollectionsIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("sidebar.collections")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
)}
{isAdmin && (
collapsed ? (
<CollapsedNavIcon
icon={<AdminPanelSettingsIcon fontSize="small" />}
label={t("sidebar.administration")}
selected={onAdmin}
onClick={() => navigate("/admin-settings")}
/>
) : (
<>
<ListItemButton
selected={false}
onClick={() => setAdminExpanded(v => !v)}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<AdminPanelSettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("sidebar.administration")} primaryTypographyProps={{ variant: "body2" }} />
{adminOpen ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</ListItemButton>
<Collapse in={adminOpen}>
<List disablePadding>
<ListItemButton
selected={onAdmin}
onClick={() => navigate("/admin-settings")}
sx={{ borderRadius: 1, mb: 0.5, pl: 4 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<SettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("common:settings")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
</List>
</Collapse>
</>
)
<>
<Divider sx={{ my: 1 }} />
{collapsed ? (
<CollapsedNavIcon
icon={<AdminPanelSettingsIcon fontSize="small" />}
label={t("sidebar.administration")}
selected={onAdmin}
onClick={() => navigate("/admin-settings")}
/>
) : (
<>
<ListItemButton
selected={false}
onClick={() => setAdminExpanded(v => !v)}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<AdminPanelSettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("sidebar.administration")} primaryTypographyProps={{ variant: "body2" }} />
{adminOpen ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</ListItemButton>
<Collapse in={adminOpen}>
<List disablePadding>
<ListItemButton
selected={onAdmin}
onClick={() => navigate("/admin-settings")}
sx={{ borderRadius: 1, mb: 0.5, pl: 4 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<SettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("common:settings")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
</List>
</Collapse>
</>
)}
</>
)}
</List>
</Box>
@@ -8,6 +8,7 @@ import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../util
import { useZipImportPrompt } from "./ZipImportModal";
import { useCollectionImportPrompt } from "./CollectionImportModal";
import { useImportModePrompt, type ImportMode } from "./ImportModeModal";
import { useImportJob } from "../Layout/ImportJobContext";
/** MakerWorld collection URLs (`/en/collections/{id}-{slug}`) list many models rather than
* being one model page -- route those to the collection picker instead of the single-link
@@ -48,6 +49,7 @@ export function useUploadImport({ onUploaded, folderId, makerworldCookie, thingi
const zipPrompt = useZipImportPrompt();
const collectionPrompt = useCollectionImportPrompt();
const importModePrompt = useImportModePrompt();
const { startCollectionImport, startZipImport } = useImportJob();
const isBusy = uploading || importing || zipPrompt.isOpen || collectionPrompt.isOpen || importModePrompt.isOpen;
const uploadFlatAsMultiplate = async (files: File[]) => {
@@ -180,11 +182,11 @@ export function useUploadImport({ onUploaded, folderId, makerworldCookie, thingi
}
},
onImportSelected: async (designIds: string[]) => {
// Registers the batch as a background job and returns almost immediately -- the
// global progress bar (ImportJobContext) takes over from here, and a notification
// + grid refresh follow once it actually finishes.
try {
const result = await importsApi.fromCollection({ ...payload, design_ids: designIds });
if (result.failed.length) {
alert(t("uploadBar.importFailedList", { files: result.failed.join(", ") }));
}
await startCollectionImport({ ...payload, design_ids: designIds });
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
@@ -192,7 +194,6 @@ export function useUploadImport({ onUploaded, folderId, makerworldCookie, thingi
}
throw err;
}
onUploaded();
},
});
return;
@@ -231,11 +232,10 @@ export function useUploadImport({ onUploaded, folderId, makerworldCookie, thingi
}
},
onImportSelected: async (entries: string[]) => {
// Same deal as the collection branch above: hands off to the background job +
// global progress bar instead of blocking here.
try {
const result = await importsApi.zipFromLink({ ...payload, entries });
if (result.failed.length) {
alert(t("uploadBar.importFailedList", { files: result.failed.join(", ") }));
}
await startZipImport({ ...payload, entries });
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
@@ -243,7 +243,6 @@ export function useUploadImport({ onUploaded, folderId, makerworldCookie, thingi
}
throw err;
}
onUploaded();
},
});
} catch (err) {
+38 -4
View File
@@ -84,7 +84,6 @@
"importing": "Importing…",
"importFailed": "Import failed",
"uploadFailed": "Failed to upload: {{files}}",
"importFailedList": "Failed to import: {{files}}",
"multiplateImportFailedWithMessage": "Multi-plate import ({{message}})",
"multiplateImportFailed": "Multi-plate import"
},
@@ -212,7 +211,8 @@
}
},
"addMenu": {
"importTitle": "Import from link"
"importTitle": "Import from link",
"disabledWhileImporting": "Wait for the current import to finish before starting another"
},
"userMenu": {
"theme": "Theme",
@@ -222,7 +222,21 @@
"serviceNotConnected": "{{service}}: not connected"
},
"notifications": {
"label": "Notifications"
"label": "Notifications",
"empty": "No notifications yet.",
"viewSource": "View source",
"justNow": "Just now",
"minutesAgo_one": "{{count}} minute ago",
"minutesAgo_other": "{{count}} minutes ago",
"hoursAgo_one": "{{count}} hour ago",
"hoursAgo_other": "{{count}} hours ago",
"daysAgo_one": "{{count}} day ago",
"daysAgo_other": "{{count}} days ago"
},
"importProgress": {
"starting": "Starting import…",
"label": "Importing… {{processed}} of {{total}}",
"tooltip": "{{imported}} imported, {{alreadyInLibrary}} already in your library, {{failedCount}} failed"
},
"dashboard": {
"comingSoon": "Dashboard panels are coming soon."
@@ -236,7 +250,10 @@
"storageDesc": "Organize files with folders, model names, creators, tags, and collections.",
"previewsTitle": "Performance",
"previewsHeading": "Model Previews",
"previewsDesc": "Choose automatic, on-demand, or disabled preview generation."
"previewsDesc": "Choose automatic, on-demand, or disabled preview generation.",
"triggersTitle": "Actions",
"triggersHeading": "Triggers",
"triggersDesc": "One-off actions that operate on another user's data."
},
"storage": {
"heading": "Storage Structure",
@@ -267,6 +284,23 @@
"disabledDesc": "Never generate card previews. Interactive viewing remains available by opening a model.",
"footerNote": "Embedded 3MF thumbnails and previously generated server thumbnails are always used when available.",
"failed": "Failed to save preview settings."
},
"triggers": {
"heading": "Triggers",
"subtitle": "Irreversible, one-off actions run against another user's account. Use with care.",
"deleteAllTitle": "Delete all models for a user",
"deleteAllDesc": "Permanently deletes every model a user created, uploaded, or imported — files included. Their categories and collections are left in place, just empty.",
"userLabel": "User",
"userOption": "{{name}} ({{email}}) — {{count}} models",
"deleteAllButton": "Delete all models…",
"loadFailed": "Unable to load users.",
"deleteSuccess": "Deleted {{count}} model(s) for {{email}}.",
"deleteFailed": "Failed to delete this user's models.",
"confirmTitle": "Delete all models for this user?",
"confirmWarning": "This permanently deletes all {{count}} model(s) belonging to {{email}}, including their files. This cannot be undone.",
"confirmInstructions": "Type {{email}} to confirm.",
"confirmButton": "Delete all models",
"deleting": "Deleting…"
}
}
},
+38 -4
View File
@@ -84,7 +84,6 @@
"importing": "Importuojama…",
"importFailed": "Importuoti nepavyko",
"uploadFailed": "Nepavyko įkelti: {{files}}",
"importFailedList": "Nepavyko importuoti: {{files}}",
"multiplateImportFailedWithMessage": "Kelių plokščių importas ({{message}})",
"multiplateImportFailed": "Kelių plokščių importas"
},
@@ -212,7 +211,8 @@
}
},
"addMenu": {
"importTitle": "Importuoti iš nuorodos"
"importTitle": "Importuoti iš nuorodos",
"disabledWhileImporting": "Palaukite, kol baigsis dabartinis importas, prieš pradėdami kitą"
},
"userMenu": {
"theme": "Tema",
@@ -222,7 +222,21 @@
"serviceNotConnected": "{{service}}: neprijungta"
},
"notifications": {
"label": "Pranešimai"
"label": "Pranešimai",
"empty": "Kol kas nėra pranešimų.",
"viewSource": "Žiūrėti šaltinį",
"justNow": "Ką tik",
"minutesAgo_one": "prieš {{count}} minutę",
"minutesAgo_other": "prieš {{count}} minučių",
"hoursAgo_one": "prieš {{count}} valandą",
"hoursAgo_other": "prieš {{count}} valandų",
"daysAgo_one": "prieš {{count}} dieną",
"daysAgo_other": "prieš {{count}} dienų"
},
"importProgress": {
"starting": "Pradedamas importas…",
"label": "Importuojama… {{processed}} iš {{total}}",
"tooltip": "{{imported}} importuota, {{alreadyInLibrary}} jau bibliotekoje, {{failedCount}} nepavyko"
},
"dashboard": {
"comingSoon": "Valdymo skydo panelės netrukus atsiras."
@@ -236,7 +250,10 @@
"storageDesc": "Tvarkykite failus pagal aplankus, modelių pavadinimus, autorius, žymas ir kolekcijas.",
"previewsTitle": "Našumas",
"previewsHeading": "Modelių peržiūros",
"previewsDesc": "Pasirinkite automatinį, pagal poreikį arba išjungtą peržiūrų generavimą."
"previewsDesc": "Pasirinkite automatinį, pagal poreikį arba išjungtą peržiūrų generavimą.",
"triggersTitle": "Veiksmai",
"triggersHeading": "Trigeriai",
"triggersDesc": "Vienkartiniai veiksmai, veikiantys kito naudotojo duomenis."
},
"storage": {
"heading": "Saugojimo struktūra",
@@ -267,6 +284,23 @@
"disabledDesc": "Niekada negeneruoti kortelių peržiūrų. Interaktyvi peržiūra vis tiek pasiekiama atidarius modelį.",
"footerNote": "Įtaisytosios 3MF miniatiūros ir anksčiau sugeneruotos serverio miniatiūros visada naudojamos, kai jos yra.",
"failed": "Nepavyko išsaugoti peržiūrų nustatymų."
},
"triggers": {
"heading": "Trigeriai",
"subtitle": "Negrįžtami, vienkartiniai veiksmai, atliekami su kito naudotojo paskyra. Naudokite atsargiai.",
"deleteAllTitle": "Ištrinti visus naudotojo modelius",
"deleteAllDesc": "Negrįžtamai ištrina visus naudotojo sukurtus, įkeltus ar importuotus modelius — kartu su failais. Jo kategorijos ir kolekcijos lieka, tik tuščios.",
"userLabel": "Naudotojas",
"userOption": "{{name}} ({{email}}) — {{count}} modelių",
"deleteAllButton": "Ištrinti visus modelius…",
"loadFailed": "Nepavyko įkelti naudotojų.",
"deleteSuccess": "Ištrinta {{count}} modelis(-ių), priklausančių {{email}}.",
"deleteFailed": "Nepavyko ištrinti šio naudotojo modelių.",
"confirmTitle": "Ištrinti visus šio naudotojo modelius?",
"confirmWarning": "Tai negrįžtamai ištrins visus {{count}} modelį(-ius), priklausantį(-čius) {{email}}, įskaitant jų failus. Šio veiksmo anuliuoti negalima.",
"confirmInstructions": "Norėdami patvirtinti, įveskite {{email}}.",
"confirmButton": "Ištrinti visus modelius",
"deleting": "Trinama…"
}
}
},
@@ -0,0 +1,195 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
import TextField from "@mui/material/TextField";
import { UnauthorizedError } from "../../api/client";
import { adminApi, type AdminUser } from "../../api/admin";
import SectionHeader from "../../components/SectionHeader";
type Props = {
onUnauthorized?: () => void;
onBack: () => void;
};
/** Admin-only "trigger" actions -- one-off, high-blast-radius operations run against another
* user's data. Today there's a single trigger (delete all of a user's models); this file
* intentionally stays a plain list of Paper sections rather than a generic "trigger registry"
* until there's a second one to justify the abstraction. */
export default function TriggersSection({ onUnauthorized, onBack }: Props) {
const { t } = useTranslation(["app", "common"]);
const [users, setUsers] = React.useState<AdminUser[]>([]);
const [loading, setLoading] = React.useState(true);
const [selectedUserId, setSelectedUserId] = React.useState("");
const [confirmOpen, setConfirmOpen] = React.useState(false);
const [confirmText, setConfirmText] = React.useState("");
const [deleting, setDeleting] = React.useState(false);
const [status, setStatus] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const loadUsers = React.useCallback(async () => {
setLoading(true);
try {
setUsers(await adminApi.listUsers());
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(t("adminSettings.triggers.loadFailed"));
} finally {
setLoading(false);
}
}, [onUnauthorized, t]);
React.useEffect(() => { void loadUsers(); }, [loadUsers]);
const selectedUser = users.find(u => u.id === selectedUserId) || null;
const openConfirm = () => {
if (!selectedUser) return;
setConfirmText("");
setError(null);
setConfirmOpen(true);
};
const closeConfirm = () => {
if (deleting) return;
setConfirmOpen(false);
};
const confirmMatches = Boolean(selectedUser) && confirmText.trim().toLowerCase() === selectedUser?.email.toLowerCase();
const handleDelete = async () => {
if (!selectedUser || !confirmMatches) return;
setDeleting(true);
setError(null);
try {
const result = await adminApi.deleteAllPrintsForUser(selectedUser.id);
setStatus(t("adminSettings.triggers.deleteSuccess", { count: result.deleted, email: selectedUser.email }));
setConfirmOpen(false);
await loadUsers();
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
setError(err instanceof Error ? err.message : t("adminSettings.triggers.deleteFailed"));
} finally {
setDeleting(false);
}
};
return (
<Stack spacing={3}>
<SectionHeader
title={t("adminSettings.triggers.heading")}
subtitle={t("adminSettings.triggers.subtitle")}
onBack={onBack}
backLabel={t("adminSettings.back")}
/>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Box>
<Typography variant="subtitle1" fontWeight={600}>
{t("adminSettings.triggers.deleteAllTitle")}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("adminSettings.triggers.deleteAllDesc")}
</Typography>
</Box>
{loading ? (
<Stack alignItems="center" sx={{ py: 2 }}>
<CircularProgress size={20} />
</Stack>
) : (
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<FormControl size="small" sx={{ minWidth: 300 }}>
<InputLabel id="trigger-user-select-label">{t("adminSettings.triggers.userLabel")}</InputLabel>
<Select
labelId="trigger-user-select-label"
label={t("adminSettings.triggers.userLabel")}
value={selectedUserId}
onChange={e => setSelectedUserId(e.target.value)}
disabled={deleting}
>
{users.map(u => (
<MenuItem key={u.id} value={u.id}>
{t("adminSettings.triggers.userOption", { name: u.display_name, email: u.email, count: u.print_count })}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
color="error"
disabled={!selectedUser || !selectedUser.print_count || deleting}
onClick={openConfirm}
>
{t("adminSettings.triggers.deleteAllButton")}
</Button>
</Stack>
)}
{status && <Alert severity="success" onClose={() => setStatus(null)}>{status}</Alert>}
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
</Stack>
</Paper>
<Dialog open={confirmOpen} onClose={closeConfirm} maxWidth="sm" fullWidth>
<DialogTitle>{t("adminSettings.triggers.confirmTitle")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 0.5 }}>
{selectedUser && (
<Alert severity="warning">
{t("adminSettings.triggers.confirmWarning", {
count: selectedUser.print_count,
email: selectedUser.email,
})}
</Alert>
)}
<DialogContentText>
{selectedUser && t("adminSettings.triggers.confirmInstructions", { email: selectedUser.email })}
</DialogContentText>
<TextField
fullWidth
size="small"
value={confirmText}
onChange={e => setConfirmText(e.target.value)}
placeholder={selectedUser?.email}
disabled={deleting}
// Deliberate: focus the confirmation field the moment the dialog opens.
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closeConfirm} disabled={deleting}>{t("common:cancel")}</Button>
<Button
variant="contained"
color="error"
onClick={handleDelete}
disabled={!confirmMatches || deleting}
startIcon={deleting ? <CircularProgress size={14} color="inherit" /> : undefined}
>
{deleting ? t("adminSettings.triggers.deleting") : t("adminSettings.triggers.confirmButton")}
</Button>
</DialogActions>
</Dialog>
</Stack>
);
}
+12 -1
View File
@@ -8,13 +8,14 @@ import Typography from "@mui/material/Typography";
import type { PreviewMode } from "../../api/settings";
import StorageSection from "./StorageSection";
import PreviewsSection from "./PreviewsSection";
import TriggersSection from "./TriggersSection";
type Props = {
onUnauthorized?: () => void;
onPreviewModeChanged?: (mode: PreviewMode) => void;
};
type Section = "root" | "storage" | "previews";
type Section = "root" | "storage" | "previews" | "triggers";
// Instance-wide config, gated to admins by the sidebar link that opens this page -- unlike
// SettingsPage, nothing here is a per-user preference.
@@ -37,6 +38,10 @@ export default function AdminSettingsPage({ onUnauthorized, onPreviewModeChanged
);
}
if (section === "triggers") {
return <TriggersSection onUnauthorized={onUnauthorized} onBack={backToRoot} />;
}
const rootCards: Array<{ eyebrow: string; heading: string; desc: string; onClick: () => void }> = [
{
eyebrow: t("adminSettings.root.storageTitle"),
@@ -50,6 +55,12 @@ export default function AdminSettingsPage({ onUnauthorized, onPreviewModeChanged
desc: t("adminSettings.root.previewsDesc"),
onClick: () => setSection("previews"),
},
{
eyebrow: t("adminSettings.root.triggersTitle"),
heading: t("adminSettings.root.triggersHeading"),
desc: t("adminSettings.root.triggersDesc"),
onClick: () => setSection("triggers"),
},
];
return (
@@ -9,6 +9,7 @@ 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 type { SxProps, Theme } from "@mui/material/styles";
import { UnauthorizedError } from "../../api/client";
import { type Collection, type CollectionInput, collectionsApi } from "../../api/collections";
import { useConfirm } from "../../components/ConfirmProvider";
@@ -19,11 +20,15 @@ type Props = {
onUpdated: (collection: Collection) => void;
onUnauthorized?: () => void;
onDeleted: () => void;
/** Lets callers restyle the trigger button -- e.g. the Collections grid's hover overlay,
* which needs to read over an arbitrary cover photo instead of the header's plain icon. */
triggerSx?: SxProps<Theme>;
};
/** 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) {
/** The "..." menu for a collection: Edit (opens the same create/edit modal used from the
* Collections grid, prefilled) and Delete (confirm, then delete). Shared by the collection
* detail page's header and the Collections grid's per-card hover overlay. */
export default function CollectionActionsMenu({ collection, onUpdated, onUnauthorized, onDeleted, triggerSx }: Props) {
const { t } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
@@ -74,6 +79,7 @@ export default function CollectionActionsMenu({ collection, onUpdated, onUnautho
onClick={e => setAnchorEl(e.currentTarget)}
aria-label={t("common:more") ?? undefined}
disabled={deleting}
sx={triggerSx}
>
{deleting ? <CircularProgress size={18} /> : <MoreVertIcon fontSize="small" />}
</IconButton>
@@ -126,7 +126,7 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize
)}
{items.length ? (
<Stack spacing={2}>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", columnGap: "20px", rowGap: "20px" }}>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", columnGap: "20px", rowGap: "20px" }}>
{items.map(item => (
<ModelCard key={item.id} item={item} theme={theme} previewMode={previewMode} />
))}
@@ -11,11 +11,15 @@ import { type PreviewMode } from "../../api/settings";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
import { collectionDisplayName } from "../../utils/collectionDisplay";
import CollectionActionsMenu from "../CollectionDetailPage/CollectionActionsMenu";
type Props = {
collection: Collection;
theme: ResolvedTheme;
previewMode: PreviewMode;
onUpdated: (collection: Collection) => void;
onDeleted: (id: string) => void;
onUnauthorized?: () => void;
};
const COVER_TILE_LIMIT = 4;
@@ -65,21 +69,27 @@ function CoverTile({
);
}
export default function CollectionCard({ collection, theme, previewMode }: Props) {
export default function CollectionCard({ collection, theme, previewMode, onUpdated, onDeleted, onUnauthorized }: 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);
// With a single model, opening the collection would just show that one card again -- go
// straight to the model instead, same as clicking its cover tile above already does.
const soleModelId = collection.item_count === 1 ? coverItems[0]?.id : undefined;
const openTarget = soleModelId ? `/models/${soleModelId}` : `/models/collections/${collection.id}`;
return (
<Paper
variant="outlined"
sx={{
position: "relative",
overflow: "hidden",
borderRadius: "12px",
borderColor: "divider",
bgcolor: (muiTheme) => (muiTheme.palette.mode === "dark" ? muiTheme.palette.grey[800] : muiTheme.palette.grey[100]),
"&:hover .collection-card-actions": { opacity: 1 },
}}
>
<Box
@@ -121,8 +131,32 @@ export default function CollectionCard({ collection, theme, previewMode }: Props
)}
</Box>
{!collection.system_key && (
<Box
className="collection-card-actions"
onClick={e => e.stopPropagation()}
sx={{
position: "absolute",
top: 8,
right: 8,
opacity: 0,
transition: "opacity .15s ease",
bgcolor: "rgba(0, 0, 0, 0.55)",
borderRadius: "50%",
}}
>
<CollectionActionsMenu
collection={collection}
onUpdated={onUpdated}
onDeleted={() => onDeleted(collection.id)}
onUnauthorized={onUnauthorized}
triggerSx={{ color: "#fff" }}
/>
</Box>
)}
<Box
onClick={() => navigate(`/models/collections/${collection.id}`)}
onClick={() => navigate(openTarget)}
sx={{
p: 1.5,
cursor: "pointer",
+32 -13
View File
@@ -10,6 +10,7 @@ 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 { usePageHeader } from "../../components/Layout/PageHeaderContext";
import CollectionCard from "./CollectionCard";
import CollectionFormModal from "./CollectionFormModal";
@@ -25,6 +26,19 @@ export default function CollectionsPage({ onUnauthorized, theme, previewMode }:
const [loading, setLoading] = useState(false);
const [formOpen, setFormOpen] = useState(false);
usePageHeader({
actions: (
<Button
variant="contained"
size="small"
startIcon={<AddIcon fontSize="small" />}
onClick={() => setFormOpen(true)}
>
{t("models:collections.newCollection")}
</Button>
),
});
const handleError = (err: unknown, message?: string) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
@@ -56,27 +70,32 @@ export default function CollectionsPage({ onUnauthorized, theme, previewMode }:
await load();
};
const handleCollectionUpdated = (updated: Collection) => {
setCollections(prev => prev.map(c => (c.id === updated.id ? updated : c)));
};
const handleCollectionDeleted = (id: string) => {
setCollections(prev => prev.filter(c => c.id !== id));
};
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" }}>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(6, 1fr)", columnGap: "20px", rowGap: "20px" }}>
{collections.map(collection => (
<CollectionCard key={collection.id} collection={collection} theme={theme} previewMode={previewMode} />
<CollectionCard
key={collection.id}
collection={collection}
theme={theme}
previewMode={previewMode}
onUpdated={handleCollectionUpdated}
onDeleted={handleCollectionDeleted}
onUnauthorized={onUnauthorized}
/>
))}
</Box>
) : (
+11 -1
View File
@@ -10,6 +10,7 @@ import { type Print, printsApi } from "../../api/prints";
import { type Folder, type FolderMetaInput, foldersApi } from "../../api/folders";
import { type PreviewMode } from "../../api/settings";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import CategoriesPanel from "./CategoriesPanel";
import ModelCard from "./ModelCard";
@@ -50,6 +51,15 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o
return [folderId, ...childIds];
}, [folderId, folders]);
// Plain "Models" (and the route's default back-to-Dashboard) while nothing's selected; once a
// category is active, the title reflects it and back instead clears the filter -- "back to
// all" one level at a time, matching the sidebar's own initial-vs-filtered framing.
const selectedFolder = folderId ? folders.find(f => f.id === folderId) ?? null : null;
usePageHeader({
title: selectedFolder ? `${t("models:pageTitle")} - ${selectedFolder.name || t("models:categories.untitled")}` : undefined,
onBack: folderId ? () => onSelectFolder(null) : undefined,
});
const handleError = (err: unknown, message?: string) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
@@ -173,7 +183,7 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o
</Stack>
) : items.length ? (
<Stack spacing={2}>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", columnGap: "20px", rowGap: "20px" }}>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", columnGap: "20px", rowGap: "20px" }}>
{items.map(item => (
<ModelCard key={item.id} item={item} theme={theme} previewMode={previewMode} />
))}