mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: allow downloading collection and tag models in bulk
This commit is contained in:
1 parent
25b84cf559
commit
d3c651c05d
9 files changed
+393
-37
No files matched your search
@@ -17,7 +17,7 @@ import { toPrintOut } from "../dto";
|
||||
import { loadFullPrint, printOutById } from "../services/printLoader";
|
||||
import { deleteAllPrintFiles, saveFileFromTemp } from "../services/printFileService";
|
||||
import { RENDERABLE_MODEL_EXTS } from "../config";
|
||||
import { sendPrintsZip } from "../services/downloadZip";
|
||||
import { estimateDownloadSize, resolvePrintsForDownload, sendPrintsZip } from "../services/downloadZip";
|
||||
import { systemCollectionKeyForId } from "../services/collectionService";
|
||||
import { createLog } from "../services/auditLog";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
@@ -337,33 +337,48 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- POST /download/zip --------------------------------------------------------------------
|
||||
// ---- POST /download/zip[/summary] ----------------------------------------------------------
|
||||
// Both routes accept the same filter (see DownloadZipFilter): print_ids, tag, category_id, and/or
|
||||
// collection_id, combined with AND when more than one is given. /summary is the "are you sure?"
|
||||
// confirmation step -- model count + an upper-bound size estimate, computed straight from stored
|
||||
// Plate/PrintFile size columns with no filesystem access and no zip actually built (see
|
||||
// estimateDownloadSize) -- called before the real download in DownloadZipConfirmDialog.
|
||||
|
||||
const downloadSchema = z.object({
|
||||
const downloadFilterSchema = z.object({
|
||||
print_ids: z.array(z.string()).optional(),
|
||||
tag: z.string().optional(),
|
||||
category_id: z.string().optional(),
|
||||
filename: z.string().optional(),
|
||||
collection_id: z.string().optional(),
|
||||
});
|
||||
const downloadSchema = downloadFilterSchema.extend({ filename: z.string().optional() });
|
||||
|
||||
function assertDownloadFilterGiven(body: z.infer<typeof downloadFilterSchema>): void {
|
||||
if (!(body.print_ids?.length || body.tag || body.category_id || body.collection_id)) {
|
||||
throw new HttpError(400, "Provide print_ids, tag, category_id, or collection_id to download.");
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/download/zip/summary",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(downloadFilterSchema, req.body);
|
||||
assertDownloadFilterGiven(body);
|
||||
const prints = await resolvePrintsForDownload(req.userId!, body);
|
||||
res.json({ count: prints.length, size_bytes: await estimateDownloadSize(prints) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/download/zip",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(downloadSchema, req.body);
|
||||
if (!(body.print_ids?.length || body.tag || body.category_id)) {
|
||||
throw new HttpError(400, "Provide print_ids, tag, or category_id to download.");
|
||||
}
|
||||
const where: Prisma.PrintWhereInput = { userId: req.userId };
|
||||
if (body.print_ids?.length) where.id = { in: body.print_ids };
|
||||
if (body.category_id) where.categoryId = body.category_id;
|
||||
let prints = await prisma.print.findMany({
|
||||
where,
|
||||
include: { plates: { orderBy: { position: "asc" } }, category: true },
|
||||
});
|
||||
if (body.tag) {
|
||||
const tag = body.tag.trim();
|
||||
prints = prints.filter((p) => p.tags.includes(tag));
|
||||
}
|
||||
assertDownloadFilterGiven(body);
|
||||
const prints = await resolvePrintsForDownload(req.userId!, body);
|
||||
|
||||
// A tag or collection download cuts across categories by nature, so its zip skips the usual
|
||||
// per-category subfolder (see buildZipEntries) -- a category download's entries all share one
|
||||
// category anyway, and a bare print_ids selection keeps its existing nested layout.
|
||||
const flatten = Boolean(body.tag || body.collection_id);
|
||||
|
||||
let downloadName = body.filename || "thingport.zip";
|
||||
if (body.tag) {
|
||||
@@ -377,7 +392,14 @@ router.post(
|
||||
downloadName = `${safeName}.zip`;
|
||||
}
|
||||
}
|
||||
await sendPrintsZip(res, prints, downloadName);
|
||||
if (body.collection_id) {
|
||||
const collection = await prisma.collection.findFirst({ where: { id: body.collection_id, userId: req.userId } });
|
||||
if (collection) {
|
||||
const safeName = collection.name.replace(/ /g, "_").slice(0, 50) || "collection";
|
||||
downloadName = `${safeName}.zip`;
|
||||
}
|
||||
}
|
||||
await sendPrintsZip(res, prints, downloadName, { flatten });
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -8,15 +8,77 @@ import { HttpError } from "../utils/fileUtils";
|
||||
import { writeZip, type ZipEntryDescriptor } from "../utils/zipWriter";
|
||||
import { resolvePlateFilePath } from "./printCreation";
|
||||
import { managedPrintFilePath } from "./printFileService";
|
||||
import type { Category, Plate, Print } from "@prisma/client";
|
||||
import type { Category, Plate, Print, Prisma } from "@prisma/client";
|
||||
|
||||
type PrintWithPlatesAndCategory = Print & { plates: Plate[]; category: Category | null };
|
||||
|
||||
/** The filters GET /download/zip, POST /download/zip/summary, and their category/tag/collection
|
||||
* callers all resolve the same way -- see resolvePrintsForDownload. At least one of these must
|
||||
* be set; combining more than one ANDs them together (e.g. category_id + tag). */
|
||||
export type DownloadZipFilter = {
|
||||
print_ids?: string[];
|
||||
tag?: string;
|
||||
category_id?: string;
|
||||
collection_id?: string;
|
||||
};
|
||||
|
||||
/** Resolves `filter` (see DownloadZipFilter) to this user's own matching prints, plates included
|
||||
* (needed both to build the zip and to size-estimate it without building one). `tag` is applied
|
||||
* client-side after the DB query, same as GET /tags in prints.ts -- a tag is just a value inside
|
||||
* Print.tags, not a column a `where` can filter on directly. Throws 404 if `collection_id` is
|
||||
* given but doesn't name one of this user's own (real, non-system) collections. */
|
||||
export async function resolvePrintsForDownload(
|
||||
userId: string,
|
||||
filter: DownloadZipFilter,
|
||||
): Promise<PrintWithPlatesAndCategory[]> {
|
||||
const where: Prisma.PrintWhereInput = { userId };
|
||||
if (filter.print_ids?.length) where.id = { in: filter.print_ids };
|
||||
if (filter.category_id) where.categoryId = filter.category_id;
|
||||
if (filter.collection_id) {
|
||||
const collection = await prisma.collection.findFirst({ where: { id: filter.collection_id, userId } });
|
||||
if (!collection) throw new HttpError(404, "Collection not found");
|
||||
where.collectionItems = { some: { collectionId: filter.collection_id } };
|
||||
}
|
||||
let prints = await prisma.print.findMany({
|
||||
where,
|
||||
include: { plates: { orderBy: { position: "asc" } }, category: true },
|
||||
});
|
||||
if (filter.tag) {
|
||||
const tag = filter.tag.trim();
|
||||
prints = prints.filter((p) => p.tags.includes(tag));
|
||||
}
|
||||
return prints;
|
||||
}
|
||||
|
||||
/** Sum of every resolved print's plate + supporting-file sizes, straight from their stored
|
||||
* Plate.size/PrintFile.size columns -- no filesystem access and no zip actually built. This is
|
||||
* necessarily an *upper bound* on the real zip's size (DEFLATE only ever shrinks), not the exact
|
||||
* byte count, but it's cheap enough to compute on every "are you sure?" confirmation (see
|
||||
* POST /download/zip/summary) without pregenerating anything. */
|
||||
export async function estimateDownloadSize(prints: PrintWithPlatesAndCategory[]): Promise<number> {
|
||||
const plateBytes = prints.reduce((sum, p) => sum + p.plates.reduce((s, plate) => s + plate.size, 0), 0);
|
||||
const printIds = prints.map((p) => p.id);
|
||||
if (!printIds.length) return plateBytes;
|
||||
const supporting = await prisma.printFile.aggregate({
|
||||
where: { printId: { in: printIds }, role: "SUPPORTING" },
|
||||
_sum: { size: true },
|
||||
});
|
||||
return plateBytes + (supporting._sum.size ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds zip entries for a set of prints: `{category_or_unassigned}/{print.name}/{plate.filename}`
|
||||
* for every plate, and `.../supporting/{file.filename}` for each print's supporting files.
|
||||
* for every plate, and `.../supporting/{file.filename}` for each print's supporting files --
|
||||
* or, with `flatten: true`, the same without the leading category folder (just
|
||||
* `{print.name}/{plate.filename}`). Used for collection/tag downloads, which by nature cut across
|
||||
* categories -- nesting under whatever category each model happens to also be in would just be
|
||||
* noise there, unlike a plain category download (where every entry shares the same category
|
||||
* anyway) or an arbitrary print_ids selection (existing behavior, left unchanged).
|
||||
*/
|
||||
export async function buildZipEntries(prints: PrintWithPlatesAndCategory[]): Promise<ZipEntryDescriptor[]> {
|
||||
export async function buildZipEntries(
|
||||
prints: PrintWithPlatesAndCategory[],
|
||||
opts: { flatten?: boolean } = {},
|
||||
): Promise<ZipEntryDescriptor[]> {
|
||||
const printIds = prints.map((p) => p.id);
|
||||
const supportingByPrint = new Map<string, { filename: string; storagePath: string }[]>();
|
||||
if (printIds.length) {
|
||||
@@ -32,25 +94,31 @@ export async function buildZipEntries(prints: PrintWithPlatesAndCategory[]): Pro
|
||||
|
||||
const entries: ZipEntryDescriptor[] = [];
|
||||
for (const print of prints) {
|
||||
const categoryPart = print.category?.name || "unassigned";
|
||||
const prefix = opts.flatten ? print.name : `${print.category?.name || "unassigned"}/${print.name}`;
|
||||
const sortedPlates = print.plates.toSorted((a, b) => a.position - b.position);
|
||||
for (const plate of sortedPlates) {
|
||||
const filePath = resolvePlateFilePath(plate);
|
||||
if (filePath) entries.push({ arcname: `${categoryPart}/${print.name}/${plate.filename}`, filePath });
|
||||
if (filePath) entries.push({ arcname: `${prefix}/${plate.filename}`, filePath });
|
||||
}
|
||||
for (const file of supportingByPrint.get(print.id) ?? []) {
|
||||
const filePath = managedPrintFilePath(file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
entries.push({ arcname: `${categoryPart}/${print.name}/supporting/${file.filename}`, filePath });
|
||||
entries.push({ arcname: `${prefix}/supporting/${file.filename}`, filePath });
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Builds a zip from `prints` and streams it as the HTTP response, deleting the temp file after. */
|
||||
export async function sendPrintsZip(res: Response, prints: PrintWithPlatesAndCategory[], downloadName: string): Promise<void> {
|
||||
const entries = await buildZipEntries(prints);
|
||||
/** Builds a zip from `prints` and streams it as the HTTP response, deleting the temp file after.
|
||||
* See buildZipEntries for `flatten`. */
|
||||
export async function sendPrintsZip(
|
||||
res: Response,
|
||||
prints: PrintWithPlatesAndCategory[],
|
||||
downloadName: string,
|
||||
opts: { flatten?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const entries = await buildZipEntries(prints, opts);
|
||||
if (!entries.length) throw new HttpError(404, "No files available for download");
|
||||
|
||||
const tmpPath = path.join(os.tmpdir(), `thingport-zip-${crypto.randomBytes(8).toString("hex")}.zip`);
|
||||
|
||||
@@ -96,6 +96,21 @@ export type UploadPrintsResult = {
|
||||
|
||||
export type PrintSortMode = "newest" | "popular" | "downloads";
|
||||
|
||||
/** Selects which prints a zip download covers -- combined with AND when more than one is set.
|
||||
* See backend's downloadZip.ts (DownloadZipFilter/resolvePrintsForDownload), which both
|
||||
* POST /download/zip and POST /download/zip/summary resolve this the same way. */
|
||||
export type DownloadZipFilter = {
|
||||
print_ids?: string[];
|
||||
tag?: string;
|
||||
category_id?: string;
|
||||
collection_id?: string;
|
||||
};
|
||||
|
||||
export type DownloadZipSummary = {
|
||||
count: number;
|
||||
size_bytes: number;
|
||||
};
|
||||
|
||||
export const printsApi = {
|
||||
// API returns relative URLs. Join with API base.
|
||||
fileUrl: (rel: string) => {
|
||||
@@ -400,7 +415,7 @@ export const printsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
downloadZip: async (opts: { print_ids?: string[]; tag?: string; category_id?: string; filename?: string }) => {
|
||||
downloadZip: async (opts: DownloadZipFilter & { filename?: string }) => {
|
||||
const res = await fetch(`${apiBase()}/download/zip`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
@@ -410,6 +425,19 @@ export const printsApi = {
|
||||
return res;
|
||||
},
|
||||
|
||||
/** How many models `filter` resolves to, and an upper-bound size estimate (bytes) for their
|
||||
* combined model files -- straight from stored file sizes, no zip actually built. Backs
|
||||
* DownloadZipConfirmDialog's "are you sure?" step before the real download above. */
|
||||
downloadZipSummary: async (filter: DownloadZipFilter): Promise<DownloadZipSummary> => {
|
||||
const res = await fetch(`${apiBase()}/download/zip/summary`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(filter),
|
||||
});
|
||||
assertOk(res, "Failed to estimate download size");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Records a completed download of this print (called once per explicit download action --
|
||||
* single-file, per-plate, or "download all as zip" -- from the model detail page). */
|
||||
recordDownload: async (id: string) => {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import Button from "@mui/material/Button";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { UnauthorizedError } from "../api/client";
|
||||
import { type DownloadZipFilter, printsApi } from "../api/prints";
|
||||
import { formatFileSize } from "../utils/fileSize";
|
||||
import { saveResponseToDisk } from "../utils/downloadResponse";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Which prints this covers -- see DownloadZipFilter. Combined with `filename` this is sent
|
||||
* as-is to both POST /download/zip/summary (on open) and POST /download/zip (on confirm). */
|
||||
filter: DownloadZipFilter;
|
||||
filename: string;
|
||||
/** Dialog title -- callers phrase this themselves (e.g. "Download collection as zip", or
|
||||
* "Download tag \"foo\" as zip") since only they know which context this is. */
|
||||
title: string;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** The "are you sure?" step shown before any group zip download (a whole collection, or every
|
||||
* model carrying a tag) -- shows how many models are included and an approximate combined file
|
||||
* size *before* committing to building the zip, fetched via POST /download/zip/summary (model
|
||||
* count + a sum of stored Plate/PrintFile sizes -- an upper bound on the real zip, since DEFLATE
|
||||
* only ever shrinks, but cheap enough to compute without generating anything). If a future
|
||||
* filter type can't size itself this cheaply, `size_bytes` can come back as 0/omitted and this
|
||||
* just shows the model count alone -- see the summary rendering below. */
|
||||
export default function DownloadZipConfirmDialog({ open, onClose, filter, filename, title, onUnauthorized }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
const [sizeBytes, setSizeBytes] = useState<number | null>(null);
|
||||
const [summaryError, setSummaryError] = useState<string | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setSummaryError(null);
|
||||
setCount(null);
|
||||
setSizeBytes(null);
|
||||
setDownloadError(null);
|
||||
(async () => {
|
||||
try {
|
||||
const summary = await printsApi.downloadZipSummary(filter);
|
||||
if (cancelled) return;
|
||||
setCount(summary.count);
|
||||
setSizeBytes(summary.size_bytes || null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
setSummaryError(t("models:downloadZip.summaryFailed"));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// filter/filename are provided fresh by the caller on every open (a new object literal each
|
||||
// render), so keying only on `open` avoids re-fetching the summary on every parent re-render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (downloading) return;
|
||||
setDownloading(true);
|
||||
setDownloadError(null);
|
||||
try {
|
||||
const res = await printsApi.downloadZip({ ...filter, filename });
|
||||
await saveResponseToDisk(res, filename);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
setDownloadError(err instanceof Error ? err.message : t("models:downloadZip.failed"));
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const busy = loading || downloading;
|
||||
const empty = count === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={() => !busy && onClose()} fullWidth maxWidth="xs">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={1.5} sx={{ mt: 0.5 }}>
|
||||
{loading && (
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ color: "text.secondary" }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2">{t("models:downloadZip.loading")}</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{!loading && summaryError && <Alert severity="error">{summaryError}</Alert>}
|
||||
{!loading && !summaryError && empty && <Alert severity="info">{t("models:downloadZip.empty")}</Alert>}
|
||||
{!loading && !summaryError && !empty && count !== null && (
|
||||
<Typography variant="body2">
|
||||
{sizeBytes
|
||||
? t("models:downloadZip.summary", { count, size: formatFileSize(sizeBytes) })
|
||||
: t("models:downloadZip.summaryNoSize", { count })}
|
||||
</Typography>
|
||||
)}
|
||||
{downloadError && <Alert severity="error">{downloadError}</Alert>}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={busy}>{t("common:cancel")}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleDownload}
|
||||
disabled={busy || empty || Boolean(summaryError)}
|
||||
startIcon={downloading ? <CircularProgress size={14} color="inherit" /> : <DownloadIcon fontSize="small" />}
|
||||
>
|
||||
{downloading ? t("models:downloadZip.downloading") : t("common:download")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -546,6 +546,17 @@
|
||||
"uploading": "Uploading to {{target}}...",
|
||||
"prompt": "Drop files or folders to upload to {{target}}"
|
||||
},
|
||||
"downloadZip": {
|
||||
"loading": "Calculating…",
|
||||
"summary_one": "{{count}} model (~{{size}})",
|
||||
"summary_other": "{{count}} models (~{{size}})",
|
||||
"summaryNoSize_one": "{{count}} model",
|
||||
"summaryNoSize_other": "{{count}} models",
|
||||
"summaryFailed": "Couldn't calculate the download size. Please try again.",
|
||||
"empty": "No downloadable model files found.",
|
||||
"downloading": "Downloading…",
|
||||
"failed": "Failed to download the zip file. Please try again."
|
||||
},
|
||||
"errors": {
|
||||
"loadPrintsFailed": "Failed to load prints. Please sign in again or refresh.",
|
||||
"loadMoreFailed": "Failed to load more prints.",
|
||||
@@ -1162,6 +1173,8 @@
|
||||
"bookmarkCollection": "Bookmark collection",
|
||||
"unbookmarkCollection": "Remove bookmark",
|
||||
"bookmarkFailed": "Failed to update bookmark. Please try again.",
|
||||
"downloadAllZip": "Download all models as zip",
|
||||
"downloadZipTitle": "Download \"{{name}}\" as zip",
|
||||
"system": {
|
||||
"favorites": "Favourites",
|
||||
"history": "Browsing History"
|
||||
@@ -1201,6 +1214,8 @@
|
||||
"bookmarkTag": "Bookmark tag",
|
||||
"unbookmarkTag": "Remove bookmark",
|
||||
"bookmarkFailed": "Failed to update bookmark. Please try again.",
|
||||
"downloadAllZip": "Download all Tag models as zip",
|
||||
"downloadZipTitle": "Download tag \"{{name}}\" as zip",
|
||||
"detail": {
|
||||
"title": "Tag: {{name}}",
|
||||
"empty": "No models with this tag yet."
|
||||
|
||||
@@ -546,6 +546,17 @@
|
||||
"uploading": "Įkeliama į {{target}}...",
|
||||
"prompt": "Vilkite failus ar aplankus, kad įkeltumėte į {{target}}"
|
||||
},
|
||||
"downloadZip": {
|
||||
"loading": "Skaičiuojama…",
|
||||
"summary_one": "{{count}} modelis (~{{size}})",
|
||||
"summary_other": "{{count}} modeliai (~{{size}})",
|
||||
"summaryNoSize_one": "{{count}} modelis",
|
||||
"summaryNoSize_other": "{{count}} modeliai",
|
||||
"summaryFailed": "Nepavyko apskaičiuoti atsisiuntimo dydžio. Bandykite dar kartą.",
|
||||
"empty": "Nerasta modelių failų atsisiuntimui.",
|
||||
"downloading": "Atsisiunčiama…",
|
||||
"failed": "Nepavyko atsisiųsti zip failo. Bandykite dar kartą."
|
||||
},
|
||||
"errors": {
|
||||
"loadPrintsFailed": "Nepavyko įkelti spaudinių. Prisijunkite iš naujo arba atnaujinkite puslapį.",
|
||||
"loadMoreFailed": "Nepavyko įkelti daugiau spaudinių.",
|
||||
@@ -1163,6 +1174,8 @@
|
||||
"bookmarkCollection": "Įsiminti kolekciją",
|
||||
"unbookmarkCollection": "Pašalinti iš įsimintų",
|
||||
"bookmarkFailed": "Nepavyko atnaujinti įsimintos kolekcijos. Bandykite dar kartą.",
|
||||
"downloadAllZip": "Atsisiųsti visus modelius kaip zip",
|
||||
"downloadZipTitle": "Atsisiųsti „{{name}}“ kaip zip",
|
||||
"system": {
|
||||
"favorites": "Mėgstami",
|
||||
"history": "Naršymo istorija"
|
||||
@@ -1202,6 +1215,8 @@
|
||||
"bookmarkTag": "Įsiminti žymą",
|
||||
"unbookmarkTag": "Pašalinti iš įsimintų",
|
||||
"bookmarkFailed": "Nepavyko atnaujinti įsimintos žymos. Bandykite dar kartą.",
|
||||
"downloadAllZip": "Atsisiųsti visus žymos modelius kaip zip",
|
||||
"downloadZipTitle": "Atsisiųsti žymą „{{name}}“ kaip zip",
|
||||
"detail": {
|
||||
"title": "Žyma: {{name}}",
|
||||
"empty": "Su šia žyma modelių kol kas nėra."
|
||||
|
||||
@@ -9,12 +9,14 @@ 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 DownloadIcon from "@mui/icons-material/Download";
|
||||
import BookmarkIcon from "@mui/icons-material/Bookmark";
|
||||
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
|
||||
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";
|
||||
import DownloadZipConfirmDialog from "../../components/DownloadZipConfirmDialog";
|
||||
import CollectionFormModal from "../CollectionsPage/CollectionFormModal";
|
||||
|
||||
type Props = {
|
||||
@@ -48,6 +50,7 @@ export default function CollectionActionsMenu({
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [bookmarking, setBookmarking] = useState(false);
|
||||
const [downloadOpen, setDownloadOpen] = useState(false);
|
||||
|
||||
const closeMenu = () => setAnchorEl(null);
|
||||
|
||||
@@ -127,6 +130,10 @@ export default function CollectionActionsMenu({
|
||||
{collection.bookmarked ? t("models:collections.unbookmarkCollection") : t("models:collections.bookmarkCollection")}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { closeMenu(); setDownloadOpen(true); }}>
|
||||
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>{t("models:collections.downloadAllZip")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { closeMenu(); setEditOpen(true); }}>
|
||||
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>{t("common:edit")}</ListItemText>
|
||||
@@ -140,6 +147,15 @@ export default function CollectionActionsMenu({
|
||||
{editOpen && (
|
||||
<CollectionFormModal collection={collection} onClose={() => setEditOpen(false)} onSubmit={handleEdit} />
|
||||
)}
|
||||
|
||||
<DownloadZipConfirmDialog
|
||||
open={downloadOpen}
|
||||
onClose={() => setDownloadOpen(false)}
|
||||
filter={{ collection_id: collection.id }}
|
||||
filename={`${collection.name || "collection"}.zip`}
|
||||
title={t("models:collections.downloadZipTitle", { name: collection.name })}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import DownloadZipConfirmDialog from "../../components/DownloadZipConfirmDialog";
|
||||
|
||||
type Props = {
|
||||
tag: string;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** The tag detail page's title-row "..." menu -- just "Download all Tag models as zip" for now
|
||||
* (every print carrying this tag, across every category; see DownloadZipConfirmDialog), same
|
||||
* shape as CollectionActionsMenu's own download item. A single-item menu still earns its own
|
||||
* dropdown rather than a bare icon button: it reads as the same affordance as every other
|
||||
* detail page's "..." menu, and leaves room to grow without another layout change later. */
|
||||
export default function TagActionsMenu({ tag, onUnauthorized }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [downloadOpen, setDownloadOpen] = useState(false);
|
||||
|
||||
const closeMenu = () => setAnchorEl(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton size="small" onClick={e => setAnchorEl(e.currentTarget)} aria-label={t("common:more") ?? undefined}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
|
||||
<MenuItem onClick={() => { closeMenu(); setDownloadOpen(true); }}>
|
||||
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>{t("models:tags.downloadAllZip")}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<DownloadZipConfirmDialog
|
||||
open={downloadOpen}
|
||||
onClose={() => setDownloadOpen(false)}
|
||||
filter={{ tag }}
|
||||
filename={`${tag || "tag"}.zip`}
|
||||
title={t("models:tags.downloadZipTitle", { name: tag })}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import ModelCard from "../ModelsPage/ModelCard";
|
||||
import SortTabs from "../ModelsPage/SortTabs";
|
||||
import CollectionCard from "../CollectionsPage/CollectionCard";
|
||||
import TagBookmarkButton from "./TagBookmarkButton";
|
||||
import TagActionsMenu from "./TagActionsMenu";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
@@ -34,8 +35,9 @@ type Props = {
|
||||
* fetch-by-id step (or "not found" state) -- the page header title comes directly from the
|
||||
* route param and the list is prints filtered by that tag, plus (since collections can carry
|
||||
* tags too) any collections carrying it, shown first in the same grid as CollectionCards. The
|
||||
* title row's only action is a bookmark toggle (TagBookmarkButton) -- no dropdown menu, since
|
||||
* there's nothing else to put in one. */
|
||||
* title row has a bookmark toggle (TagBookmarkButton) plus a "..." menu (TagActionsMenu, so far
|
||||
* just "Download all Tag models as zip") -- same two-control layout as CollectionDetailPage's
|
||||
* own title row. */
|
||||
export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBookmarksChanged, viewer }: Props) {
|
||||
const { tagName } = useParams<{ tagName: string }>();
|
||||
const tag = tagName ? decodeURIComponent(tagName) : "";
|
||||
@@ -63,12 +65,15 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBo
|
||||
usePageHeader({
|
||||
title: tag ? t("models:tags.detail.title", { name: tag }) : undefined,
|
||||
actions: tag ? (
|
||||
<TagBookmarkButton
|
||||
tag={tag}
|
||||
bookmarked={bookmarked}
|
||||
onUnauthorized={onUnauthorized}
|
||||
onBookmarksChanged={onBookmarksChanged}
|
||||
/>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||
<TagBookmarkButton
|
||||
tag={tag}
|
||||
bookmarked={bookmarked}
|
||||
onUnauthorized={onUnauthorized}
|
||||
onBookmarksChanged={onBookmarksChanged}
|
||||
/>
|
||||
<TagActionsMenu tag={tag} onUnauthorized={onUnauthorized} />
|
||||
</Stack>
|
||||
) : undefined,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user