feat(search): add option to see all results

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-14 22:14:55 +02:00
1 parent d0a167461f
commit ad27e950a9
11 files changed
+300 -28

No files matched your search

+14 -16
View File
@@ -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;
+19 -6
View File
@@ -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 };
}
+58 -1
View File
@@ -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([]);
});
});
+12
View File
@@ -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={
+2 -1
View File
@@ -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(
+2
View File
@@ -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 });
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+4 -2
View File
@@ -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>
);
}