feat: add to collections modals

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-12 08:45:48 +02:00
1 parent e9ac4f9776
commit 1d8443e069
7 files changed
+236 -5

No files matched your search

+42
View File
@@ -6,6 +6,7 @@ import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import {
addPrintsToCollection,
assertCollectionNameAvailable,
isSystemCollectionId,
listSystemCollectionOuts,
@@ -172,4 +173,45 @@ router.delete(
}),
);
// The reverse of the DELETE above -- adds one print to a real collection. Same system-collection
// restriction: Favourites/Browsing History membership comes from Print.favoritedAt/lastViewedAt,
// not CollectionItem rows, so the frontend never offers this for them.
router.post(
"/collection/:id/items/:printId",
asyncHandler(async (req, res) => {
if (isSystemCollectionId(req.params.id)) {
throw new HttpError(400, "This collection can't be edited");
}
const collection = await prisma.collection.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!collection) throw new HttpError(404, "Collection not found");
const print = await prisma.print.findFirst({ where: { id: req.params.printId, userId: req.userId } });
if (!print) throw new HttpError(404, "Print not found");
await addPrintsToCollection(collection.id, [print.id]);
res.json({ ok: true });
void createLog({
userId: req.userId!,
action: "collection_item_added",
targetId: collection.id,
details: { printId: print.id, name: print.name },
});
}),
);
// Every real (non-system) collection this user owns, flagged with whether `printId` is currently
// a member -- backs the "Add to collection" picker opened from ModelActionsMenu. System
// pseudo-collections are omitted since they can't be toggled this way (see the POST/DELETE above).
router.get(
"/print/:id/collections",
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 collections = await prisma.collection.findMany({
where: { userId: req.userId },
orderBy: { createdAt: "desc" },
include: { items: { where: { printId: print.id }, select: { id: true } } },
});
res.json(collections.map((c) => ({ id: c.id, name: c.name, in_collection: c.items.length > 0 })));
}),
);
export default router;
+1
View File
@@ -12,6 +12,7 @@ export type LogAction =
| "collection_created"
| "collection_edited"
| "collection_deleted"
| "collection_item_added"
| "collection_item_removed";
/** Writes one admin-audit-trail entry (see the Log model's schema.prisma doc comment). Always
+26
View File
@@ -24,6 +24,14 @@ export type CollectionInput = {
tags?: string[];
};
/** One row of the "Add to collection" picker -- a real (non-system) collection this user owns,
* flagged with whether the print being edited is currently a member. */
export type CollectionMembership = {
id: string;
name: string;
in_collection: boolean;
};
export const collectionsApi = {
list: async (): Promise<Collection[]> => {
const res = await fetch(`${apiBase()}/collections`, { headers: authHeaders() });
@@ -72,4 +80,22 @@ export const collectionsApi = {
});
assertOk(res, "Remove from collection failed");
},
/** Adds one print to a (real, non-system) collection -- the reverse of removeItem. Used by the
* "Add to collection" picker. */
addItem: async (collectionId: string, printId: string): Promise<void> => {
const res = await fetch(`${apiBase()}/collection/${collectionId}/items/${printId}`, {
method: "POST",
headers: authHeaders(),
});
assertOk(res, "Add to collection failed");
},
/** Every real (non-system) collection this user owns, flagged with whether `printId` is
* currently a member -- backs the "Add to collection" picker. */
listForPrint: async (printId: string): Promise<CollectionMembership[]> => {
const res = await fetch(`${apiBase()}/print/${printId}/collections`, { headers: authHeaders() });
assertOk(res, "Failed to list collections");
return res.json();
},
};
+5
View File
@@ -1019,6 +1019,11 @@
"downloadFailed": "Download failed. Please try again.",
"deleteFailed": "Failed to delete the model.",
"confirmDelete": "Delete \"{{name}}\"? This cannot be undone.",
"addToCollection": "Add to collection",
"addToCollectionTitle": "Add to collection",
"addToCollectionEmpty": "You don't have any collections yet.",
"addToCollectionLoadFailed": "Failed to load collections. Please try again.",
"addToCollectionFailed": "Failed to update collections. Please try again.",
"removeFromCollection": "Remove from collection",
"removeFromCollectionFailed": "Failed to remove from collection. Please try again.",
"openInProvider": "Open in {{provider}}",
+5
View File
@@ -1020,6 +1020,11 @@
"downloadFailed": "Nepavyko atsisiųsti. Bandykite dar kartą.",
"deleteFailed": "Nepavyko ištrinti modelio.",
"confirmDelete": "Ištrinti „{{name}}“? Šio veiksmo nebus galima atšaukti.",
"addToCollection": "Pridėti prie kolekcijos",
"addToCollectionTitle": "Pridėti prie kolekcijos",
"addToCollectionEmpty": "Kol kas neturite jokių kolekcijų.",
"addToCollectionLoadFailed": "Nepavyko įkelti kolekcijų. Bandykite dar kartą.",
"addToCollectionFailed": "Nepavyko atnaujinti kolekcijų. Bandykite dar kartą.",
"removeFromCollection": "Pašalinti iš kolekcijos",
"removeFromCollectionFailed": "Nepavyko pašalinti iš kolekcijos. Bandykite dar kartą.",
"openInProvider": "Atverti „{{provider}}“",
@@ -0,0 +1,136 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import Typography from "@mui/material/Typography";
import CheckIcon from "@mui/icons-material/Check";
import { UnauthorizedError } from "../../api/client";
import { collectionsApi, type CollectionMembership } from "../../api/collections";
type Props = {
open: boolean;
onClose: () => void;
printId: string;
onUnauthorized?: () => void;
/** The collection currently being browsed, if any -- toggling it off here also fires
* onRemovedFromCollection so the card can drop out of that grid immediately, mirroring
* ModelActionsMenu's dedicated "Remove from collection" item. */
collectionId?: string;
onRemovedFromCollection?: () => void;
};
/** Tag-style picker for a model's collection membership: every real (non-system) collection the
* user owns as a clickable chip, filled when the model is already in it. Each click toggles that
* one collection immediately (no separate save step), matching how TagInput-adjacent pickers
* behave elsewhere in the app. Opened from ModelActionsMenu's "Add to collection" item, shared by
* the model detail page's title menu and every models grid's per-card "..." menu. */
export default function AddToCollectionModal({
open,
onClose,
printId,
onUnauthorized,
collectionId,
onRemovedFromCollection,
}: Props) {
const { t } = useTranslation(["models", "common"]);
const [collections, setCollections] = useState<CollectionMembership[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setError(null);
setCollections(null);
collectionsApi
.listForPrint(printId)
.then(setCollections)
.catch((err) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
setError(err instanceof Error ? err.message : t("models:detail.addToCollectionLoadFailed"));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, printId]);
const toggle = async (collection: CollectionMembership) => {
if (pendingId) return;
const nextIn = !collection.in_collection;
setPendingId(collection.id);
setError(null);
try {
if (nextIn) {
await collectionsApi.addItem(collection.id, printId);
} else {
await collectionsApi.removeItem(collection.id, printId);
}
setCollections((prev) => prev?.map((c) => (c.id === collection.id ? { ...c, in_collection: nextIn } : c)) ?? prev);
if (collection.id === collectionId && !nextIn) onRemovedFromCollection?.();
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
setError(err instanceof Error ? err.message : t("models:detail.addToCollectionFailed"));
} finally {
setPendingId(null);
}
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
<DialogTitle>{t("models:detail.addToCollectionTitle")}</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 1.5 }}>
{error}
</Alert>
)}
{collections === null && !error && (
<Stack alignItems="center" sx={{ py: 3 }}>
<CircularProgress size={24} />
</Stack>
)}
{collections?.length === 0 && (
<Typography variant="body2" color="text.secondary">
{t("models:detail.addToCollectionEmpty")}
</Typography>
)}
{collections && collections.length > 0 && (
<Stack direction="row" flexWrap="wrap" gap={1}>
{collections.map((c) => (
<Chip
key={c.id}
label={c.name}
clickable
disabled={pendingId === c.id}
onClick={() => toggle(c)}
color={c.in_collection ? "primary" : "default"}
variant={c.in_collection ? "filled" : "outlined"}
icon={
pendingId === c.id ? (
<CircularProgress size={14} color="inherit" />
) : c.in_collection ? (
<CheckIcon fontSize="small" />
) : undefined
}
/>
))}
</Stack>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("common:close")}</Button>
</DialogActions>
</Dialog>
);
}
@@ -11,6 +11,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
import DownloadIcon from "@mui/icons-material/Download";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import PlaylistAddIcon from "@mui/icons-material/PlaylistAdd";
import PlaylistRemoveIcon from "@mui/icons-material/PlaylistRemove";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import LaunchIcon from "@mui/icons-material/Launch";
@@ -25,6 +26,7 @@ import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
import { useDownloadPrint } from "./useDownloadPrint";
import DownloadPickerDialog from "./DownloadPickerDialog";
import AddToCollectionModal from "./AddToCollectionModal";
type Props = {
print: Print;
@@ -46,11 +48,11 @@ type Props = {
/** 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), "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. */
* 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. */
export default function ModelActionsMenu({
print,
onUnauthorized,
@@ -66,6 +68,7 @@ export default function ModelActionsMenu({
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [deleting, setDeleting] = useState(false);
const [removingFromCollection, setRemovingFromCollection] = useState(false);
const [addToCollectionOpen, setAddToCollectionOpen] = useState(false);
const { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates } =
useDownloadPrint(print, onUnauthorized);
@@ -159,6 +162,10 @@ export default function ModelActionsMenu({
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t("common:edit")}</ListItemText>
</MenuItem>
<MenuItem onClick={() => { closeMenu(); setAddToCollectionOpen(true); }}>
<ListItemIcon><PlaylistAddIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t("models:detail.addToCollection")}</ListItemText>
</MenuItem>
{collectionId && (
<MenuItem onClick={handleRemoveFromCollection}>
<ListItemIcon><PlaylistRemoveIcon fontSize="small" /></ListItemIcon>
@@ -193,6 +200,15 @@ export default function ModelActionsMenu({
downloadAllZip={downloadAllZip}
downloadPlate={downloadPlate}
/>
<AddToCollectionModal
open={addToCollectionOpen}
onClose={() => setAddToCollectionOpen(false)}
printId={print.id}
onUnauthorized={onUnauthorized}
collectionId={collectionId}
onRemovedFromCollection={onRemovedFromCollection}
/>
</>
);
}