feat: edit model functionality

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-12 09:45:28 +02:00
1 parent 1d8443e069
commit 37ac318e40
13 files changed
+782 -39

No files matched your search

+2
View File
@@ -7,6 +7,7 @@ import healthRoutes from "./routes/health";
import authRoutes from "./routes/auth";
import printsRoutes from "./routes/prints";
import platesRoutes from "./routes/plates";
import previewImagesRoutes from "./routes/previewImages";
import categoriesRoutes from "./routes/categories";
import printFilesRoutes from "./routes/printFiles";
import settingsRoutes from "./routes/settings";
@@ -37,6 +38,7 @@ export function createApp(): Express {
app.use("/api", settingsRoutes);
app.use("/api", printsRoutes);
app.use("/api", platesRoutes);
app.use("/api", previewImagesRoutes);
app.use("/api", categoriesRoutes);
app.use("/api", printFilesRoutes);
app.use("/api", importsRoutes);
+87
View File
@@ -0,0 +1,87 @@
import fs from "node:fs/promises";
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../db";
import { requireAuth } from "../auth";
import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { thumbnailUpload } from "../uploadMiddleware";
import { addPreviewImage, previewImagePath } from "../services/previewImageService";
import { printOutById } from "../services/printLoader";
const router = Router();
router.use(requireAuth);
// ---- POST /print/:id/preview-images (append) --------------------------------------------------
router.post(
"/print/:id/preview-images",
thumbnailUpload.array("files"),
asyncHandler(async (req, res) => {
const files = (req.files as Express.Multer.File[]) || [];
if (!files.length) throw new HttpError(400, "No files uploaded");
const print = await prisma.print.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!print) throw new HttpError(404, "Print not found");
// Best-effort, like addGeneratedPreviewImageIfNone -- an undecodable file is silently skipped
// rather than failing the whole batch, since the rest may well be valid images.
for (const file of files) {
await addPreviewImage(print.id, file.buffer);
}
res.json({ print: await printOutById(req.userId!, print.id) });
}),
);
// ---- DELETE /print/:id/preview-images/:imageId -------------------------------------------------
router.delete(
"/print/:id/preview-images/:imageId",
asyncHandler(async (req, res) => {
const print = await prisma.print.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!print) throw new HttpError(404, "Print not found");
const images = await prisma.previewImage.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
const target = images.find((img) => img.id === req.params.imageId);
if (!target) throw new HttpError(404, "Preview image not found");
const remaining = images.filter((img) => img.id !== target.id);
// Same two-phase (negative temp position) renumber as plates.ts's delete route, to dodge the
// (printId, position) unique index regardless of update ordering.
await prisma.previewImage.delete({ where: { id: target.id } });
await prisma.$transaction(
remaining.map((img, idx) => prisma.previewImage.update({ where: { id: img.id }, data: { position: -(idx + 1) } })),
);
await prisma.$transaction(
remaining.map((img, idx) => prisma.previewImage.update({ where: { id: img.id }, data: { position: idx } })),
);
await fs.rm(previewImagePath(target.id), { force: true }).catch(() => undefined);
res.json({ print: await printOutById(req.userId!, print.id) });
}),
);
// ---- POST /print/:id/preview-images/reorder -----------------------------------------------------
const reorderSchema = z.object({ image_ids: z.array(z.string()).min(1) });
router.post(
"/print/:id/preview-images/reorder",
asyncHandler(async (req, res) => {
const body = parseBody(reorderSchema, req.body);
const print = await prisma.print.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!print) throw new HttpError(404, "Print not found");
const images = await prisma.previewImage.findMany({ where: { printId: print.id } });
const byId = new Map(images.map((img) => [img.id, img]));
if (body.image_ids.length !== images.length || body.image_ids.some((id) => !byId.has(id))) {
throw new HttpError(400, "image_ids must contain exactly the print's current preview image ids");
}
await prisma.$transaction(
body.image_ids.map((id, idx) => prisma.previewImage.update({ where: { id }, data: { position: -(idx + 1) } })),
);
await prisma.$transaction(
body.image_ids.map((id, idx) => prisma.previewImage.update({ where: { id }, data: { position: idx } })),
);
res.json({ print: await printOutById(req.userId!, print.id) });
}),
);
export default router;
+18
View File
@@ -463,6 +463,24 @@ router.post(
}),
);
// Clears the imported author/creator and import-source linkage, so the print reads exactly like
// one this user uploaded themselves (a plain upload never sets authorId/creator/sourceProvider/
// sourceExternalId -- see createPrint in printCreation.ts). Used by the Edit modal's "reset
// author" action.
router.post(
"/print/:id/author-reset",
asyncHandler(async (req, res) => {
const print = await prisma.print.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!print) throw new HttpError(404, "Print not found");
await prisma.print.update({
where: { id: print.id },
data: { authorId: null, creator: null, sourceProvider: null, sourceExternalId: null },
});
res.json({ print: await printOutById(req.userId!, print.id) });
void createLog({ userId: req.userId!, action: "model_edited", targetId: print.id, details: { field: "author_reset", name: print.name } });
}),
);
router.delete(
"/print/:id",
asyncHandler(async (req, res) => {
+43
View File
@@ -351,6 +351,49 @@ export const printsApi = {
return res.json();
},
/** Clears the imported author/creator and import-source linkage -- afterward the print reads
* exactly like one this user uploaded themselves. Used by the Edit modal's "reset author". */
resetAuthor: async (id: string): Promise<{ print: Print }> => {
const res = await fetch(`${apiBase()}/print/${id}/author-reset`, { method: "POST", headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to reset author"));
return res.json();
},
addPreviewImages: async (id: string, files: File[]): Promise<{ print: Print }> => {
const fd = new FormData();
for (const file of files) fd.append("files", file);
const res = await fetch(`${apiBase()}/print/${id}/preview-images`, {
method: "POST",
body: fd,
headers: authHeaders(),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to add preview image"));
return res.json();
},
deletePreviewImage: async (printId: string, imageId: string): Promise<{ print: Print }> => {
const res = await fetch(`${apiBase()}/print/${printId}/preview-images/${imageId}`, {
method: "DELETE",
headers: authHeaders(),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to remove preview image"));
return res.json();
},
reorderPreviewImages: async (id: string, imageIds: string[]): Promise<{ print: Print }> => {
const res = await fetch(`${apiBase()}/print/${id}/preview-images/reorder`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ image_ids: imageIds }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to reorder preview images"));
return res.json();
},
downloadZip: async (opts: { print_ids?: string[]; tag?: string; category_id?: string; filename?: string }) => {
const res = await fetch(`${apiBase()}/download/zip`, {
method: "POST",
+18
View File
@@ -1044,6 +1044,24 @@
"downloadModelFiles": "Download model files",
"imported": "Imported {{date}}"
},
"edit": {
"title": "Edit model",
"titleLabel": "Title",
"noCategory": "No category",
"resetAuthor": "Reset author",
"undoResetAuthor": "Undo",
"authorWillBeYou": "You (after Update)",
"previewImages": "Preview images",
"thumbnailBadge": "Thumbnail",
"newBadge": "New",
"modelFiles": "Model files",
"addFiles": "Add files",
"update": "Update",
"confirmDiscard": "Discard all unsaved changes to this model?",
"discardConfirmLabel": "Discard",
"updateSuccess": "Model updated",
"updateFailed": "Failed to update the model. Please try again."
},
"author": {
"pageTitle": "Author",
"comingSoon": "Author pages are coming soon."
+18
View File
@@ -1045,6 +1045,24 @@
"downloadModelFiles": "Atsisiųsti modelio failus",
"imported": "Importuota {{date}}"
},
"edit": {
"title": "Redaguoti modelį",
"titleLabel": "Pavadinimas",
"noCategory": "Be kategorijos",
"resetAuthor": "Atstatyti autorių",
"undoResetAuthor": "Anuliuoti",
"authorWillBeYou": "Jūs (po atnaujinimo)",
"previewImages": "Peržiūros nuotraukos",
"thumbnailBadge": "Miniatiūra",
"newBadge": "Nauja",
"modelFiles": "Modelio failai",
"addFiles": "Pridėti failų",
"update": "Atnaujinti",
"confirmDiscard": "Atmesti visus neišsaugotus šio modelio pakeitimus?",
"discardConfirmLabel": "Atmesti",
"updateSuccess": "Modelis atnaujintas",
"updateFailed": "Nepavyko atnaujinti modelio. Bandykite dar kartą."
},
"author": {
"pageTitle": "Autorius",
"comingSoon": "Autorių puslapiai netrukus bus pasiekiami."
@@ -176,6 +176,7 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize
}
collectionId={collection && !collection.system_key ? collection.id : undefined}
onRemovedFromCollection={removedId => setItems(prev => prev.filter(i => i.id !== removedId))}
onUpdated={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
/>
))}
@@ -0,0 +1,511 @@
import { useEffect, useMemo, useRef, 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 TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import Alert from "@mui/material/Alert";
import CircularProgress from "@mui/material/CircularProgress";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import IconButton from "@mui/material/IconButton";
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
import Chip from "@mui/material/Chip";
import CloseIcon from "@mui/icons-material/Close";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import AddPhotoAlternateIcon from "@mui/icons-material/AddPhotoAlternate";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import DeleteIcon from "@mui/icons-material/Delete";
import { type Print, printsApi } from "../../api/prints";
import { categoriesApi, type Category } from "../../api/categories";
import { UnauthorizedError } from "../../api/client";
import { useConfirm } from "../../components/ConfirmProvider";
import { useToast } from "../../components/ToastProvider";
import TagInput from "../../components/TagInput";
import { translateCategoryDisplay } from "../../utils/translateCategoryDisplay";
type ImageItem =
| { kind: "existing"; id: string; url: string }
| { kind: "new"; localId: string; file: File; previewUrl: string };
type PlateItem =
| { kind: "existing"; id: string; filename: string }
| { kind: "new"; localId: string; file: File };
function imageKey(img: ImageItem): string {
return img.kind === "existing" ? img.id : img.localId;
}
function plateKey(p: PlateItem): string {
return p.kind === "existing" ? p.id : p.localId;
}
function moveItem<T>(list: T[], index: number, dir: -1 | 1): T[] {
const swapIndex = index + dir;
if (index < 0 || swapIndex < 0 || swapIndex >= list.length) return list;
const next = [...list];
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
return next;
}
type Props = {
print: Print;
onClose: () => void;
onUnauthorized?: () => void;
onUpdated: (print: Print) => void;
};
/** The "Edit" modal for a model: title, category (a two-level tree, but only leaf/secondary
* categories are selectable), author (a "reset to me" action that also clears the import source),
* preview images (delete/upload/reorder, position 0 is the main/default image), description,
* tags, and the model's files/plates (delete/upload/reorder). Everything here is staged locally
* and only committed -- as a sequence of the existing field-scoped mutation calls -- when
* "Update" is clicked; "Cancel" discards it all, confirming first if anything was actually
* touched. Opened from ModelActionsMenu's "Edit" item, shared by the model detail page's header
* and the Models/Collection grids' per-card menu. */
export default function EditModelModal({ print, onClose, onUnauthorized, onUpdated }: Props) {
const { t, i18n } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
const showToast = useToast();
const [title, setTitle] = useState(print.title || print.name);
const [categoryId, setCategoryId] = useState<string | null>(print.category_id ?? null);
const [notes, setNotes] = useState(print.notes ?? "");
const [tags, setTags] = useState<string[]>(print.tags);
const hasImportedAuthor = Boolean(print.author || print.creator || print.source_provider);
const [authorResetPending, setAuthorResetPending] = useState(false);
const [images, setImages] = useState<ImageItem[]>(
() => print.preview_images.map((img): ImageItem => ({ kind: "existing", id: img.id, url: img.url })),
);
const [plateItems, setPlateItems] = useState<PlateItem[]>(
() => print.plates.map((p): PlateItem => ({ kind: "existing", id: p.id, filename: p.filename })),
);
const [categories, setCategories] = useState<Category[] | null>(null);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const createdUrlsRef = useRef<string[]>([]);
const addImageInputRef = useRef<HTMLInputElement>(null);
const addFileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
categoriesApi.list().then(setCategories).catch(() => setCategories([]));
}, []);
const markDirty = () => setDirty(true);
const { roots, childrenByParent } = useMemo(() => {
const list = categories ?? [];
const childrenMap: Record<string, Category[]> = {};
const rootList: Category[] = [];
for (const c of list) {
if (c.parent_id) {
if (!childrenMap[c.parent_id]) childrenMap[c.parent_id] = [];
childrenMap[c.parent_id].push(c);
} else {
rootList.push(c);
}
}
const byPosition = (a: Category, b: Category) => a.position - b.position || a.name.localeCompare(b.name);
Object.keys(childrenMap).forEach((key) => { childrenMap[key] = childrenMap[key].toSorted(byPosition); });
return { roots: rootList.toSorted(byPosition), childrenByParent: childrenMap };
}, [categories]);
const categoryName = (c: Category) => translateCategoryDisplay(c, i18n).name;
const onAddImages = (fileList: FileList | null) => {
if (!fileList?.length) return;
const newItems: ImageItem[] = Array.from(fileList).map((file) => {
const previewUrl = URL.createObjectURL(file);
createdUrlsRef.current.push(previewUrl);
return { kind: "new", localId: crypto.randomUUID(), file, previewUrl };
});
setImages((prev) => [...prev, ...newItems]);
markDirty();
};
const removeImage = (key: string) => {
setImages((prev) => prev.filter((img) => imageKey(img) !== key));
markDirty();
};
const moveImageBy = (key: string, dir: -1 | 1) => {
setImages((prev) => moveItem(prev, prev.findIndex((img) => imageKey(img) === key), dir));
markDirty();
};
const onAddFiles = (fileList: FileList | null) => {
if (!fileList?.length) return;
const newItems: PlateItem[] = Array.from(fileList).map((file) => ({ kind: "new", localId: crypto.randomUUID(), file }));
setPlateItems((prev) => [...prev, ...newItems]);
markDirty();
};
const removePlateItem = (key: string) => {
setPlateItems((prev) => (prev.length <= 1 ? prev : prev.filter((p) => plateKey(p) !== key)));
markDirty();
};
const movePlateItemBy = (key: string, dir: -1 | 1) => {
setPlateItems((prev) => moveItem(prev, prev.findIndex((p) => plateKey(p) === key), dir));
markDirty();
};
const requestClose = async () => {
if (saving) return;
if (dirty) {
const confirmed = await confirmDialog({
message: t("models:edit.confirmDiscard"),
confirmLabel: t("models:edit.discardConfirmLabel"),
destructive: true,
});
if (!confirmed) return;
}
onClose();
};
const handleUpdate = async () => {
setSaving(true);
setError(null);
let latest: Print = print;
try {
const metaRes = await printsApi.updateMeta(print.id, { title, notes });
latest = metaRes.print ?? latest;
const catRes = await printsApi.updateCategory(print.id, categoryId);
latest = catRes.print ?? latest;
const tagsRes = await printsApi.setTags(print.id, tags);
latest = tagsRes.print ?? latest;
if (authorResetPending) {
const authorRes = await printsApi.resetAuthor(print.id);
latest = authorRes.print ?? latest;
}
// Preview images: delete what's gone, upload what's new, then reorder to the arrangement
// the user actually left on screen.
const keptImageIds = new Set(images.filter((img) => img.kind === "existing").map((img) => img.id));
for (const original of print.preview_images) {
if (keptImageIds.has(original.id)) continue;
const res = await printsApi.deletePreviewImage(print.id, original.id);
latest = res.print ?? latest;
}
const newImageFiles = images.filter((img) => img.kind === "new").map((img) => img.file);
let newImageIdsInOrder: string[] = [];
if (newImageFiles.length) {
const beforeIds = new Set(latest.preview_images.map((img) => img.id));
const uploadRes = await printsApi.addPreviewImages(print.id, newImageFiles);
latest = uploadRes.print;
newImageIdsInOrder = latest.preview_images
.filter((img) => !beforeIds.has(img.id))
.toSorted((a, b) => a.position - b.position)
.map((img) => img.id);
}
if (images.length) {
let nextNew = 0;
const finalOrder = images.map((img) => (img.kind === "existing" ? img.id : newImageIdsInOrder[nextNew++]));
const reorderRes = await printsApi.reorderPreviewImages(print.id, finalOrder);
latest = reorderRes.print ?? latest;
}
// Model files (plates): same delete -> upload -> reorder sequence. Always at least one
// plate stays staged locally (removePlateItem refuses to drop the last one), matching the
// backend's own "can't remove the only plate" rule.
const keptPlateIds = new Set(plateItems.filter((p) => p.kind === "existing").map((p) => p.id));
for (const original of print.plates) {
if (keptPlateIds.has(original.id)) continue;
const res = await printsApi.deletePlate(print.id, original.id);
latest = res.print ?? latest;
}
const newPlateFiles = plateItems.filter((p) => p.kind === "new").map((p) => p.file);
let newPlateIdsInOrder: string[] = [];
if (newPlateFiles.length) {
const beforeIds = new Set(latest.plates.map((p) => p.id));
const uploadRes = await printsApi.addPlates(print.id, newPlateFiles);
latest = uploadRes.print;
newPlateIdsInOrder = latest.plates
.filter((p) => !beforeIds.has(p.id))
.toSorted((a, b) => a.position - b.position)
.map((p) => p.id);
}
{
let nextNew = 0;
const finalOrder = plateItems.map((p) => (p.kind === "existing" ? p.id : newPlateIdsInOrder[nextNew++]));
const reorderRes = await printsApi.reorderPlates(print.id, finalOrder);
latest = reorderRes.print ?? latest;
}
onUpdated(latest);
showToast({ message: t("models:edit.updateSuccess") });
onClose();
} catch (err) {
onUpdated(latest);
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
setError(err instanceof Error ? err.message : t("models:edit.updateFailed"));
} finally {
setSaving(false);
}
};
return (
<Dialog open onClose={requestClose} fullWidth maxWidth="md">
<DialogTitle>{t("models:edit.title")}</DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 0.5 }}>
{error && <Alert severity="error">{error}</Alert>}
<TextField
label={t("models:edit.titleLabel")}
value={title}
onChange={(e) => { setTitle(e.target.value); markDirty(); }}
disabled={saving}
fullWidth
// Deliberate: focus the title field the moment the dialog opens.
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<FormControl fullWidth disabled={saving || !categories}>
<InputLabel id="edit-model-category-label">{t("models:detail.category")}</InputLabel>
<Select
labelId="edit-model-category-label"
label={t("models:detail.category")}
value={categoryId ?? ""}
onChange={(e) => { setCategoryId(e.target.value || null); markDirty(); }}
>
<MenuItem value="">{t("models:edit.noCategory")}</MenuItem>
{roots.flatMap((root) => [
// A real <ListSubheader> here would still get Select's selection/click handling
// cloned onto it (a known MUI quirk), leaving the menu stuck open on a "click" that
// selected nothing -- a disabled MenuItem sidesteps that entirely, since Select
// already knows to skip disabled items.
<MenuItem key={`h-${root.id}`} disabled divider sx={{ fontWeight: 700, opacity: "1 !important" }}>
{categoryName(root)}
</MenuItem>,
].concat(
(childrenByParent[root.id] ?? []).map((child) => (
<MenuItem key={child.id} value={child.id} sx={{ pl: 3 }}>{categoryName(child)}</MenuItem>
)),
))}
</Select>
</FormControl>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5 }}>
{t("models:detail.author")}
</Typography>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="body2">
{authorResetPending
? t("models:edit.authorWillBeYou")
: (print.author?.name || print.author?.handle || print.creator || t("models:card.unknownAuthor"))}
</Typography>
{hasImportedAuthor && (
authorResetPending ? (
<Button size="small" disabled={saving} onClick={() => setAuthorResetPending(false)}>
{t("models:edit.undoResetAuthor")}
</Button>
) : (
<Button
size="small"
disabled={saving}
startIcon={<RestartAltIcon fontSize="small" />}
onClick={() => { setAuthorResetPending(true); markDirty(); }}
>
{t("models:edit.resetAuthor")}
</Button>
)
)}
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>{t("models:edit.previewImages")}</Typography>
<Stack direction="row" spacing={1} sx={{ overflowX: "auto", pb: 0.5 }}>
{images.map((img, idx) => {
const key = imageKey(img);
const src = img.kind === "existing" ? printsApi.fileUrl(img.url) : img.previewUrl;
return (
<Box
key={key}
sx={{
position: "relative",
flexShrink: 0,
width: 96,
height: 96,
borderRadius: 1,
overflow: "hidden",
border: "1px solid",
borderColor: "divider",
}}
>
<Box component="img" src={src} sx={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
{idx === 0 && (
<Chip
label={t("models:edit.thumbnailBadge")}
size="small"
sx={{ position: "absolute", bottom: 3, left: 3, height: 18, fontSize: 10 }}
/>
)}
<IconButton
size="small"
disabled={saving}
onClick={() => removeImage(key)}
sx={{ position: "absolute", top: 2, right: 2, bgcolor: "rgba(0,0,0,0.55)", color: "#fff", "&:hover": { bgcolor: "rgba(0,0,0,0.75)" } }}
>
<CloseIcon sx={{ fontSize: 14 }} />
</IconButton>
<Stack direction="row" sx={{ position: "absolute", bottom: 2, right: 2 }}>
<IconButton
size="small"
disabled={saving || idx === 0}
onClick={() => moveImageBy(key, -1)}
sx={{ color: "#fff", bgcolor: "rgba(0,0,0,0.55)", "&:hover": { bgcolor: "rgba(0,0,0,0.75)" } }}
>
<ChevronLeftIcon sx={{ fontSize: 14 }} />
</IconButton>
<IconButton
size="small"
disabled={saving || idx === images.length - 1}
onClick={() => moveImageBy(key, 1)}
sx={{ color: "#fff", bgcolor: "rgba(0,0,0,0.55)", "&:hover": { bgcolor: "rgba(0,0,0,0.75)" } }}
>
<ChevronRightIcon sx={{ fontSize: 14 }} />
</IconButton>
</Stack>
</Box>
);
})}
<Box
component="button"
type="button"
disabled={saving}
onClick={() => addImageInputRef.current?.click()}
sx={{
flexShrink: 0,
width: 96,
height: 96,
borderRadius: 1,
border: "1px dashed",
borderColor: "divider",
bgcolor: "transparent",
cursor: "pointer",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 0.5,
color: "text.secondary",
}}
>
<AddPhotoAlternateIcon fontSize="small" />
<Typography variant="caption">{t("common:add")}</Typography>
</Box>
<input
ref={addImageInputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={(e) => { onAddImages(e.target.files); e.target.value = ""; }}
/>
</Stack>
</Box>
<TextField
label={t("models:detail.description")}
value={notes}
onChange={(e) => { setNotes(e.target.value); markDirty(); }}
disabled={saving}
fullWidth
multiline
minRows={3}
/>
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
{t("models:detail.tags")}
</Typography>
<TagInput value={tags} onChange={(v) => { setTags(v); markDirty(); }} />
</Box>
<Divider />
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>{t("models:edit.modelFiles")}</Typography>
<Stack spacing={1}>
{plateItems.map((p, idx) => {
const key = plateKey(p);
const filename = p.kind === "existing" ? p.filename : p.file.name;
return (
<Stack
key={key}
direction="row"
alignItems="center"
spacing={1}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1, px: 1, py: 0.5 }}
>
<InsertDriveFileIcon fontSize="small" color="action" />
<Typography variant="body2" noWrap sx={{ flex: 1 }}>{filename}</Typography>
{p.kind === "new" && <Chip label={t("models:edit.newBadge")} size="small" />}
<IconButton size="small" disabled={saving || idx === 0} onClick={() => movePlateItemBy(key, -1)}>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton size="small" disabled={saving || idx === plateItems.length - 1} onClick={() => movePlateItemBy(key, 1)}>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
<IconButton size="small" disabled={saving || plateItems.length <= 1} onClick={() => removePlateItem(key)}>
<DeleteIcon fontSize="small" color="error" />
</IconButton>
</Stack>
);
})}
<Button
size="small"
disabled={saving}
startIcon={<UploadFileIcon fontSize="small" />}
onClick={() => addFileInputRef.current?.click()}
sx={{ alignSelf: "flex-start" }}
>
{t("models:edit.addFiles")}
</Button>
<input
ref={addFileInputRef}
type="file"
multiple
hidden
onChange={(e) => { onAddFiles(e.target.files); e.target.value = ""; }}
/>
</Stack>
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={requestClose} disabled={saving}>{t("common:cancel")}</Button>
<Button
variant="contained"
onClick={handleUpdate}
disabled={saving}
startIcon={saving ? <CircularProgress size={14} color="inherit" /> : undefined}
>
{t("models:edit.update")}
</Button>
</DialogActions>
</Dialog>
);
}
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
@@ -27,11 +28,15 @@ import { useSlicerPreference } from "../../hooks/useSlicerPreference";
import { useDownloadPrint } from "./useDownloadPrint";
import DownloadPickerDialog from "./DownloadPickerDialog";
import AddToCollectionModal from "./AddToCollectionModal";
import EditModelModal from "./EditModelModal";
type Props = {
print: Print;
onUnauthorized?: () => void;
onDeleted: () => void;
/** Called with the fresh print after a successful Edit-modal update, so the grid card / detail
* page it's rendered in can refresh without a full refetch -- same shape as onFavoriteChange. */
onUpdated?: (print: Print) => void;
/** Set only while browsing an actual (non-system) collection -- shows "Remove from collection"
* above Delete. Favourites/Browsing History have no real membership to drop (see
* collectionsApi.removeItem's doc comment), so callers there simply don't pass this. */
@@ -46,17 +51,20 @@ type Props = {
iconFontSize?: number;
};
/** The "..." menu for a model: "Open in {Slicer}" (launches the user's preferred slicer via its
* own URL protocol -- disabled when no slicer is set, or it's set to "Other"), Download (single
* file, or a plate picker / zip-all for multi-plate models), Edit (disabled for now), "Add to
* collection" (opens the chip-toggle picker), "Remove from collection" (only while browsing one),
* Delete (confirm, then delete), and -- only for an imported print -- a divider then "Open in
* {Provider}" linking back to the original model page. Shared by the model detail page's header
* and the Models/Collection grids' per-card hover overlay. */
/** The "..." menu for a model: Download (single file, or a plate picker / zip-all for multi-plate
* models), Edit (opens EditModelModal, driven by a `?edit=<id>` URL param -- see openEdit/
* closeEdit below), "Add to collection" (opens the chip-toggle picker), "Remove from collection"
* (only while browsing one), Delete (confirm, then delete), then a divider followed by the two
* "leaves the app" actions grouped together: "Open in {Slicer}" (launches the user's preferred
* slicer via its own URL protocol -- disabled when no slicer is set, or it's set to "Other") and
* -- only for an imported print -- "Open in {Provider}" linking back to the original model page.
* Shared by the model detail page's header and the Models/Collection grids' per-card hover
* overlay. */
export default function ModelActionsMenu({
print,
onUnauthorized,
onDeleted,
onUpdated,
collectionId,
onRemovedFromCollection,
triggerSx,
@@ -65,15 +73,31 @@ export default function ModelActionsMenu({
const { t } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
const slicerPreference = useSlicerPreference();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [deleting, setDeleting] = useState(false);
const [removingFromCollection, setRemovingFromCollection] = useState(false);
const [addToCollectionOpen, setAddToCollectionOpen] = useState(false);
// Driven by the URL (?edit=<id>) rather than local state, per spec -- lets a direct link (or the
// back button) open/close it too, and lets the grid-card trigger below just navigate there.
const editOpen = searchParams.get("edit") === print.id;
const { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates } =
useDownloadPrint(print, onUnauthorized);
const closeMenu = () => setAnchorEl(null);
const openEdit = () => {
closeMenu();
navigate(`/models/${print.id}?edit=${print.id}`);
};
const closeEdit = () => {
const next = new URLSearchParams(searchParams);
next.delete("edit");
setSearchParams(next, { replace: true });
};
const onDownloadClick = () => {
closeMenu();
handleDownload();
@@ -146,19 +170,11 @@ export default function ModelActionsMenu({
)}
</IconButton>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
<MenuItem component="a" href={openInSlicerHref} onClick={closeMenu} disabled={!openInSlicerHref}>
<ListItemIcon><LaunchIcon fontSize="small" /></ListItemIcon>
<ListItemText>
{slicerOption
? t("models:detail.openInSlicer", { slicer: slicerOption.label })
: t("models:detail.openInSlicerGeneric")}
</ListItemText>
</MenuItem>
<MenuItem onClick={onDownloadClick} disabled={downloading}>
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t("common:download")}</ListItemText>
</MenuItem>
<MenuItem disabled>
<MenuItem onClick={openEdit}>
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t("common:edit")}</ListItemText>
</MenuItem>
@@ -176,10 +192,17 @@ export default function ModelActionsMenu({
<ListItemIcon><DeleteIcon fontSize="small" color="error" /></ListItemIcon>
<ListItemText sx={{ color: "error.main" }}>{t("common:delete")}</ListItemText>
</MenuItem>
{providerInfo && print.source_url && [
<Divider key="open-in-provider-divider" />,
<Divider />
<MenuItem component="a" href={openInSlicerHref} onClick={closeMenu} disabled={!openInSlicerHref}>
<ListItemIcon><LaunchIcon fontSize="small" /></ListItemIcon>
<ListItemText>
{slicerOption
? t("models:detail.openInSlicer", { slicer: slicerOption.label })
: t("models:detail.openInSlicerGeneric")}
</ListItemText>
</MenuItem>
{providerInfo && print.source_url && (
<MenuItem
key="open-in-provider"
component="a"
href={print.source_url}
target="_blank"
@@ -188,8 +211,8 @@ export default function ModelActionsMenu({
>
<ListItemIcon><OpenInNewIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t("models:detail.openInProvider", { provider: providerInfo.label })}</ListItemText>
</MenuItem>,
]}
</MenuItem>
)}
</Menu>
<DownloadPickerDialog
@@ -209,6 +232,15 @@ export default function ModelActionsMenu({
collectionId={collectionId}
onRemovedFromCollection={onRemovedFromCollection}
/>
{editOpen && (
<EditModelModal
print={print}
onClose={closeEdit}
onUnauthorized={onUnauthorized}
onUpdated={(updated) => onUpdated?.(updated)}
/>
)}
</>
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
actions: print ? (
<Stack direction="row" alignItems="center" spacing={0.5}>
<FavoriteButton print={print} onUpdated={setPrint} onUnauthorized={onUnauthorized} />
<ModelActionsMenu print={print} onUnauthorized={onUnauthorized} onDeleted={goBack} />
<ModelActionsMenu print={print} onUnauthorized={onUnauthorized} onDeleted={goBack} onUpdated={setPrint} />
</Stack>
) : undefined,
});
+28 -17
View File
@@ -7,6 +7,7 @@ import Stack from "@mui/material/Stack";
import Avatar from "@mui/material/Avatar";
import Typography from "@mui/material/Typography";
import Tooltip from "@mui/material/Tooltip";
import { useTheme } from "@mui/material/styles";
import VisibilityIcon from "@mui/icons-material/Visibility";
import PrintIcon from "@mui/icons-material/Print";
import { type Print, printsApi } from "../../api/prints";
@@ -25,6 +26,7 @@ type Props = {
previewMode: PreviewMode;
onDeleted?: (id: string) => void;
onFavoriteChange?: (print: Print) => void;
onUpdated?: (print: Print) => void;
onUnauthorized?: () => void;
/** Set only by CollectionDetailPage, and only for a real (non-system) collection -- shows
* "Remove from collection" in the card's "..." menu. See ModelActionsMenu's doc comment. */
@@ -32,10 +34,18 @@ type Props = {
onRemovedFromCollection?: (id: string) => void;
};
// No solid backdrop behind the hover-overlay icons (a card's thumbnail can be any color) -- a
// drop-shadow keeps them legible against light and dark images alike without boxing them in.
const hoverIconShadow = { filter: "drop-shadow(0 1px 3px rgba(0, 0, 0, 0.85))" };
const HOVER_ICON_SIZE = 26;
// The star/"..." hover icons sit on a plain neutral circle -- readable over any thumbnail color
// without a drop-shadow crutch -- and both share this exact size so they read as one matched pair
// regardless of which icon (star vs. dots) is inside.
const OVERLAY_BUTTON_SIZE = 30;
const HOVER_ICON_SIZE = 18;
const overlayButtonSx = {
width: OVERLAY_BUTTON_SIZE,
height: OVERLAY_BUTTON_SIZE,
padding: 0,
borderRadius: "50%",
bgcolor: "background.paper",
} as const;
export default function ModelCard({
item,
@@ -43,6 +53,7 @@ export default function ModelCard({
previewMode,
onDeleted,
onFavoriteChange,
onUpdated,
onUnauthorized,
collectionId,
onRemovedFromCollection,
@@ -50,6 +61,11 @@ export default function ModelCard({
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
const showToast = useToast();
const muiTheme = useTheme();
// headingText is dark text in light mode, white in dark mode -- exactly the contrast an icon
// needs against overlayButtonSx's own bgcolor (background.paper: white in light mode, the panel
// color in dark mode).
const overlayIconColor = muiTheme.thingport.headingText;
// Flips immediately on click (optimistic, rolled back on failure) instead of waiting on the
// request behind a spinner -- StarToggle's burst animation only plays on an actual
// false->true prop transition while mounted, so swapping it for a spinner mid-request (then
@@ -100,7 +116,7 @@ export default function ModelCard({
overflow: "hidden",
borderRadius: "12px",
borderColor: "transparent",
bgcolor: (muiTheme) => (muiTheme.palette.mode === "dark" ? muiTheme.thingport.pageBackground : muiTheme.palette.grey[100]),
bgcolor: muiTheme.palette.mode === "dark" ? muiTheme.thingport.pageBackground : muiTheme.palette.grey[100],
transition: "background-color .15s ease, box-shadow .15s ease, transform .15s ease",
"&:hover": {
bgcolor: "background.paper",
@@ -111,13 +127,7 @@ export default function ModelCard({
"&:hover .model-card-actions": { opacity: 1 },
}}
>
<Box
sx={{
width: "100%",
aspectRatio: "4 / 3",
bgcolor: (muiTheme) => (muiTheme.palette.mode === "dark" ? muiTheme.thingport.pageBackground : muiTheme.palette.grey[200]),
}}
>
<Box sx={{ width: "100%", aspectRatio: "4 / 3" }}>
{renderPreviewContent(item, "card", theme, t, previewMode)}
</Box>
@@ -160,9 +170,9 @@ export default function ModelCard({
active={isFavorite}
onClick={toggleFavorite}
ariaLabel={favoriteLabel}
inactiveColor="#fff"
inactiveColor={overlayIconColor}
size={HOVER_ICON_SIZE}
sx={hoverIconShadow}
sx={overlayButtonSx}
/>
</Box>
</Tooltip>
@@ -170,19 +180,20 @@ export default function ModelCard({
print={item}
onUnauthorized={onUnauthorized}
onDeleted={() => onDeleted?.(item.id)}
onUpdated={onUpdated}
collectionId={collectionId}
onRemovedFromCollection={() => onRemovedFromCollection?.(item.id)}
triggerSx={{ color: "#fff", ...hoverIconShadow }}
triggerSx={{ color: overlayIconColor, ...overlayButtonSx }}
iconFontSize={HOVER_ICON_SIZE}
/>
</Stack>
<Box sx={{ p: 1.5 }}>
<Box sx={{ px: 1.5, pt: 0.75, pb: 1.5 }}>
<Typography
variant="body2"
fontWeight={600}
noWrap
title={item.title || item.name}
sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}
sx={{ color: muiTheme.thingport.headingText }}
>
{item.title || item.name}
</Typography>
+1
View File
@@ -261,6 +261,7 @@ export default function ModelsPage({ categoryId, onSelectCategory, categoriesVer
previewMode={previewMode}
onDeleted={deletedId => setItems(prev => prev.filter(i => i.id !== deletedId))}
onFavoriteChange={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUpdated={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
/>
))}
@@ -171,6 +171,7 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized }: Pr
previewMode={previewMode}
onDeleted={deletedId => setItems(prev => prev.filter(i => i.id !== deletedId))}
onFavoriteChange={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUpdated={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
/>
))}