feat: add sorting and ect

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-08 23:03:22 +02:00
1 parent ead97510d4
commit 312c6be706
19 files changed
+526 -57

No files matched your search

+34 -4
View File
@@ -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),
})),
});
}),
);
+20 -13
View File
@@ -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);
});
+14
View File
@@ -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<Set<string>> {
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;
+109
View File
@@ -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));
});
});
+32 -1
View File
@@ -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<Response>>(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 },
]);
});
+32 -2
View File
@@ -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<Response>>(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 },
]);
});
+3 -1
View File
@@ -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<Print> => {
const res = await fetch(`${apiBase()}/import`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
+4
View File
@@ -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<ListPrintsResult> => {
@@ -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()}`, {
+6 -3
View File
@@ -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
<ThemeProvider theme={muiTheme}>
<CssBaseline />
<ConfirmProvider>
<NotificationsProvider onUnauthorized={onUnauthorized}>
<AppLayoutShell onUnauthorized={onUnauthorized} {...shellProps} />
</NotificationsProvider>
<ToastProvider>
<NotificationsProvider onUnauthorized={onUnauthorized}>
<AppLayoutShell onUnauthorized={onUnauthorized} {...shellProps} />
</NotificationsProvider>
</ToastProvider>
</ConfirmProvider>
</ThemeProvider>
);
+69
View File
@@ -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<QueuedToast[]>([]);
const [current, setCurrent] = React.useState<QueuedToast | null>(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 (
<ToastContext.Provider value={showToast}>
{children}
<Snackbar
key={current?.key}
open={open}
autoHideDuration={4000}
onClose={handleClose}
slotProps={{ transition: { onExited: () => setCurrent(null) } }}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert onClose={() => setOpen(false)} severity={current?.severity ?? "success"} variant="filled" sx={{ width: "100%" }}>
{current?.message}
</Alert>
</Snackbar>
</ToastContext.Provider>
);
}
/** 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;
}
@@ -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 (
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1, maxHeight: 420, overflow: "auto" }}>
{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={
<Checkbox
checked={selected.has(entry.design_id)}
onChange={() => 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
<Typography variant="body2" noWrap sx={{ flex: 1, minWidth: 0 }}>
{entry.title}
</Typography>
{entry.already_imported && (
<Chip label={t("collectionImport.alreadyImported")} size="small" variant="outlined" sx={{ flexShrink: 0 }} />
)}
</Stack>
}
/>
@@ -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 ? (
<CollectionImportModalView
@@ -9,6 +9,7 @@ import { useZipImportPrompt } from "./ZipImportModal";
import { useCollectionImportPrompt } from "./CollectionImportModal";
import { useImportModePrompt, type ImportMode } from "./ImportModeModal";
import { useImportJob } from "../Layout/ImportJobContext";
import { useToast } from "../ToastProvider";
/** MakerWorld collection URLs (`/en/collections/{id}-{slug}`) list many models rather than
* being one model page -- route those to the collection picker instead of the single-link
@@ -87,6 +88,7 @@ type Props = {
* elsewhere in the app, so `modals` must be rendered by the caller alongside the menu. */
export function useUploadImport({ onUploaded, folderId, makerworldCookie, onUnauthorized }: Props) {
const { t } = useTranslation("app");
const showToast = useToast();
const inputRef = useRef<HTMLInputElement | null>(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?.();
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
@@ -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 && (
<Typography variant="body2" color="text.secondary">{collection.description}</Typography>
)}
<SortTabs value={sortMode} onChange={setSortMode} />
{items.length ? (
<Stack spacing={2}>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", columnGap: "20px", rowGap: "20px" }}>
@@ -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}
/>
))}
+65 -12
View File
@@ -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 (
<Paper
variant="outlined"
@@ -80,8 +118,10 @@ export default function ModelCard({ item, theme, previewMode, onDeleted, onUnaut
</Box>
)}
<Box
<Stack
className="model-card-actions"
direction="row"
spacing={0.5}
onClick={e => 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%",
}}
>
<ModelActionsMenu
print={item}
onUnauthorized={onUnauthorized}
onDeleted={() => onDeleted?.(item.id)}
triggerSx={{ color: "#fff" }}
/>
</Box>
<Tooltip title={favoriteLabel}>
<Box sx={hoverActionBg}>
<IconButton size="small" onClick={toggleFavorite} disabled={favoriteBusy} aria-label={favoriteLabel ?? undefined}>
{favoriteBusy ? (
<CircularProgress size={16} sx={{ color: "#fff" }} />
) : item.is_favorite ? (
<FavoriteIcon fontSize="small" color="error" />
) : (
<FavoriteBorderIcon fontSize="small" sx={{ color: "#fff" }} />
)}
</IconButton>
</Box>
</Tooltip>
<Box sx={hoverActionBg}>
<ModelActionsMenu
print={item}
onUnauthorized={onUnauthorized}
onDeleted={() => onDeleted?.(item.id)}
triggerSx={{ color: "#fff" }}
/>
</Box>
</Stack>
<Box sx={{ p: 1.5 }}>
<Typography variant="body2" fontWeight={600} noWrap title={item.title || item.name}>
{item.title || item.name}
@@ -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 (
<Stack direction="row" spacing={3}>
{SORT_MODES.map(mode => (
<Typography
key={mode}
variant="body2"
onClick={() => onChange(mode)}
sx={{
cursor: "pointer",
fontWeight: value === mode ? 700 : 400,
color: value === mode ? "primary.main" : "text.secondary",
"&:hover": { color: "primary.main" },
}}
>
{t(`sort.${mode}`)}
</Typography>
))}
</Stack>
);
}
+22 -4
View File
@@ -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}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ mb: 2 }}>
<SortTabs value={sortMode} onChange={setSortMode} />
</Box>
{loading ? (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
@@ -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}
/>
))}