mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat(search): add option to see all results
This commit is contained in:
1 parent
d0a167461f
commit
ad27e950a9
11 files changed
+300
-28
No files matched your search
@@ -32,6 +32,7 @@ import { notifyUpload } from "../services/modelNotifications";
|
||||
import { fillSourceGaps } from "../services/fillGapsService";
|
||||
import { forgetAiDescription } from "../services/aiDescriptionService";
|
||||
import { isNormalizable3mf, normalize3mfStatus, normalized3mfFor } from "../services/normalized3mfCache";
|
||||
import { rankedPrintIds } from "../services/searchService";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
const router = Router();
|
||||
@@ -59,7 +60,8 @@ function isRenderableUpload(f: Express.Multer.File): boolean {
|
||||
return RENDERABLE_MODEL_EXTS.has(path.extname(f.originalname).toLowerCase());
|
||||
}
|
||||
|
||||
async function buildPrintWhere(req: Request): Promise<Prisma.PrintWhereInput> {
|
||||
/** `rankedIds` is set when `q` is: the matches, best first, for orderBy=relevance. */
|
||||
async function buildPrintWhere(req: Request): Promise<{ where: Prisma.PrintWhereInput; rankedIds: string[] | null }> {
|
||||
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||
const categoryIdParam = typeof req.query.category_id === "string" ? req.query.category_id : "";
|
||||
const categoryIds = categoryIdParam
|
||||
@@ -92,18 +94,11 @@ async function buildPrintWhere(req: Request): Promise<Prisma.PrintWhereInput> {
|
||||
else if (systemKey === "history") where.lastViewedAt = { not: null };
|
||||
else where.collectionItems = { some: { collectionId } };
|
||||
}
|
||||
if (q) {
|
||||
andClauses.push({
|
||||
OR: [
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ notes: { contains: q, mode: "insensitive" } },
|
||||
{ creator: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
// Matched as the search palette matches, so its "see all results" lists what it found.
|
||||
const rankedIds = q ? await rankedPrintIds(req.userId!, q) : null;
|
||||
if (rankedIds) andClauses.push({ id: { in: rankedIds } });
|
||||
if (andClauses.length) where.AND = andClauses;
|
||||
return where;
|
||||
return { where, rankedIds };
|
||||
}
|
||||
|
||||
router.post(
|
||||
@@ -228,10 +223,10 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
type PrintSortMode = "newest" | "popular" | "downloads";
|
||||
type PrintSortMode = "newest" | "popular" | "downloads" | "relevance";
|
||||
|
||||
function parseSortMode(raw: unknown): PrintSortMode {
|
||||
if (raw === "popular" || raw === "downloads") return raw;
|
||||
if (raw === "popular" || raw === "downloads" || raw === "relevance") return raw;
|
||||
return "newest";
|
||||
}
|
||||
|
||||
@@ -251,7 +246,7 @@ router.get(
|
||||
if (!Number.isFinite(offset) || offset < 0) throw new HttpError(400, "Invalid offset");
|
||||
}
|
||||
|
||||
const where = await buildPrintWhere(req);
|
||||
const { where, rankedIds } = await buildPrintWhere(req);
|
||||
const tagList = parseTagList(req);
|
||||
const allMatching = await prisma.print.findMany({
|
||||
where,
|
||||
@@ -279,7 +274,10 @@ router.get(
|
||||
const recencyField = systemKey === "favorites" ? "favoritedAt" : systemKey === "history" ? "lastViewedAt" : null;
|
||||
const sortMode = parseSortMode(req.query.orderBy);
|
||||
|
||||
// Relevance needs a query to rank by; without one it falls back to newest.
|
||||
const rank = sortMode === "relevance" && rankedIds ? new Map(rankedIds.map((id, i) => [id, i])) : null;
|
||||
const sortValue = (p: (typeof prints)[number]): number => {
|
||||
if (rank) return -(rank.get(p.id) ?? rank.size);
|
||||
if (sortMode === "popular") return p.viewCount;
|
||||
if (sortMode === "downloads") return p.printCount;
|
||||
if (recencyField) return p[recencyField]?.getTime() ?? 0;
|
||||
@@ -370,7 +368,7 @@ router.post(
|
||||
router.get(
|
||||
"/tags",
|
||||
asyncHandler(async (req, res) => {
|
||||
const where = await buildPrintWhere(req);
|
||||
const { where } = await buildPrintWhere(req);
|
||||
const tagList = parseTagList(req);
|
||||
const allRows = await prisma.print.findMany({ where, select: { tags: true } });
|
||||
const rows = tagList.length ? allRows.filter((r) => matchesTagList(r.tags, tagList)) : allRows;
|
||||
|
||||
@@ -11,11 +11,13 @@ const TAG_RESULT_LIMIT = 5;
|
||||
|
||||
export type SearchResult = {
|
||||
models: PrintOut[];
|
||||
/** More models match than `models` holds, for a "see all results" link. */
|
||||
has_more_models: boolean;
|
||||
collections: { id: string; name: string; item_count: number }[];
|
||||
tags: { tag: string; count: number }[];
|
||||
};
|
||||
|
||||
const EMPTY_RESULT: SearchResult = { models: [], collections: [], tags: [] };
|
||||
const EMPTY_RESULT: SearchResult = { models: [], has_more_models: false, collections: [], tags: [] };
|
||||
|
||||
/** Every token becomes a prefix match ("prin:* & yod:*") so type-ahead works. Tokens are reduced
|
||||
* to letters/digits since to_tsquery throws on stray syntax. Null when nothing usable is left. */
|
||||
@@ -34,17 +36,25 @@ function escapeLikeTerm(raw: string): string {
|
||||
return raw.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||
}
|
||||
|
||||
async function searchPrintIds(userId: string, tsQuery: string, limit: number): Promise<string[]> {
|
||||
/** Without a limit, every match. */
|
||||
async function searchPrintIds(userId: string, tsQuery: string, limit?: number): Promise<string[]> {
|
||||
const rows = await prisma.$queryRaw<{ id: string }[]>`
|
||||
SELECT "id"
|
||||
FROM "Print"
|
||||
WHERE "userId" = ${userId} AND "searchVector" @@ to_tsquery('simple'::regconfig, ${tsQuery})
|
||||
ORDER BY ts_rank("searchVector", to_tsquery('simple'::regconfig, ${tsQuery})) DESC, "name" ASC
|
||||
LIMIT ${limit}
|
||||
LIMIT ${limit ?? null}
|
||||
`;
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
/** Ids of every model matching `rawQuery` as the search palette matches it, best first; for
|
||||
* GET /prints?q=, so a "see all results" page lists what the palette found. */
|
||||
export async function rankedPrintIds(userId: string, rawQuery: string): Promise<string[]> {
|
||||
const tsQuery = buildPrefixTsQuery(rawQuery);
|
||||
return tsQuery ? searchPrintIds(userId, tsQuery) : [];
|
||||
}
|
||||
|
||||
async function searchCollections(
|
||||
userId: string,
|
||||
tsQuery: string,
|
||||
@@ -84,15 +94,18 @@ export async function search(userId: string, rawQuery: string, limit?: number):
|
||||
const tsQuery = buildPrefixTsQuery(rawQuery);
|
||||
if (!tsQuery) return EMPTY_RESULT;
|
||||
|
||||
const [modelIds, collections, tags] = await Promise.all([
|
||||
searchPrintIds(userId, tsQuery, limit ?? MODEL_RESULT_LIMIT),
|
||||
const modelLimit = limit ?? MODEL_RESULT_LIMIT;
|
||||
const [modelIdsAndOne, collections, tags] = await Promise.all([
|
||||
// One extra says whether there are more.
|
||||
searchPrintIds(userId, tsQuery, modelLimit + 1),
|
||||
searchCollections(userId, tsQuery, limit ?? COLLECTION_RESULT_LIMIT),
|
||||
searchTags(userId, rawQuery.trim(), TAG_RESULT_LIMIT),
|
||||
]);
|
||||
|
||||
const modelIds = modelIdsAndOne.slice(0, modelLimit);
|
||||
const printOuts = await printOutsByIds(userId, modelIds);
|
||||
// Re-apply rank order; printOutsByIds returns an unordered Map.
|
||||
const models = modelIds.map((id) => printOuts.get(id)).filter((p): p is PrintOut => Boolean(p));
|
||||
|
||||
return { models, collections, tags };
|
||||
return { models, has_more_models: modelIdsAndOne.length > modelLimit, collections, tags };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const stamp = Date.now();
|
||||
const word = `zorblax${stamp}`;
|
||||
let session: string;
|
||||
let apiToken: string;
|
||||
let userId: string;
|
||||
|
||||
function auth(t: string) {
|
||||
return { Authorization: `Bearer ${t}` };
|
||||
@@ -22,7 +23,7 @@ beforeAll(async () => {
|
||||
throw new Error(`Failed to register during test setup: ${registered.status} ${JSON.stringify(registered.body)}`);
|
||||
}
|
||||
session = registered.body.token;
|
||||
const userId = registered.body.user.id;
|
||||
userId = registered.body.user.id;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const name = `${word} model ${i}`;
|
||||
await prisma.print.create({ data: { userId, name, nameNormalized: name.toLowerCase() } });
|
||||
@@ -35,6 +36,7 @@ describe("GET /search", () => {
|
||||
const res = await request(app).get("/api/search").query({ q: word }).set(auth(session));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.models).toHaveLength(6);
|
||||
expect(res.body.has_more_models).toBe(true);
|
||||
});
|
||||
|
||||
it("returns more with a limit, for clients that show more", async () => {
|
||||
@@ -45,6 +47,7 @@ describe("GET /search", () => {
|
||||
it("caps the limit", async () => {
|
||||
const res = await request(app).get("/api/search").query({ q: word, limit: 1000 }).set(auth(session));
|
||||
expect(res.body.models).toHaveLength(9);
|
||||
expect(res.body.has_more_models).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a limit that isn't a number", async () => {
|
||||
@@ -58,3 +61,57 @@ describe("GET /search", () => {
|
||||
expect(res.body.models[0].name).toContain(word);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /prints?q= -- the palette\'s "see all results"', () => {
|
||||
const tagWord = `quibble${stamp}`;
|
||||
let nameMatchId: string;
|
||||
let tagMatchId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// The name match is older, so only relevance puts it first.
|
||||
const nameMatch = await prisma.print.create({
|
||||
data: {
|
||||
userId,
|
||||
name: `${tagWord} holder`,
|
||||
nameNormalized: `${tagWord} holder`,
|
||||
createdAt: new Date("2020-01-01T00:00:00Z"),
|
||||
},
|
||||
});
|
||||
const tagMatch = await prisma.print.create({
|
||||
data: { userId, name: "Plain stand", nameNormalized: "plain stand", tags: [tagWord] },
|
||||
});
|
||||
nameMatchId = nameMatch.id;
|
||||
tagMatchId = tagMatch.id;
|
||||
});
|
||||
|
||||
it("finds what the palette finds, tags included", async () => {
|
||||
const res = await request(app).get("/api/prints").query({ q: tagWord }).set(auth(session));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.map((p: { id: string }) => p.id).toSorted()).toEqual([nameMatchId, tagMatchId].toSorted());
|
||||
});
|
||||
|
||||
it("matches word prefixes", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/prints")
|
||||
.query({ q: tagWord.slice(0, -2) })
|
||||
.set(auth(session));
|
||||
expect(res.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("orders by relevance when asked: a name match before a tag match", async () => {
|
||||
const res = await request(app).get("/api/prints").query({ q: tagWord, orderBy: "relevance" }).set(auth(session));
|
||||
expect(res.body.map((p: { id: string }) => p.id)).toEqual([nameMatchId, tagMatchId]);
|
||||
});
|
||||
|
||||
it("pages through all of them", async () => {
|
||||
const first = await request(app).get("/api/prints").query({ q: word, limit: 5, offset: 0 }).set(auth(session));
|
||||
expect(first.body).toHaveLength(5);
|
||||
expect(first.headers["x-has-more"]).toBe("true");
|
||||
expect(first.headers["x-total-count"]).toBe("9");
|
||||
});
|
||||
|
||||
it("matches nothing when the query has no letters or digits", async () => {
|
||||
const res = await request(app).get("/api/prints").query({ q: "%%" }).set(auth(session));
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import CollectionsPage from "./pages/CollectionsPage";
|
||||
import CollectionDetailPage from "./pages/CollectionDetailPage";
|
||||
import TagsPage from "./pages/TagsPage";
|
||||
import TagDetailPage from "./pages/TagDetailPage";
|
||||
import SearchResultsPage from "./pages/SearchResultsPage";
|
||||
import AuthorPage from "./pages/AuthorPage";
|
||||
import AuthPage from "./pages/AuthPage";
|
||||
import VerifyEmailPage from "./pages/VerifyEmailPage";
|
||||
@@ -181,6 +182,17 @@ function AppShell({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/search-results"
|
||||
element={
|
||||
<SearchResultsPage
|
||||
theme={resolvedTheme}
|
||||
previewMode={previewMode}
|
||||
onUnauthorized={onUnauthorized}
|
||||
viewer={user}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/models/:printId"
|
||||
element={
|
||||
|
||||
@@ -122,7 +122,8 @@ export type FillGapsResult = {
|
||||
remaining: SourceGap[];
|
||||
};
|
||||
|
||||
export type PrintSortMode = "newest" | "popular" | "downloads";
|
||||
/** "relevance" ranks by how well a model matches `q`, as the search palette does. */
|
||||
export type PrintSortMode = "newest" | "popular" | "downloads" | "relevance";
|
||||
|
||||
export class PrintApiError extends Error {
|
||||
constructor(
|
||||
|
||||
@@ -16,6 +16,8 @@ export type SearchTagResult = {
|
||||
export type SearchResult = {
|
||||
/** Pre-ranked by the backend. */
|
||||
models: Print[];
|
||||
/** More models match than `models` holds. */
|
||||
has_more_models: boolean;
|
||||
collections: SearchCollectionResult[];
|
||||
tags: SearchTagResult[];
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import SpaceDashboardIcon from "@mui/icons-material/SpaceDashboard";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import PersonIcon from "@mui/icons-material/Person";
|
||||
import WebAssetIcon from "@mui/icons-material/WebAsset";
|
||||
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||
import { UnauthorizedError } from "../../../api/client";
|
||||
import { type SearchResult, searchApi } from "../../../api/search";
|
||||
import { printsApi } from "../../../api/prints";
|
||||
@@ -52,6 +53,8 @@ const MAX_MATCHED_COMMANDS = 3;
|
||||
const LIST_ID = "global-search-list";
|
||||
const BAR_RADIUS = 18;
|
||||
const PANEL_RADIUS = 14;
|
||||
/** Where "see all results" leads; it reads `?q=` itself. */
|
||||
const RESULTS_PATH = "/search-results";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
@@ -162,7 +165,9 @@ export default function GlobalSearch({
|
||||
|
||||
// `?q=` on any page opens the search with that query, for links from elsewhere (a dashboard's
|
||||
// "More in Thingport"). The parameter is dropped straight away so a reload doesn't reopen it.
|
||||
// The results page keeps it: there it's the page's own query.
|
||||
useEffect(() => {
|
||||
if (location.pathname === RESULTS_PATH) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const linked = params.get("q");
|
||||
if (linked === null) return;
|
||||
@@ -464,6 +469,20 @@ export default function GlobalSearch({
|
||||
kindLabel("tag"),
|
||||
),
|
||||
);
|
||||
// Kept out of recents: it's the query, not a place.
|
||||
if (result.has_more_models) {
|
||||
models.push({
|
||||
key: "more:models",
|
||||
title: t("app:search.seeAllModels", { query: trimmedQuery }),
|
||||
accessory: "",
|
||||
icon: <ArrowForwardIcon />,
|
||||
actionLabel: t("app:search.open"),
|
||||
run: () => {
|
||||
finish();
|
||||
navigate(`${RESULTS_PATH}?${new URLSearchParams({ q: trimmedQuery })}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (models.length) sections.push({ id: "models", label: t("app:search.sectionModels"), items: models });
|
||||
if (collections.length) {
|
||||
sections.push({ id: "collections", label: t("app:search.sectionCollections"), items: collections });
|
||||
|
||||
@@ -271,6 +271,9 @@
|
||||
"failed": "Search failed. Please try again.",
|
||||
"noResults": "No results for “{{query}}”.",
|
||||
"sectionModels": "Models",
|
||||
"seeAllModels": "See all models matching “{{query}}”",
|
||||
"resultsTitle": "“{{query}}”",
|
||||
"resultsSubtitle": "Search results",
|
||||
"sectionCollections": "Collections",
|
||||
"sectionTags": "Tags",
|
||||
"modelCount_one": "{{count}} model",
|
||||
@@ -1767,7 +1770,8 @@
|
||||
"sort": {
|
||||
"newest": "Newest",
|
||||
"popular": "Popular",
|
||||
"downloads": "Downloads"
|
||||
"downloads": "Downloads",
|
||||
"relevance": "Relevance"
|
||||
},
|
||||
"card": {
|
||||
"unknownAuthor": "Unknown",
|
||||
|
||||
@@ -271,6 +271,9 @@
|
||||
"failed": "Paieška nepavyko. Bandykite dar kartą.",
|
||||
"noResults": "Nerasta rezultatų pagal „{{query}}“.",
|
||||
"sectionModels": "Modeliai",
|
||||
"seeAllModels": "Visi modeliai pagal „{{query}}“",
|
||||
"resultsTitle": "„{{query}}“",
|
||||
"resultsSubtitle": "Paieškos rezultatai",
|
||||
"sectionCollections": "Kolekcijos",
|
||||
"sectionTags": "Žymos",
|
||||
"modelCount_one": "{{count}} modelis",
|
||||
@@ -1800,7 +1803,8 @@
|
||||
"sort": {
|
||||
"newest": "Naujausi",
|
||||
"popular": "Populiariausi",
|
||||
"downloads": "Atsisiuntimai"
|
||||
"downloads": "Atsisiuntimai",
|
||||
"relevance": "Tinkamiausi"
|
||||
},
|
||||
"card": {
|
||||
"unknownAuthor": "Nežinomas",
|
||||
|
||||
@@ -7,15 +7,17 @@ const SORT_MODES: PrintSortMode[] = ["newest", "popular", "downloads"];
|
||||
|
||||
type Props = {
|
||||
value: PrintSortMode;
|
||||
/** Defaults to newest, popular, downloads. */
|
||||
modes?: PrintSortMode[];
|
||||
onChange: (mode: PrintSortMode) => void;
|
||||
};
|
||||
|
||||
/** "Popular" sorts by views, "Downloads" by print count. */
|
||||
export default function SortTabs({ value, onChange }: Props) {
|
||||
export default function SortTabs({ value, modes = SORT_MODES, onChange }: Props) {
|
||||
const { t } = useTranslation("models");
|
||||
return (
|
||||
<Stack direction="row" spacing={3}>
|
||||
{SORT_MODES.map((mode) => (
|
||||
{modes.map((mode) => (
|
||||
<Typography
|
||||
key={mode}
|
||||
variant="body2"
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, 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 { UnauthorizedError } from "../../api/client";
|
||||
import { type Print, type PrintSortMode, printsApi } from "../../api/prints";
|
||||
import { type PreviewMode } from "../../api/settings";
|
||||
import type { AuthUser } from "../../api/auth";
|
||||
import { type ResolvedTheme } from "../../constants/settingsOptions";
|
||||
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
|
||||
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
|
||||
import ModelCard from "../ModelsPage/ModelCard";
|
||||
import SortTabs from "../ModelsPage/SortTabs";
|
||||
import { CardSkeletons } from "../../components/media/CardSkeleton";
|
||||
import { cardGridSx } from "../../components/media/cardGrid";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
const SORT_MODES: PrintSortMode[] = ["relevance", "newest", "popular", "downloads"];
|
||||
|
||||
type Props = {
|
||||
theme: ResolvedTheme;
|
||||
previewMode: PreviewMode;
|
||||
onUnauthorized?: () => void;
|
||||
viewer?: AuthUser | null;
|
||||
};
|
||||
|
||||
/** Every model matching `?q=`, where the search palette's "see all results" leads. GlobalSearch
|
||||
* leaves `?q=` alone on this page. */
|
||||
export default function SearchResultsPage({ theme, previewMode, onUnauthorized, viewer }: Props) {
|
||||
const { t } = useTranslation(["models", "app"]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const query = (searchParams.get("q") ?? "").trim();
|
||||
const [items, setItems] = useState<Print[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const sortModeParam = searchParams.get("orderBy");
|
||||
const sortMode: PrintSortMode =
|
||||
sortModeParam === "newest" || sortModeParam === "popular" || sortModeParam === "downloads"
|
||||
? sortModeParam
|
||||
: "relevance";
|
||||
|
||||
const setSortMode = (mode: PrintSortMode) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (mode === "relevance") next.delete("orderBy");
|
||||
else next.set("orderBy", mode);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
usePageHeader({
|
||||
title: query ? t("app:search.resultsTitle", { query }) : undefined,
|
||||
subtitle: query ? t("app:search.resultsSubtitle") : undefined,
|
||||
});
|
||||
|
||||
const handleError = (err: unknown, message?: string) => {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return true;
|
||||
}
|
||||
console.error(err);
|
||||
if (message) alert(message);
|
||||
return false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
setItems([]);
|
||||
setHasMore(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const result = await printsApi.list({ q: query, order_by: sortMode, limit: PAGE_SIZE, offset: 0 });
|
||||
if (cancelled) return;
|
||||
setItems(result.items);
|
||||
setOffset(result.items.length);
|
||||
setHasMore(result.hasMore);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
handleError(err, t("models:errors.loadFailed"));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, sortMode]);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore || !hasMore || !query) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await printsApi.list({ q: query, order_by: sortMode, limit: PAGE_SIZE, offset });
|
||||
setItems((prev) => [...prev, ...result.items]);
|
||||
setOffset(offset + result.items.length);
|
||||
setHasMore(result.hasMore);
|
||||
} catch (err) {
|
||||
handleError(err, t("models:errors.loadFailed"));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreSentinelRef = useInfiniteScroll(loadMore, hasMore, loading || loadingMore);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ maxWidth: "1920px", mx: "auto", pt: 7 }}>
|
||||
<Box sx={cardGridSx(6)}>
|
||||
<CardSkeletons count={12} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} sx={{ maxWidth: "1920px", mx: "auto" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<SortTabs value={sortMode} modes={SORT_MODES} onChange={setSortMode} />
|
||||
</Box>
|
||||
{items.length ? (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={cardGridSx(6)}>
|
||||
{items.map((item) => (
|
||||
<ModelCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
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)))}
|
||||
onUpdated={(updated) => setItems((prev) => prev.map((i) => (i.id === updated.id ? updated : i)))}
|
||||
onUnauthorized={onUnauthorized}
|
||||
viewer={viewer}
|
||||
/>
|
||||
))}
|
||||
{loadingMore && <CardSkeletons count={6} />}
|
||||
</Box>
|
||||
{hasMore && <Box ref={loadMoreSentinelRef} sx={{ height: 1 }} />}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
|
||||
<Typography variant="body2">
|
||||
{query ? t("app:search.noResults", { query }) : t("app:search.keepTyping")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user