feat: improve model details page

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-11 17:45:28 +02:00
1 parent 05fae4c93f
commit d5db3d3b18
10 files changed
+468 -239

No files matched your search

+4 -4
View File
@@ -1,10 +1,10 @@
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { toPrintOut, type PrintOut } from "../dto";
import type { Author, Plate, PreviewImage, Print, PrintFile } from "@prisma/client";
import type { Author, Folder, Plate, PreviewImage, Print, PrintFile } from "@prisma/client";
export type FullPrint = {
print: Print & { author: Author | null };
print: Print & { author: Author | null; folder: Folder | null };
plates: Plate[];
files: PrintFile[];
preparedFile: PrintFile | null;
@@ -12,7 +12,7 @@ export type FullPrint = {
};
export async function loadFullPrint(userId: string, printId: string): Promise<FullPrint> {
const print = await prisma.print.findFirst({ where: { id: printId, userId }, include: { author: true } });
const print = await prisma.print.findFirst({ where: { id: printId, userId }, include: { author: true, folder: true } });
if (!print) throw new HttpError(404, "Print not found");
const [plates, files, previewImages] = await Promise.all([
prisma.plate.findMany({ where: { printId }, orderBy: { position: "asc" } }),
@@ -27,7 +27,7 @@ export async function loadFullPrint(userId: string, printId: string): Promise<Fu
export async function printOutById(userId: string, printId: string): Promise<PrintOut> {
const full = await loadFullPrint(userId, printId);
return toPrintOut(full.print, full.plates, full.files, full.preparedFile, full.print.author, full.previewImages);
return toPrintOut(full.print, full.plates, full.files, full.preparedFile, full.print.author, full.previewImages, full.print.folder);
}
function groupByPrintId<T extends { printId: string }>(rows: T[]): Map<string, T[]> {
+1 -1
View File
@@ -119,7 +119,7 @@ function AppShell({
/>
<Route
path="/models/:printId"
element={<ModelDetailPage theme={resolvedTheme} onUnauthorized={onUnauthorized} />}
element={<ModelDetailPage theme={resolvedTheme} onSelectFolder={setFolderId} onUnauthorized={onUnauthorized} />}
/>
<Route path="/authors/:authorId" element={<AuthorPage />} />
<Route
+3
View File
@@ -60,6 +60,9 @@ export type Print = {
collection?: string | null;
tags: string[];
folder_id?: string | null;
// Only populated by GET /print/:id (the detail page) -- list endpoints don't join it.
folder_name?: string | null;
created_at: string;
storage_path?: string | null;
plates: Plate[]; // ordered by position, length >= 1
preview_images: PreviewImage[]; // ordered by position; [0] is the default/main gallery image
+4 -1
View File
@@ -671,7 +671,10 @@
"nextImage": "Next image",
"addToFavorites": "Add to Favourites",
"removeFromFavorites": "Remove from Favourites",
"favoriteFailed": "Failed to update favourites. Please try again."
"favoriteFailed": "Failed to update favourites. Please try again.",
"category": "Category",
"downloadModelFiles": "Download model files",
"imported": "Imported {{date}}"
},
"author": {
"pageTitle": "Author",
+4 -1
View File
@@ -672,7 +672,10 @@
"nextImage": "Kitas paveikslėlis",
"addToFavorites": "Pridėti prie mėgstamų",
"removeFromFavorites": "Pašalinti iš mėgstamų",
"favoriteFailed": "Nepavyko atnaujinti mėgstamų. Bandykite dar kartą."
"favoriteFailed": "Nepavyko atnaujinti mėgstamų. Bandykite dar kartą.",
"category": "Kategorija",
"downloadModelFiles": "Atsisiųsti modelio failus",
"imported": "Importuota {{date}}"
},
"author": {
"pageTitle": "Autorius",
@@ -0,0 +1,62 @@
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 Divider from "@mui/material/Divider";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText";
import CircularProgress from "@mui/material/CircularProgress";
import DownloadIcon from "@mui/icons-material/Download";
import type { Plate } from "../../api/prints";
type Props = {
open: boolean;
onClose: () => void;
downloading: boolean;
sortedPlates: Plate[];
downloadAllZip: () => void;
downloadPlate: (plate: Plate) => void;
};
/** The "which plate?" picker shown when a multi-plate model's download is triggered -- see
* useDownloadPrint. Shared by ModelActionsMenu's "Download" menu item and the detail page's
* "Download model files" button. */
export default function DownloadPickerDialog({ open, onClose, downloading, sortedPlates, downloadAllZip, downloadPlate }: Props) {
const { t } = useTranslation(["models", "common"]);
return (
<Dialog open={open} onClose={() => !downloading && onClose()} fullWidth maxWidth="xs">
<DialogTitle>{t("models:detail.downloadPickerTitle")}</DialogTitle>
<DialogContent>
<Stack spacing={1.5}>
<Button
variant="contained"
onClick={downloadAllZip}
disabled={downloading}
startIcon={downloading ? <CircularProgress size={14} /> : <DownloadIcon fontSize="small" />}
>
{t("models:detail.downloadAllZip", { count: sortedPlates.length })}
</Button>
<Divider>{t("models:detail.orDownloadOne")}</Divider>
<List disablePadding>
{sortedPlates.map((plate, idx) => (
<ListItemButton key={plate.id} onClick={() => downloadPlate(plate)} disabled={downloading} sx={{ borderRadius: 1 }}>
<ListItemText
primary={t("models:detail.plateLabel", { n: idx + 1 })}
secondary={plate.filename}
secondaryTypographyProps={{ noWrap: true }}
/>
</ListItemButton>
))}
</List>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={downloading}>{t("common:cancel")}</Button>
</DialogActions>
</Dialog>
);
}
@@ -5,15 +5,7 @@ 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 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 Divider from "@mui/material/Divider";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import CircularProgress from "@mui/material/CircularProgress";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DownloadIcon from "@mui/icons-material/Download";
@@ -23,13 +15,14 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import LaunchIcon from "@mui/icons-material/Launch";
import type { SxProps, Theme } from "@mui/material/styles";
import { UnauthorizedError } from "../../api/client";
import { type Plate, type Print, printsApi } from "../../api/prints";
import { saveResponseToDisk } from "../../utils/downloadResponse";
import { type Print, printsApi } from "../../api/prints";
import { slicerLaunchUrl } from "../../utils/slicerLaunch";
import { useConfirm } from "../../components/ConfirmProvider";
import { importProviderInfo } from "../../constants/importProviders";
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
import { useDownloadPrint } from "./useDownloadPrint";
import DownloadPickerDialog from "./DownloadPickerDialog";
type Props = {
print: Print;
@@ -55,59 +48,15 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted, tri
const confirmDialog = useConfirm();
const slicerPreference = useSlicerPreference();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [downloading, setDownloading] = useState(false);
const [deleting, setDeleting] = useState(false);
const { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates } =
useDownloadPrint(print, onUnauthorized);
const closeMenu = () => setAnchorEl(null);
const handleDownloadError = (err: unknown) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
alert(t("models:detail.downloadFailed"));
};
const downloadPlate = async (plate: Plate) => {
setDownloading(true);
try {
const res = await fetch(printsApi.fileUrl(plate.url));
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error("Download failed");
await saveResponseToDisk(res, plate.filename || "download");
setPickerOpen(false);
printsApi.recordDownload(print.id).catch(() => {});
} catch (err) {
handleDownloadError(err);
} finally {
setDownloading(false);
}
};
const downloadAllZip = async () => {
setDownloading(true);
try {
const res = await printsApi.downloadZip({ print_ids: [print.id] });
await saveResponseToDisk(res, `${print.name || "model"}.zip`);
setPickerOpen(false);
printsApi.recordDownload(print.id).catch(() => {});
} catch (err) {
handleDownloadError(err);
} finally {
setDownloading(false);
}
};
const handleDownload = () => {
const onDownloadClick = () => {
closeMenu();
if (print.plates.length <= 1) {
const plate = print.plates[0];
if (plate) void downloadPlate(plate);
return;
}
setPickerOpen(true);
handleDownload();
};
const handleDelete = async () => {
@@ -133,7 +82,6 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted, tri
}
};
const sortedPlates = print.plates.toSorted((a, b) => a.position - b.position);
const providerInfo = importProviderInfo(print.source_provider);
// "other" has no registered URL protocol to launch -- treated the same as no preference set.
const slicerOption = SLICER_OPTIONS.find(opt => opt.id === slicerPreference && opt.id !== "other");
@@ -167,7 +115,7 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted, tri
: t("models:detail.openInSlicerGeneric")}
</ListItemText>
</MenuItem>
<MenuItem onClick={handleDownload} disabled={downloading}>
<MenuItem onClick={onDownloadClick} disabled={downloading}>
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t("common:download")}</ListItemText>
</MenuItem>
@@ -195,41 +143,14 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted, tri
]}
</Menu>
<Dialog open={pickerOpen} onClose={() => !downloading && setPickerOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>{t("models:detail.downloadPickerTitle")}</DialogTitle>
<DialogContent>
<Stack spacing={1.5}>
<Button
variant="contained"
onClick={downloadAllZip}
disabled={downloading}
startIcon={downloading ? <CircularProgress size={14} /> : <DownloadIcon fontSize="small" />}
>
{t("models:detail.downloadAllZip", { count: sortedPlates.length })}
</Button>
<Divider>{t("models:detail.orDownloadOne")}</Divider>
<List disablePadding>
{sortedPlates.map((plate, idx) => (
<ListItemButton
key={plate.id}
onClick={() => downloadPlate(plate)}
disabled={downloading}
sx={{ borderRadius: 1 }}
>
<ListItemText
primary={t("models:detail.plateLabel", { n: idx + 1 })}
secondary={plate.filename}
secondaryTypographyProps={{ noWrap: true }}
/>
</ListItemButton>
))}
</List>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setPickerOpen(false)} disabled={downloading}>{t("common:cancel")}</Button>
</DialogActions>
</Dialog>
<DownloadPickerDialog
open={pickerOpen}
onClose={() => setPickerOpen(false)}
downloading={downloading}
sortedPlates={sortedPlates}
downloadAllZip={downloadAllZip}
downloadPlate={downloadPlate}
/>
</>
);
}
@@ -0,0 +1,155 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import ButtonBase from "@mui/material/ButtonBase";
import FolderIcon from "@mui/icons-material/Folder";
import LaunchIcon from "@mui/icons-material/Launch";
import DownloadIcon from "@mui/icons-material/Download";
import VisibilityIcon from "@mui/icons-material/Visibility";
import PrintIcon from "@mui/icons-material/Print";
import type { Print } from "../../api/prints";
import { printsApi } from "../../api/prints";
import { slicerLaunchUrl } from "../../utils/slicerLaunch";
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
import { useDownloadPrint } from "./useDownloadPrint";
import DownloadPickerDialog from "./DownloadPickerDialog";
type Props = {
print: Print;
onSelectFolder: (id: string) => void;
onUnauthorized?: () => void;
};
/** The model detail page's right-hand summary card: category (jumps back to the Models grid
* filtered to it), "Open in {Slicer}" (only when both a preference is set and this print has a
* slicer_url -- same condition ModelActionsMenu's menu item uses), "Download model files" (the
* same picker-or-direct-download flow as ModelActionsMenu's Download, via useDownloadPrint so
* the two can't drift), view/print counts, and -- only for an actually-imported print, per
* source_provider -- when it was imported. */
export default function ModelSidePanel({ print, onSelectFolder, onUnauthorized }: Props) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
const slicerPreference = useSlicerPreference();
const { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates } =
useDownloadPrint(print, onUnauthorized);
// "other" has no registered URL protocol to launch -- treated the same as no preference set.
const slicerOption = SLICER_OPTIONS.find(opt => opt.id === slicerPreference && opt.id !== "other");
const openInSlicerHref = slicerOption && print.slicer_url
? slicerLaunchUrl(slicerOption.id, printsApi.fileUrl(print.slicer_url), print.slicer_filename ?? undefined)
: undefined;
const goToCategory = () => {
if (!print.folder_id) return;
onSelectFolder(print.folder_id);
navigate("/models");
};
const importedDate = print.source_provider
? new Date(print.created_at).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })
: null;
return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: "12px" }}>
<Stack spacing={2}>
{print.folder_id && print.folder_name && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5 }}>
{t("models:detail.category")}
</Typography>
<ButtonBase
onClick={goToCategory}
sx={{
borderRadius: 1,
px: 0.5,
py: 0.25,
mx: -0.5,
gap: 0.75,
color: "text.primary",
"&:hover": { color: "primary.main" },
}}
>
<FolderIcon fontSize="small" />
<Typography variant="body2" fontWeight={600}>{print.folder_name}</Typography>
</ButtonBase>
</Box>
)}
{openInSlicerHref && (
<Button
component="a"
href={openInSlicerHref}
startIcon={<LaunchIcon fontSize="small" />}
fullWidth
sx={{
bgcolor: "background.paper",
color: "primary.main",
border: "1.5px solid",
borderColor: "primary.main",
"&:hover": { bgcolor: "action.hover", borderColor: "primary.dark" },
}}
>
{t("models:detail.openInSlicer", { slicer: slicerOption!.label })}
</Button>
)}
<Button
onClick={handleDownload}
disabled={downloading}
startIcon={<DownloadIcon fontSize="small" />}
fullWidth
sx={{
bgcolor: "primary.main",
color: "primary.contrastText",
"&:hover": { bgcolor: "primary.dark" },
}}
>
{t("models:detail.downloadModelFiles")}
</Button>
<Stack direction="row" spacing={1.5}>
<Stack
direction="row"
alignItems="center"
justifyContent="center"
spacing={0.75}
sx={{ flex: 1, py: 1, border: "1px solid", borderColor: "divider", borderRadius: 2 }}
>
<VisibilityIcon fontSize="small" sx={{ color: "text.secondary" }} />
<Typography variant="body2" fontWeight={600}>{print.view_count}</Typography>
</Stack>
<Stack
direction="row"
alignItems="center"
justifyContent="center"
spacing={0.75}
sx={{ flex: 1, py: 1, border: "1px solid", borderColor: "divider", borderRadius: 2 }}
>
<PrintIcon fontSize="small" sx={{ color: "text.secondary" }} />
<Typography variant="body2" fontWeight={600}>{print.print_count}</Typography>
</Stack>
</Stack>
{importedDate && (
<Typography variant="caption" sx={{ textAlign: "right", fontSize: 12, color: "text.secondary" }}>
{t("models:detail.imported", { date: importedDate })}
</Typography>
)}
</Stack>
<DownloadPickerDialog
open={pickerOpen}
onClose={() => setPickerOpen(false)}
downloading={downloading}
sortedPlates={sortedPlates}
downloadAllZip={downloadAllZip}
downloadPlate={downloadPlate}
/>
</Paper>
);
}
+152 -137
View File
@@ -25,13 +25,15 @@ import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import Model3DPreviewModal from "./Model3DPreviewModal";
import ModelActionsMenu from "./ModelActionsMenu";
import FavoriteButton from "./FavoriteButton";
import ModelSidePanel from "./ModelSidePanel";
type Props = {
theme: ResolvedTheme;
onSelectFolder: (id: string) => void;
onUnauthorized?: () => void;
};
export default function ModelDetailPage({ theme, onUnauthorized }: Props) {
export default function ModelDetailPage({ theme, onSelectFolder, onUnauthorized }: Props) {
const { printId } = useParams<{ printId: string }>();
const navigate = useNavigate();
const { t } = useTranslation(["models", "common", "library"]);
@@ -115,156 +117,169 @@ export default function ModelDetailPage({ theme, onUnauthorized }: Props) {
const goNextImage = () => setActiveImageIndex(i => (i + 1) % images.length);
return (
<Box sx={{ maxWidth: 1100 }}>
<Stack
direction="row"
alignItems="center"
spacing={1}
<Box sx={{ maxWidth: "1390px", mx: "auto" }}>
<Box
sx={{
mb: 2,
width: "fit-content",
cursor: print.author ? "pointer" : "default",
...(print.author ? { "&:hover": { color: "primary.main" } } : undefined),
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "2fr 1fr" },
gap: "24px",
alignItems: "start",
}}
onClick={() => print.author && navigate(`/authors/${print.author.id}`)}
>
<Avatar src={print.author?.avatar_url || undefined} sx={{ width: 28, height: 28, fontSize: 13, color: "inherit !important" }}>
{(authorName || "?").slice(0, 1).toUpperCase()}
</Avatar>
<Typography variant="body2" sx={{ color: "inherit" }}>
{authorName || t("models:card.unknownAuthor")}
</Typography>
</Stack>
<Box sx={{ position: "relative", width: "100%", aspectRatio: "16 / 10", borderRadius: "12px", overflow: "hidden", bgcolor: "action.hover" }}>
{hasImages ? (
<Box
component="img"
src={printsApi.fileUrl(activeImage.url)}
alt={print.title || print.name}
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
) : (
firstPlate && renderPreviewContent(print, "modal", theme, t, "automatic", firstPlate)
)}
{images.length > 1 && (
<>
<IconButton
onClick={goPrevImage}
aria-label={t("models:detail.previousImage") ?? undefined}
sx={{
position: "absolute",
left: 8,
top: "50%",
transform: "translateY(-50%)",
bgcolor: "rgba(0, 0, 0, 0.45)",
color: "#fff",
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.65)" },
}}
>
<ChevronLeftIcon />
</IconButton>
<IconButton
onClick={goNextImage}
aria-label={t("models:detail.nextImage") ?? undefined}
sx={{
position: "absolute",
right: 8,
top: "50%",
transform: "translateY(-50%)",
bgcolor: "rgba(0, 0, 0, 0.45)",
color: "#fff",
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.65)" },
}}
>
<ChevronRightIcon />
</IconButton>
</>
)}
{canPreview3d && (
<Button
variant="contained"
size="small"
startIcon={<ViewInArIcon fontSize="small" />}
onClick={() => setPreviewOpen(true)}
<Box>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{
position: "absolute",
left: 12,
bottom: 12,
bgcolor: "rgba(0, 0, 0, 0.65)",
color: "#fff",
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.8)" },
mb: 2,
width: "fit-content",
cursor: print.author ? "pointer" : "default",
...(print.author ? { "&:hover": { color: "primary.main" } } : undefined),
}}
onClick={() => print.author && navigate(`/authors/${print.author.id}`)}
>
{t("models:detail.preview3d")}
</Button>
)}
</Box>
<Avatar src={print.author?.avatar_url || undefined} sx={{ width: 28, height: 28, fontSize: 13, color: "inherit !important" }}>
{(authorName || "?").slice(0, 1).toUpperCase()}
</Avatar>
<Typography variant="body2" sx={{ color: "inherit" }}>
{authorName || t("models:card.unknownAuthor")}
</Typography>
</Stack>
{images.length > 1 && (
<Stack direction="row" spacing={1} sx={{ mt: 1.5, overflowX: "auto", pb: 0.5 }}>
{images.map((image, idx) => (
<ButtonBase
key={image.id}
onClick={() => setActiveImageIndex(idx)}
sx={{
width: 84,
height: 64,
borderRadius: 1.5,
overflow: "hidden",
border: "2px solid",
borderColor: idx === activeImageIndex ? "primary.main" : "divider",
flexShrink: 0,
}}
>
<Box sx={{ position: "relative", width: "100%", aspectRatio: "16 / 10", borderRadius: "12px", overflow: "hidden", bgcolor: "action.hover" }}>
{hasImages ? (
<Box
component="img"
src={printsApi.fileUrl(image.url)}
alt=""
sx={{ width: "100%", height: "100%", objectFit: "cover" }}
src={printsApi.fileUrl(activeImage.url)}
alt={print.title || print.name}
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
</ButtonBase>
))}
</Stack>
)}
) : (
firstPlate && renderPreviewContent(print, "modal", theme, t, "automatic", firstPlate)
)}
{needsGeneratedPreview && firstPlate && (
<Box sx={{ position: "absolute", width: 1, height: 1, overflow: "hidden", opacity: 0 }} aria-hidden="true">
<ModelSnapshot
url={printsApi.fileUrl(firstPlate.url)}
ext={extOf(firstPlate.filename)}
plateId={firstPlate.id}
theme={theme}
mode="automatic"
/>
</Box>
)}
{images.length > 1 && (
<>
<IconButton
onClick={goPrevImage}
aria-label={t("models:detail.previousImage") ?? undefined}
sx={{
position: "absolute",
left: 8,
top: "50%",
transform: "translateY(-50%)",
bgcolor: "rgba(0, 0, 0, 0.45)",
color: "#fff",
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.65)" },
}}
>
<ChevronLeftIcon />
</IconButton>
<IconButton
onClick={goNextImage}
aria-label={t("models:detail.nextImage") ?? undefined}
sx={{
position: "absolute",
right: 8,
top: "50%",
transform: "translateY(-50%)",
bgcolor: "rgba(0, 0, 0, 0.45)",
color: "#fff",
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.65)" },
}}
>
<ChevronRightIcon />
</IconButton>
</>
)}
<Paper variant="outlined" sx={{ mt: 3, p: 2, borderRadius: "12px" }}>
<Typography variant="subtitle1" fontWeight={700} sx={{ mb: 1 }}>{t("models:detail.description")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", color: print.notes ? "text.primary" : "text.disabled" }}>
{print.notes || t("models:detail.noDescription")}
</Typography>
<Typography variant="subtitle1" fontWeight={700} sx={{ mt: 2.5, mb: 1 }}>{t("models:detail.tags")}</Typography>
{print.tags.length ? (
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1}>
{print.tags.map(tag => (
<Chip
key={tag}
label={tag}
{canPreview3d && (
<Button
variant="contained"
size="small"
variant="outlined"
sx={{ bgcolor: "background.paper", borderColor: "divider", color: "text.primary" }}
startIcon={<ViewInArIcon fontSize="small" />}
onClick={() => setPreviewOpen(true)}
sx={{
position: "absolute",
left: 12,
bottom: 12,
bgcolor: "rgba(0, 0, 0, 0.65)",
color: "#fff",
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.8)" },
}}
>
{t("models:detail.preview3d")}
</Button>
)}
</Box>
{images.length > 1 && (
<Stack direction="row" spacing={1} sx={{ mt: 1.5, overflowX: "auto", pb: 0.5 }}>
{images.map((image, idx) => (
<ButtonBase
key={image.id}
onClick={() => setActiveImageIndex(idx)}
sx={{
width: 84,
height: 64,
borderRadius: 1.5,
overflow: "hidden",
border: "2px solid",
borderColor: idx === activeImageIndex ? "primary.main" : "divider",
flexShrink: 0,
}}
>
<Box
component="img"
src={printsApi.fileUrl(image.url)}
alt=""
sx={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</ButtonBase>
))}
</Stack>
)}
{needsGeneratedPreview && firstPlate && (
<Box sx={{ position: "absolute", width: 1, height: 1, overflow: "hidden", opacity: 0 }} aria-hidden="true">
<ModelSnapshot
url={printsApi.fileUrl(firstPlate.url)}
ext={extOf(firstPlate.filename)}
plateId={firstPlate.id}
theme={theme}
mode="automatic"
/>
))}
</Stack>
) : (
<Typography variant="body2" color="text.disabled">{t("models:detail.noTags")}</Typography>
)}
</Paper>
</Box>
)}
<Paper variant="outlined" sx={{ mt: 3, p: 2, borderRadius: "12px" }}>
<Typography variant="subtitle1" fontWeight={700} sx={{ mb: 1 }}>{t("models:detail.description")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", color: print.notes ? "text.primary" : "text.disabled" }}>
{print.notes || t("models:detail.noDescription")}
</Typography>
<Typography variant="subtitle1" fontWeight={700} sx={{ mt: 2.5, mb: 1 }}>{t("models:detail.tags")}</Typography>
{print.tags.length ? (
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1}>
{print.tags.map(tag => (
<Chip
key={tag}
label={tag}
size="small"
variant="outlined"
sx={{ bgcolor: "background.paper", borderColor: "divider", color: "text.primary" }}
/>
))}
</Stack>
) : (
<Typography variant="body2" color="text.disabled">{t("models:detail.noTags")}</Typography>
)}
</Paper>
</Box>
<ModelSidePanel print={print} onSelectFolder={onSelectFolder} onUnauthorized={onUnauthorized} />
</Box>
{previewOpen && <Model3DPreviewModal print={print} onClose={() => setPreviewOpen(false)} />}
</Box>
@@ -0,0 +1,67 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { UnauthorizedError } from "../../api/client";
import { type Plate, type Print, printsApi } from "../../api/prints";
import { saveResponseToDisk } from "../../utils/downloadResponse";
/** Shared download logic for a Print: a single-plate model downloads its one file directly; a
* multi-plate one opens a picker (download-all-as-zip, or pick one plate) -- see
* DownloadPickerDialog. Used by both ModelActionsMenu's "Download" menu item and the model
* detail page's own big "Download model files" button, so the two behaviors can't drift apart. */
export function useDownloadPrint(print: Print, onUnauthorized?: () => void) {
const { t } = useTranslation(["models"]);
const [pickerOpen, setPickerOpen] = useState(false);
const [downloading, setDownloading] = useState(false);
const handleDownloadError = (err: unknown) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
alert(t("models:detail.downloadFailed"));
};
const downloadPlate = async (plate: Plate) => {
setDownloading(true);
try {
const res = await fetch(printsApi.fileUrl(plate.url));
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error("Download failed");
await saveResponseToDisk(res, plate.filename || "download");
setPickerOpen(false);
printsApi.recordDownload(print.id).catch(() => {});
} catch (err) {
handleDownloadError(err);
} finally {
setDownloading(false);
}
};
const downloadAllZip = async () => {
setDownloading(true);
try {
const res = await printsApi.downloadZip({ print_ids: [print.id] });
await saveResponseToDisk(res, `${print.name || "model"}.zip`);
setPickerOpen(false);
printsApi.recordDownload(print.id).catch(() => {});
} catch (err) {
handleDownloadError(err);
} finally {
setDownloading(false);
}
};
const handleDownload = () => {
if (print.plates.length <= 1) {
const plate = print.plates[0];
if (plate) void downloadPlate(plate);
return;
}
setPickerOpen(true);
};
const sortedPlates = print.plates.toSorted((a, b) => a.position - b.position);
return { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates };
}