mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: improve model details page
This commit is contained in:
1 parent
05fae4c93f
commit
d5db3d3b18
10 files changed
+468
-239
No files matched your search
@@ -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[]> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user