mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: add sorting and ect
This commit is contained in:
1 parent
ead97510d4
commit
312c6be706
19 files changed
+526
-57
No files matched your search
@@ -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),
|
||||
})),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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()}`, {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user