From 312c6be706ec3f49356f71cb933bc78d0d64b71e Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Tue, 8 Sep 2026 23:03:22 +0200 Subject: [PATCH] feat: add sorting and ect --- backend/src/routes/imports.ts | 38 +++++- backend/src/routes/prints.ts | 33 +++--- backend/src/services/importService.ts | 14 +++ backend/tests/printsSort.test.ts | 109 ++++++++++++++++++ backend/tests/thingiverseCollection.test.ts | 33 +++++- backend/tests/thingiverseLikes.test.ts | 34 +++++- frontend/src/api/imports.ts | 4 +- frontend/src/api/prints.ts | 4 + frontend/src/components/Layout/AppLayout.tsx | 9 +- frontend/src/components/ToastProvider.tsx | 69 +++++++++++ .../CollectionEntryList.tsx | 14 ++- .../uploads/CollectionImportModal/index.tsx | 10 +- .../components/uploads/useUploadImport.tsx | 11 +- frontend/src/i18n/locales/en.json | 13 ++- frontend/src/i18n/locales/lt.json | 13 ++- .../src/pages/CollectionDetailPage/index.tsx | 34 +++++- frontend/src/pages/ModelsPage/ModelCard.tsx | 77 +++++++++++-- frontend/src/pages/ModelsPage/SortTabs.tsx | 38 ++++++ frontend/src/pages/ModelsPage/index.tsx | 26 ++++- 19 files changed, 526 insertions(+), 57 deletions(-) create mode 100644 backend/tests/printsSort.test.ts create mode 100644 frontend/src/components/ToastProvider.tsx create mode 100644 frontend/src/pages/ModelsPage/SortTabs.tsx diff --git a/backend/src/routes/imports.ts b/backend/src/routes/imports.ts index 3f01775..51ec356 100644 --- a/backend/src/routes/imports.ts +++ b/backend/src/routes/imports.ts @@ -7,7 +7,7 @@ import { HttpError } from "../utils/fileUtils"; import { normalizeImportUrl } from "../utils/urlUtils"; import { parseBody } from "../utils/validate"; import { asyncHandler } from "../utils/asyncHandler"; -import { downloadImportToTemp, importPrintFromUrl, inspectImportLink } from "../services/importService"; +import { downloadImportToTemp, findImportedExternalIds, importPrintFromUrl, inspectImportLink } from "../services/importService"; import { resolveMakerworldCookie } from "../services/importResolvers"; import { extractMakerworldBearerToken } from "../services/makerworldCloudApi"; import { fetchMakerworldCollectionEntries, fetchMakerworldCollectionTitle, parseMakerworldCollectionUrl } from "../services/makerworldCollections"; @@ -94,11 +94,21 @@ router.post( ]); if (!listing.entries.length) throw new HttpError(400, "Could not load this collection's models"); + const alreadyImported = await findImportedExternalIds( + req.userId!, + "makerworld", + listing.entries.map((e) => e.designId), + ); res.json({ title, total: listing.total, truncated: listing.truncated, - entries: listing.entries.map((e) => ({ design_id: e.designId, title: e.title, cover: e.cover })), + entries: listing.entries.map((e) => ({ + design_id: e.designId, + title: e.title, + cover: e.cover, + already_imported: alreadyImported.has(e.designId), + })), }); }), ); @@ -121,11 +131,21 @@ router.post( const listing = await fetchThingiverseUserLikes(parsed.username, accessToken); if (!listing.entries.length) throw new HttpError(400, "Could not load this user's likes -- check the username and try again"); + const alreadyImported = await findImportedExternalIds( + req.userId!, + "thingiverse", + listing.entries.map((e) => e.thingId), + ); res.json({ title: `${parsed.username}'s Thingiverse Likes`, total: listing.entries.length, truncated: listing.truncated, - entries: listing.entries.map((e) => ({ design_id: e.thingId, title: e.title, cover: e.cover })), + entries: listing.entries.map((e) => ({ + design_id: e.thingId, + title: e.title, + cover: e.cover, + already_imported: alreadyImported.has(e.thingId), + })), }); }), ); @@ -151,11 +171,21 @@ router.post( ]); if (!listing.entries.length) throw new HttpError(400, "Could not load this collection's models"); + const alreadyImported = await findImportedExternalIds( + req.userId!, + "thingiverse", + listing.entries.map((e) => e.thingId), + ); res.json({ title, total: listing.entries.length, truncated: listing.truncated, - entries: listing.entries.map((e) => ({ design_id: e.thingId, title: e.title, cover: e.cover })), + entries: listing.entries.map((e) => ({ + design_id: e.thingId, + title: e.title, + cover: e.cover, + already_imported: alreadyImported.has(e.thingId), + })), }); }), ); diff --git a/backend/src/routes/prints.ts b/backend/src/routes/prints.ts index c0b9c31..5e05bc2 100644 --- a/backend/src/routes/prints.ts +++ b/backend/src/routes/prints.ts @@ -112,6 +112,13 @@ router.post( // ---- GET /prints ------------------------------------------------------------------------------ +type PrintSortMode = "newest" | "popular" | "downloads"; + +function parseSortMode(raw: unknown): PrintSortMode { + if (raw === "popular" || raw === "downloads") return raw; + return "newest"; +} + router.get( "/prints", asyncHandler(async (req, res) => { @@ -146,24 +153,24 @@ router.get( filesByPrint.set(f.printId, list); } - // The "Favourites"/"Browsing History" pseudo-collections are ordered most-recent-first (by - // when they were favorited/last viewed) rather than the default alphabetical grid order. + // The "Favourites"/"Browsing History" pseudo-collections default to most-recent-first (by + // when they were favorited/last viewed) under the "newest" sort -- "popular"/"downloads" + // still sort by their own metric even inside those collections, same as anywhere else. const collectionIdParam = typeof req.query.collection_id === "string" ? req.query.collection_id.trim() : ""; const systemKey = systemCollectionKeyForId(collectionIdParam); const recencyField = systemKey === "favorites" ? "favoritedAt" : systemKey === "history" ? "lastViewedAt" : null; + const sortMode = parseSortMode(req.query.orderBy); + + const sortValue = (p: (typeof prints)[number]): number => { + if (sortMode === "popular") return p.viewCount; + if (sortMode === "downloads") return p.printCount; + if (recencyField) return p[recencyField]?.getTime() ?? 0; + return p.createdAt.getTime(); + }; const sorted = prints.toSorted((a, b) => { - if (recencyField) { - const at = a[recencyField]?.getTime() ?? 0; - const bt = b[recencyField]?.getTime() ?? 0; - if (at !== bt) return bt - at; - } - const nameCmp = a.name.localeCompare(b.name); - if (nameCmp !== 0) return nameCmp; - const af = a.plates[0]?.filename ?? ""; - const bf = b.plates[0]?.filename ?? ""; - const fCmp = af.localeCompare(bf); - if (fCmp !== 0) return fCmp; + const diff = sortValue(b) - sortValue(a); + if (diff !== 0) return diff; return a.id.localeCompare(b.id); }); diff --git a/backend/src/services/importService.ts b/backend/src/services/importService.ts index 7a03e81..857ac76 100644 --- a/backend/src/services/importService.ts +++ b/backend/src/services/importService.ts @@ -461,6 +461,20 @@ async function findExistingImportedPrint( return { print, plates, author: print.author, previewImages }; } +/** Bulk version of findExistingImportedPrint's lookup -- used by the collection/likes ".../entries" + * routes to flag which entries in a listing the user already has, *before* they pick what to + * import, instead of only finding out one by one as each import attempt hits the unique + * constraint. Returns just the set of already-imported external ids (one indexed query), not + * full Print records -- the entries list only needs a yes/no per item. */ +export async function findImportedExternalIds(userId: string, provider: string, externalIds: string[]): Promise> { + if (!externalIds.length) return new Set(); + const prints = await prisma.print.findMany({ + where: { userId, sourceProvider: provider, sourceExternalId: { in: externalIds } }, + select: { sourceExternalId: true }, + }); + return new Set(prints.map((p) => p.sourceExternalId).filter((id): id is string => id !== null)); +} + const THINGIVERSE_PLATE_EXTS = new Set([...IMPORT_ALLOWED_EXTS].filter((ext) => ext !== ".zip")); type PlainDownloadResult = { input: NewPlateInput } | { rateLimited: true } | null; diff --git a/backend/tests/printsSort.test.ts b/backend/tests/printsSort.test.ts new file mode 100644 index 0000000..9c4d79c --- /dev/null +++ b/backend/tests/printsSort.test.ts @@ -0,0 +1,109 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import request from "supertest"; +import { createApp } from "../src/app"; +import { prisma } from "../src/db"; + +// GET /prints's `orderBy` param (newest [default] / popular / downloads) -- see printsSort's +// sortValue() in routes/prints.ts. Kept to exactly these three fixed metrics, no per-column +// ascending/descending toggle, mirroring MakerWorld's model-browsing sort row. + +const app = createApp(); +let token: string; +let userId: string; + +beforeAll(async () => { + const email = `prints-sort-test-${Date.now()}@example.com`; + const res = await request(app) + .post("/api/register") + .send({ displayName: "Prints Sort Test", email, password: "password123" }); + if (res.status !== 200) { + throw new Error(`Failed to register during test setup: ${res.status} ${JSON.stringify(res.body)}`); + } + token = res.body.token; + userId = res.body.user.id; +}); + +function auth() { + return { Authorization: `Bearer ${token}` }; +} + +describe("GET /prints -- orderBy", () => { + let oldestId: string; + let middleId: string; + let newestId: string; + + beforeAll(async () => { + // Distinct createdAt/viewCount/printCount per print, deliberately *not* correlated with each + // other (oldest has the most views, newest has the most prints) so each sort mode's result + // order can only match if it's actually reading the field it claims to. + const oldest = await prisma.print.create({ + data: { + userId, + name: "Sort Oldest", + nameNormalized: "sort oldest", + createdAt: new Date("2020-01-01T00:00:00Z"), + viewCount: 100, + printCount: 1, + }, + }); + const middle = await prisma.print.create({ + data: { + userId, + name: "Sort Middle", + nameNormalized: "sort middle", + createdAt: new Date("2021-01-01T00:00:00Z"), + viewCount: 50, + printCount: 5, + }, + }); + const newest = await prisma.print.create({ + data: { + userId, + name: "Sort Newest", + nameNormalized: "sort newest", + createdAt: new Date("2022-01-01T00:00:00Z"), + viewCount: 10, + printCount: 20, + }, + }); + oldestId = oldest.id; + middleId = middle.id; + newestId = newest.id; + }); + + it("defaults to newest-first (createdAt desc) when orderBy is omitted", async () => { + const res = await request(app).get("/api/prints").set(auth()); + expect(res.status).toBe(200); + const ids = res.body.map((p: { id: string }) => p.id); + expect(ids.indexOf(newestId)).toBeLessThan(ids.indexOf(middleId)); + expect(ids.indexOf(middleId)).toBeLessThan(ids.indexOf(oldestId)); + }); + + it("orderBy=newest sorts by createdAt desc explicitly", async () => { + const res = await request(app).get("/api/prints").set(auth()).query({ orderBy: "newest" }); + const ids = res.body.map((p: { id: string }) => p.id); + expect(ids.indexOf(newestId)).toBeLessThan(ids.indexOf(middleId)); + expect(ids.indexOf(middleId)).toBeLessThan(ids.indexOf(oldestId)); + }); + + it("orderBy=popular sorts by view count desc", async () => { + const res = await request(app).get("/api/prints").set(auth()).query({ orderBy: "popular" }); + const ids = res.body.map((p: { id: string }) => p.id); + expect(ids.indexOf(oldestId)).toBeLessThan(ids.indexOf(middleId)); + expect(ids.indexOf(middleId)).toBeLessThan(ids.indexOf(newestId)); + }); + + it("orderBy=downloads sorts by print count desc", async () => { + const res = await request(app).get("/api/prints").set(auth()).query({ orderBy: "downloads" }); + const ids = res.body.map((p: { id: string }) => p.id); + expect(ids.indexOf(newestId)).toBeLessThan(ids.indexOf(middleId)); + expect(ids.indexOf(middleId)).toBeLessThan(ids.indexOf(oldestId)); + }); + + it("falls back to newest for an unrecognized orderBy value", async () => { + const res = await request(app).get("/api/prints").set(auth()).query({ orderBy: "bogus" }); + const ids = res.body.map((p: { id: string }) => p.id); + expect(ids.indexOf(newestId)).toBeLessThan(ids.indexOf(middleId)); + expect(ids.indexOf(middleId)).toBeLessThan(ids.indexOf(oldestId)); + }); +}); diff --git a/backend/tests/thingiverseCollection.test.ts b/backend/tests/thingiverseCollection.test.ts index 32ee4f1..fce6f9d 100644 --- a/backend/tests/thingiverseCollection.test.ts +++ b/backend/tests/thingiverseCollection.test.ts @@ -7,6 +7,7 @@ import { parseThingiverseCollectionUrl, } from "../src/services/thingiverseApi"; import { setThingiverseAccessToken } from "../src/services/settingsService"; +import { prisma } from "../src/db"; const ACCESS_TOKEN = "test-access-token"; const COLLECTION_ID = "40781574"; @@ -79,6 +80,7 @@ describe("fetchThingiverseCollectionThings / fetchThingiverseCollectionTitle", ( describe("POST /import/thingiverse-collection/entries", () => { const app = createApp(); let token: string; + let userId: string; const originalFetch = global.fetch; @@ -91,6 +93,7 @@ describe("POST /import/thingiverse-collection/entries", () => { throw new Error(`Failed to register during test setup: ${res.status} ${JSON.stringify(res.body)}`); } token = res.body.token; + userId = res.body.user.id; }); afterEach(async () => { @@ -115,7 +118,35 @@ describe("POST /import/thingiverse-collection/entries", () => { expect(res.status).toBe(200); expect(res.body.title).toBe(COLLECTION_TITLE); expect(res.body.entries).toEqual([ - { design_id: "1", title: "Collected Thing 1", cover: "https://cdn.thingiverse.com/assets/test/1.jpg" }, + { design_id: "1", title: "Collected Thing 1", cover: "https://cdn.thingiverse.com/assets/test/1.jpg", already_imported: false }, + ]); + }); + + it("flags an entry already in the user's library instead of letting it be re-selected", async () => { + await setThingiverseAccessToken(ACCESS_TOKEN); + await prisma.print.create({ + data: { + userId, + name: "Already imported", + nameNormalized: "already imported", + sourceProvider: "thingiverse", + sourceExternalId: "1", + }, + }); + global.fetch = vi.fn<(input: RequestInfo | URL) => Promise>(async (input) => { + const url = String(input); + if (url.includes("/things?")) return jsonResponse(200, [thingEntry(1)]); + return jsonResponse(200, { id: Number(COLLECTION_ID), name: COLLECTION_TITLE }); + }) as unknown as typeof fetch; + + const res = await request(app) + .post("/api/import/thingiverse-collection/entries") + .set("Authorization", `Bearer ${token}`) + .send({ url: `https://www.thingiverse.com/Derzinskas/collections/${COLLECTION_ID}/things`, tags: [] }); + + expect(res.status).toBe(200); + expect(res.body.entries).toEqual([ + { design_id: "1", title: "Collected Thing 1", cover: "https://cdn.thingiverse.com/assets/test/1.jpg", already_imported: true }, ]); }); diff --git a/backend/tests/thingiverseLikes.test.ts b/backend/tests/thingiverseLikes.test.ts index c989c6f..7994f00 100644 --- a/backend/tests/thingiverseLikes.test.ts +++ b/backend/tests/thingiverseLikes.test.ts @@ -3,6 +3,7 @@ import request from "supertest"; import { createApp } from "../src/app"; import { fetchThingiverseUserLikes, parseThingiverseLikesUrl } from "../src/services/thingiverseApi"; import { setThingiverseAccessToken } from "../src/services/settingsService"; +import { prisma } from "../src/db"; const ACCESS_TOKEN = "test-access-token"; const USERNAME = "Derzinskas"; @@ -73,6 +74,7 @@ describe("fetchThingiverseUserLikes", () => { describe("POST /import/thingiverse-likes/entries", () => { const app = createApp(); let token: string; + let userId: string; const originalFetch = global.fetch; beforeAll(async () => { @@ -84,6 +86,7 @@ describe("POST /import/thingiverse-likes/entries", () => { throw new Error(`Failed to register during test setup: ${res.status} ${JSON.stringify(res.body)}`); } token = res.body.token; + userId = res.body.user.id; }); afterEach(async () => { @@ -115,8 +118,35 @@ describe("POST /import/thingiverse-likes/entries", () => { expect(res.status).toBe(200); expect(res.body.title).toBe(`${USERNAME}'s Thingiverse Likes`); expect(res.body.entries).toEqual([ - { design_id: "1", title: "Liked Thing 1", cover: "https://cdn.thingiverse.com/assets/test/1.jpg" }, - { design_id: "2", title: "Liked Thing 2", cover: "https://cdn.thingiverse.com/assets/test/2.jpg" }, + { design_id: "1", title: "Liked Thing 1", cover: "https://cdn.thingiverse.com/assets/test/1.jpg", already_imported: false }, + { design_id: "2", title: "Liked Thing 2", cover: "https://cdn.thingiverse.com/assets/test/2.jpg", already_imported: false }, + ]); + }); + + it("flags entries already in the user's library instead of letting them be re-selected", async () => { + await setThingiverseAccessToken(ACCESS_TOKEN); + await prisma.print.create({ + data: { + userId, + name: "Already imported", + nameNormalized: "already imported", + sourceProvider: "thingiverse", + sourceExternalId: "1", + }, + }); + global.fetch = vi.fn<(input: RequestInfo | URL) => Promise>(async () => + jsonResponse(200, [likeEntry(1), likeEntry(2)]), + ) as unknown as typeof fetch; + + const res = await request(app) + .post("/api/import/thingiverse-likes/entries") + .set("Authorization", `Bearer ${token}`) + .send({ url: `https://www.thingiverse.com/${USERNAME}/likes`, tags: [] }); + + expect(res.status).toBe(200); + expect(res.body.entries).toEqual([ + { design_id: "1", title: "Liked Thing 1", cover: "https://cdn.thingiverse.com/assets/test/1.jpg", already_imported: true }, + { design_id: "2", title: "Liked Thing 2", cover: "https://cdn.thingiverse.com/assets/test/2.jpg", already_imported: false }, ]); }); diff --git a/frontend/src/api/imports.ts b/frontend/src/api/imports.ts index 0174f7e..6bacebe 100644 --- a/frontend/src/api/imports.ts +++ b/frontend/src/api/imports.ts @@ -1,5 +1,6 @@ import { authHeaders } from "../utils/auth"; import { apiBase, readErrorMessage, UnauthorizedError } from "./client"; +import type { Print } from "./prints"; export type ImportInspectInfo = { filename: string; @@ -16,6 +17,7 @@ export type ImportCollectionEntry = { design_id: string; title: string; cover: string | null; + already_imported: boolean; }; export type ImportCollectionEntriesResult = { @@ -58,7 +60,7 @@ type ImportLinkPayload = { }; export const importsApi = { - fromLink: async (payload: ImportLinkPayload) => { + fromLink: async (payload: ImportLinkPayload): Promise => { const res = await fetch(`${apiBase()}/import`, { method: "POST", headers: authHeaders({ "Content-Type": "application/json" }), diff --git a/frontend/src/api/prints.ts b/frontend/src/api/prints.ts index 2c1336a..aa30ad1 100644 --- a/frontend/src/api/prints.ts +++ b/frontend/src/api/prints.ts @@ -87,6 +87,8 @@ export type UploadPrintsResult = { prints: Print[]; }; +export type PrintSortMode = "newest" | "popular" | "downloads"; + export const printsApi = { // API returns relative URLs. Join with API base. fileUrl: (rel: string) => { @@ -99,6 +101,7 @@ export const printsApi = { tags?: string[]; folder_id?: string | string[]; collection_id?: string; + order_by?: PrintSortMode; limit?: number; offset?: number; } = {}): Promise => { @@ -109,6 +112,7 @@ export const printsApi = { qs.set("folder_id", Array.isArray(params.folder_id) ? params.folder_id.join(",") : params.folder_id); } if (params.collection_id) qs.set("collection_id", params.collection_id); + if (params.order_by) qs.set("orderBy", params.order_by); if (typeof params.limit === "number") qs.set("limit", String(params.limit)); if (typeof params.offset === "number") qs.set("offset", String(params.offset)); const res = await fetch(`${apiBase()}/prints?${qs.toString()}`, { diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index 2df3bad..ff6633b 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -9,6 +9,7 @@ import Sidebar from "./Sidebar"; import TopBar from "./TopBar"; import ImportProgressBar from "./ImportProgressBar"; import { ConfirmProvider } from "../ConfirmProvider"; +import { ToastProvider } from "../ToastProvider"; import { PageHeaderContext, type PageHeader } from "./PageHeaderContext"; import { ImportJobProvider } from "./ImportJobContext"; import { NotificationsProvider, useNotifications } from "./NotificationsContext"; @@ -152,9 +153,11 @@ export default function AppLayout({ muiTheme, onUnauthorized, ...shellProps }: A - - - + + + + + ); diff --git a/frontend/src/components/ToastProvider.tsx b/frontend/src/components/ToastProvider.tsx new file mode 100644 index 0000000..87b68f7 --- /dev/null +++ b/frontend/src/components/ToastProvider.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import Snackbar from "@mui/material/Snackbar"; +import Alert, { type AlertColor } from "@mui/material/Alert"; + +export type ToastOptions = { + message: string; + severity?: AlertColor; +}; + +type QueuedToast = ToastOptions & { key: number }; + +const ToastContext = React.createContext<((options: ToastOptions) => void) | null>(null); + +let nextKey = 0; + +/** App-wide toast/snackbar, mirroring ConfirmProvider's single-instance-shared-by-every-caller + * shape: `showToast({ message, severity })` in place of wiring up a Snackbar per component. + * Mounted once in AppLayout. Queues toasts one at a time (MUI's own recommended pattern for + * consecutive Snackbars) instead of stacking or dropping one that fires while another is + * still showing. */ +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [queue, setQueue] = React.useState([]); + const [current, setCurrent] = React.useState(null); + const [open, setOpen] = React.useState(false); + + const showToast = React.useCallback((options: ToastOptions) => { + setQueue(prev => [...prev, { ...options, key: nextKey++ }]); + }, []); + + React.useEffect(() => { + if (queue.length && !current) { + setCurrent(queue[0]); + setQueue(prev => prev.slice(1)); + setOpen(true); + } else if (queue.length && current && open) { + setOpen(false); + } + }, [queue, current, open]); + + const handleClose = (_event: unknown, reason?: string) => { + if (reason === "clickaway") return; + setOpen(false); + }; + + return ( + + {children} + setCurrent(null) } }} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setOpen(false)} severity={current?.severity ?? "success"} variant="filled" sx={{ width: "100%" }}> + {current?.message} + + + + ); +} + +/** Returns a function that queues a toast -- `showToast({ message: "..." })`. */ +export function useToast() { + const showToast = React.useContext(ToastContext); + if (!showToast) throw new Error("useToast must be used within a ToastProvider"); + return showToast; +} diff --git a/frontend/src/components/uploads/CollectionImportModal/CollectionEntryList.tsx b/frontend/src/components/uploads/CollectionImportModal/CollectionEntryList.tsx index 13b4577..b1294a4 100644 --- a/frontend/src/components/uploads/CollectionImportModal/CollectionEntryList.tsx +++ b/frontend/src/components/uploads/CollectionImportModal/CollectionEntryList.tsx @@ -2,8 +2,10 @@ import React from "react"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import Checkbox from "@mui/material/Checkbox"; +import Chip from "@mui/material/Chip"; import FormControlLabel from "@mui/material/FormControlLabel"; import Stack from "@mui/material/Stack"; +import { useTranslation } from "react-i18next"; import type { ImportCollectionEntry } from "../../../api/imports"; type Props = { @@ -17,8 +19,12 @@ type Props = { /** The scrollable checkbox list shown in the collection import wizard's "select models" step * -- one row per MakerWorld design, with its cover thumbnail so it's recognizable at a glance * (unlike ZipEntryList's plain filenames, a collection's entries are only meaningfully told - * apart by what they look like). */ + * apart by what they look like). Entries already in the user's library (already_imported, from + * the backend's bulk dedup check -- see findImportedExternalIds) are shown dimmed with their + * checkbox disabled instead of left selectable: re-importing would just hit the same dedup and + * do nothing, so there's no point offering it. */ export default function CollectionEntryList({ entries, selected, busy, noEntriesLabel, onToggleEntry }: Props) { + const { t } = useTranslation("app"); return ( {entries.map(entry => ( @@ -33,12 +39,13 @@ export default function CollectionEntryList({ entries, selected, busy, noEntries borderBottom: "1px solid", borderColor: "divider", "&:last-of-type": { borderBottom: "none" }, + opacity: entry.already_imported ? 0.5 : 1, }} control={ onToggleEntry(entry.design_id)} - disabled={busy} + disabled={busy || entry.already_imported} size="small" /> } @@ -61,6 +68,9 @@ export default function CollectionEntryList({ entries, selected, busy, noEntries {entry.title} + {entry.already_imported && ( + + )} } /> diff --git a/frontend/src/components/uploads/CollectionImportModal/index.tsx b/frontend/src/components/uploads/CollectionImportModal/index.tsx index 785b8b0..3ac9bf7 100644 --- a/frontend/src/components/uploads/CollectionImportModal/index.tsx +++ b/frontend/src/components/uploads/CollectionImportModal/index.tsx @@ -71,7 +71,7 @@ export function useCollectionImportPrompt() { setTotal(result.total); setTruncated(result.truncated); setEntries(result.entries); - setSelected(new Set(result.entries.map(entry => entry.design_id))); + setSelected(new Set(result.entries.filter(entry => !entry.already_imported).map(entry => entry.design_id))); setStage("select"); } catch (err) { setError(errorMessage(err, t("collectionImport.loadError"))); @@ -110,6 +110,8 @@ export function useCollectionImportPrompt() { }; const toggleEntry = (designId: string) => { + const entry = entries.find(e => e.design_id === designId); + if (entry?.already_imported) return; setSelected(prev => { const next = new Set(prev); if (next.has(designId)) next.delete(designId); @@ -118,8 +120,10 @@ export function useCollectionImportPrompt() { }); }; + const selectableEntries = entries.filter(entry => !entry.already_imported); + const selectAll = () => { - setSelected(new Set(entries.map(entry => entry.design_id))); + setSelected(new Set(selectableEntries.map(entry => entry.design_id))); }; const clearAll = () => { @@ -127,7 +131,7 @@ export function useCollectionImportPrompt() { }; const selectedCount = selected.size; - const allSelected = entries.length > 0 && selectedCount === entries.length; + const allSelected = selectableEntries.length > 0 && selectedCount === selectableEntries.length; const modal = state ? ( (null); const [uploading, setUploading] = useState(false); const [importing, setImporting] = useState(false); @@ -298,14 +300,16 @@ export function useUploadImport({ onUploaded, folderId, makerworldCookie, onUnau if (isThingiverseThingUrl(url)) { // A Thing always resolves to a zip; skip straight past the inspect/zip-picker steps -- // the backend already splits it into plates automatically. - await importsApi.fromLink(payload); + const imported = await importsApi.fromLink(payload); + showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) }); onUploaded(); return; } const inspect = await importsApi.inspectLink(payload); if (!inspect.is_zip) { - await importsApi.fromLink(payload); + const imported = await importsApi.fromLink(payload); + showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) }); onUploaded(); return; } @@ -314,7 +318,8 @@ export function useUploadImport({ onUploaded, folderId, makerworldCookie, onUnau label: inspect.filename, onImportAsZip: async () => { try { - await importsApi.fromLink(payload); + const imported = await importsApi.fromLink(payload); + showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) }); } catch (err) { if (err instanceof UnauthorizedError) { onUnauthorized?.(); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4cb749c..84aff4b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -85,7 +85,8 @@ "importFailed": "Import failed", "uploadFailed": "Failed to upload: {{files}}", "multiplateImportFailedWithMessage": "Multi-plate import ({{message}})", - "multiplateImportFailed": "Multi-plate import" + "multiplateImportFailed": "Multi-plate import", + "imported": "Imported \"{{name}}\"" }, "zipImport": { "title": "Import zip", @@ -111,6 +112,7 @@ "selectedCount": "{{selected}} of {{total}} selected", "selectAll": "Select all", "clear": "Clear", + "alreadyImported": "Already in library", "noEntries": "No models found in this collection.", "importSelected": "Import selected", "importing": "Importing...", @@ -514,8 +516,15 @@ "empty": "No models yet.", "loadingMore": "Loading…" }, + "sort": { + "newest": "Newest", + "popular": "Popular", + "downloads": "Downloads" + }, "card": { - "unknownAuthor": "Unknown" + "unknownAuthor": "Unknown", + "addedToFavorites": "Added \"{{name}}\" to Favourites", + "removedFromFavorites": "Removed \"{{name}}\" from Favourites" }, "detail": { "preview3d": "3D Preview", diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 2c0ac81..afac352 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -85,7 +85,8 @@ "importFailed": "Importuoti nepavyko", "uploadFailed": "Nepavyko įkelti: {{files}}", "multiplateImportFailedWithMessage": "Kelių plokščių importas ({{message}})", - "multiplateImportFailed": "Kelių plokščių importas" + "multiplateImportFailed": "Kelių plokščių importas", + "imported": "Importuota „{{name}}“" }, "zipImport": { "title": "Importuoti zip", @@ -111,6 +112,7 @@ "selectedCount": "Pažymėta {{selected}} iš {{total}}", "selectAll": "Pažymėti visus", "clear": "Išvalyti", + "alreadyImported": "Jau bibliotekoje", "noEntries": "Šioje kolekcijoje modelių nerasta.", "importSelected": "Importuoti pažymėtus", "importing": "Importuojama...", @@ -514,8 +516,15 @@ "empty": "Kol kas nėra modelių.", "loadingMore": "Įkeliama…" }, + "sort": { + "newest": "Naujausi", + "popular": "Populiariausi", + "downloads": "Atsisiuntimai" + }, "card": { - "unknownAuthor": "Nežinomas" + "unknownAuthor": "Nežinomas", + "addedToFavorites": "\"{{name}}\" pridėtas prie Mėgstamiausių", + "removedFromFavorites": "\"{{name}}\" pašalintas iš Mėgstamiausių" }, "detail": { "preview3d": "3D peržiūra", diff --git a/frontend/src/pages/CollectionDetailPage/index.tsx b/frontend/src/pages/CollectionDetailPage/index.tsx index 00d4904..088932c 100644 --- a/frontend/src/pages/CollectionDetailPage/index.tsx +++ b/frontend/src/pages/CollectionDetailPage/index.tsx @@ -1,19 +1,20 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import CircularProgress from "@mui/material/CircularProgress"; import { UnauthorizedError } from "../../api/client"; import { type Collection, collectionsApi } from "../../api/collections"; -import { type Print, printsApi } from "../../api/prints"; +import { type Print, type PrintSortMode, printsApi } from "../../api/prints"; import { type PreviewMode } from "../../api/settings"; import { type ResolvedTheme } from "../../constants/settingsOptions"; import { usePageHeader } from "../../components/Layout/PageHeaderContext"; import { useInfiniteScroll } from "../../hooks/useInfiniteScroll"; import { collectionDisplayName } from "../../utils/collectionDisplay"; import ModelCard from "../ModelsPage/ModelCard"; +import SortTabs from "../ModelsPage/SortTabs"; import CollectionActionsMenu from "./CollectionActionsMenu"; const PAGE_SIZE = 24; @@ -35,6 +36,18 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize const [loadingMore, setLoadingMore] = useState(false); const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + const sortModeParam = searchParams.get("orderBy"); + const sortMode: PrintSortMode = sortModeParam === "popular" || sortModeParam === "downloads" ? sortModeParam : "newest"; + + const setSortMode = (mode: PrintSortMode) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev); + if (mode === "newest") next.delete("orderBy"); + else next.set("orderBy", mode); + return next; + }); + }; const goBack = () => navigate("/models/collections"); @@ -69,7 +82,7 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize try { const [collectionResult, printsResult] = await Promise.all([ collectionsApi.get(collectionId), - printsApi.list({ collection_id: collectionId, limit: PAGE_SIZE, offset: 0 }), + printsApi.list({ collection_id: collectionId, order_by: sortMode, limit: PAGE_SIZE, offset: 0 }), ]); if (cancelled) return; setCollection(collectionResult); @@ -86,13 +99,13 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [collectionId]); + }, [collectionId, sortMode]); const loadMore = async () => { if (loadingMore || !hasMore || !collectionId) return; setLoadingMore(true); try { - const result = await printsApi.list({ collection_id: collectionId, limit: PAGE_SIZE, offset }); + const result = await printsApi.list({ collection_id: collectionId, order_by: sortMode, limit: PAGE_SIZE, offset }); setItems(prev => [...prev, ...result.items]); setOffset(offset + result.items.length); setHasMore(result.hasMore); @@ -126,6 +139,7 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize {collection.description && ( {collection.description} )} + {items.length ? ( @@ -136,6 +150,16 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize theme={theme} previewMode={previewMode} onDeleted={deletedId => setItems(prev => prev.filter(i => i.id !== deletedId))} + onFavoriteChange={updated => + setItems(prev => + // Viewing the built-in Favourites collection itself: unfavoriting an item here + // should drop it from view immediately, same as any other removal, instead of + // leaving a now-stale entry until the next full reload. + collection?.system_key === "favorites" && !updated.is_favorite + ? prev.filter(i => i.id !== updated.id) + : prev.map(i => (i.id === updated.id ? updated : i)), + ) + } onUnauthorized={onUnauthorized} /> ))} diff --git a/frontend/src/pages/ModelsPage/ModelCard.tsx b/frontend/src/pages/ModelsPage/ModelCard.tsx index 6590a7a..144c683 100644 --- a/frontend/src/pages/ModelsPage/ModelCard.tsx +++ b/frontend/src/pages/ModelsPage/ModelCard.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import Paper from "@mui/material/Paper"; @@ -5,11 +6,18 @@ import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Avatar from "@mui/material/Avatar"; import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import CircularProgress from "@mui/material/CircularProgress"; +import Tooltip from "@mui/material/Tooltip"; import VisibilityIcon from "@mui/icons-material/Visibility"; import PrintIcon from "@mui/icons-material/Print"; -import { type Print } from "../../api/prints"; +import FavoriteIcon from "@mui/icons-material/Favorite"; +import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; +import { type Print, printsApi } from "../../api/prints"; import { type PreviewMode } from "../../api/settings"; import { type ResolvedTheme } from "../../constants/settingsOptions"; +import { UnauthorizedError } from "../../api/client"; +import { useToast } from "../../components/ToastProvider"; import { renderPreviewContent } from "../../components/media/renderPreviewContent"; import { importProviderInfo } from "../../constants/importProviders"; import ModelActionsMenu from "../ModelDetailPage/ModelActionsMenu"; @@ -19,16 +27,46 @@ type Props = { theme: ResolvedTheme; previewMode: PreviewMode; onDeleted?: (id: string) => void; + onFavoriteChange?: (print: Print) => void; onUnauthorized?: () => void; }; -export default function ModelCard({ item, theme, previewMode, onDeleted, onUnauthorized }: Props) { +const hoverActionBg = { bgcolor: "rgba(0, 0, 0, 0.55)", borderRadius: "50%" }; + +export default function ModelCard({ item, theme, previewMode, onDeleted, onFavoriteChange, onUnauthorized }: Props) { const { t } = useTranslation(["models", "common"]); const navigate = useNavigate(); + const showToast = useToast(); + const [favoriteBusy, setFavoriteBusy] = useState(false); const author = item.author; const authorName = author?.name || author?.handle || item.creator || null; const providerInfo = importProviderInfo(item.source_provider); + const toggleFavorite = async () => { + if (favoriteBusy) return; + setFavoriteBusy(true); + try { + const updated = item.is_favorite ? await printsApi.unfavorite(item.id) : await printsApi.favorite(item.id); + onFavoriteChange?.(updated); + showToast({ + message: t(updated.is_favorite ? "models:card.addedToFavorites" : "models:card.removedFromFavorites", { + name: updated.title || updated.name, + }), + }); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return; + } + console.error(err); + showToast({ message: t("models:detail.favoriteFailed"), severity: "error" }); + } finally { + setFavoriteBusy(false); + } + }; + + const favoriteLabel = item.is_favorite ? t("models:detail.removeFromFavorites") : t("models:detail.addToFavorites"); + return ( )} - e.stopPropagation()} sx={{ position: "absolute", @@ -89,17 +129,30 @@ export default function ModelCard({ item, theme, previewMode, onDeleted, onUnaut right: 8, opacity: 0, transition: "opacity .15s ease", - bgcolor: "rgba(0, 0, 0, 0.55)", - borderRadius: "50%", }} > - onDeleted?.(item.id)} - triggerSx={{ color: "#fff" }} - /> - + + + + {favoriteBusy ? ( + + ) : item.is_favorite ? ( + + ) : ( + + )} + + + + + onDeleted?.(item.id)} + triggerSx={{ color: "#fff" }} + /> + + {item.title || item.name} diff --git a/frontend/src/pages/ModelsPage/SortTabs.tsx b/frontend/src/pages/ModelsPage/SortTabs.tsx new file mode 100644 index 0000000..32881fc --- /dev/null +++ b/frontend/src/pages/ModelsPage/SortTabs.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { type PrintSortMode } from "../../api/prints"; + +const SORT_MODES: PrintSortMode[] = ["newest", "popular", "downloads"]; + +type Props = { + value: PrintSortMode; + onChange: (mode: PrintSortMode) => void; +}; + +/** The row of "Newest / Popular / Downloads" sort links shown above every models grid (Models + * page, and a collection's model list) -- mirrors MakerWorld's model-browsing sort row, kept to + * three fixed metrics (no per-column ascending/descending toggle). "Popular" sorts by view + * count, "Downloads" by print count -- see the `orderBy` handling in GET /prints. */ +export default function SortTabs({ value, onChange }: Props) { + const { t } = useTranslation("models"); + return ( + + {SORT_MODES.map(mode => ( + onChange(mode)} + sx={{ + cursor: "pointer", + fontWeight: value === mode ? 700 : 400, + color: value === mode ? "primary.main" : "text.secondary", + "&:hover": { color: "primary.main" }, + }} + > + {t(`sort.${mode}`)} + + ))} + + ); +} diff --git a/frontend/src/pages/ModelsPage/index.tsx b/frontend/src/pages/ModelsPage/index.tsx index 678ffd4..a79eafe 100644 --- a/frontend/src/pages/ModelsPage/index.tsx +++ b/frontend/src/pages/ModelsPage/index.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import CircularProgress from "@mui/material/CircularProgress"; import { UnauthorizedError } from "../../api/client"; -import { type Print, printsApi } from "../../api/prints"; +import { type Print, type PrintSortMode, printsApi } from "../../api/prints"; import { type Folder, type FolderMetaInput, foldersApi } from "../../api/folders"; import { type PreviewMode } from "../../api/settings"; import { type ResolvedTheme } from "../../constants/settingsOptions"; @@ -13,6 +14,7 @@ import { usePageHeader } from "../../components/Layout/PageHeaderContext"; import { useInfiniteScroll } from "../../hooks/useInfiniteScroll"; import CategoriesPanel from "./CategoriesPanel"; import ModelCard from "./ModelCard"; +import SortTabs from "./SortTabs"; const PAGE_SIZE = 24; @@ -39,6 +41,18 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o const [loadingMore, setLoadingMore] = useState(false); const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + const sortModeParam = searchParams.get("orderBy"); + const sortMode: PrintSortMode = sortModeParam === "popular" || sortModeParam === "downloads" ? sortModeParam : "newest"; + + const setSortMode = (mode: PrintSortMode) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev); + if (mode === "newest") next.delete("orderBy"); + else next.set("orderBy", mode); + return next; + }); + }; // A root category has no models of its own directly in the tree UI, so selecting one should // pull in every model filed under any of its subcategories (plus the root itself, since prints @@ -88,7 +102,7 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o setLoading(true); (async () => { try { - const result = await printsApi.list({ folder_id: folderIdFilter, limit: PAGE_SIZE, offset: 0 }); + const result = await printsApi.list({ folder_id: folderIdFilter, order_by: sortMode, limit: PAGE_SIZE, offset: 0 }); setItems(result.items); setOffset(result.items.length); setHasMore(result.hasMore); @@ -99,13 +113,13 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o } })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [folderIdFilter, printsVersion]); + }, [folderIdFilter, printsVersion, sortMode]); const loadMore = async () => { if (loadingMore || !hasMore) return; setLoadingMore(true); try { - const result = await printsApi.list({ folder_id: folderIdFilter, limit: PAGE_SIZE, offset }); + const result = await printsApi.list({ folder_id: folderIdFilter, order_by: sortMode, limit: PAGE_SIZE, offset }); setItems(prev => [...prev, ...result.items]); setOffset(offset + result.items.length); setHasMore(result.hasMore); @@ -184,6 +198,9 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o onUpdateMeta={updateCategoryMeta} /> + + + {loading ? ( @@ -198,6 +215,7 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o theme={theme} 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)))} onUnauthorized={onUnauthorized} /> ))}