feat: allow downloading collection and tag models in bulk

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-19 18:38:54 +02:00
1 parent 25b84cf559
commit d3c651c05d
9 files changed
+393 -37

No files matched your search

+41 -19
View File
@@ -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 });
}),
);
+77 -9
View File
@@ -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`);
+29 -1
View File
@@ -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>
);
}
+15
View File
@@ -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."
+15
View File
@@ -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}
/>
</>
);
}
+13 -8
View File
@@ -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,
});