mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: bulk feature changes
This commit is contained in:
1 parent
b5568d35e6
commit
7d185395a3
44 files changed
+1939
-319
No files matched your search
+63
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user