mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: add to collections modals
This commit is contained in:
1 parent
e9ac4f9776
commit
1d8443e069
7 files changed
+236
-5
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user