mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: edit model functionality
This commit is contained in:
1 parent
1d8443e069
commit
37ac318e40
13 files changed
+782
-39
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user