feat: add first version of search

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-19 19:02:57 +02:00
1 parent d3c651c05d
commit 83e0bebf45
12 files changed
+537 -6

No files matched your search

@@ -36,6 +36,16 @@ ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_collectionId_fkey" FOREIGN KEY (
-- sortedBookmarks) -- so this backfill is a no-op for anyone who hasn't dragged anything yet. The
-- id is just a random-enough unique string (this table has no other rows yet, so collisions are
-- effectively impossible); it doesn't need to be a real cuid.
--
-- WHERE + ON CONFLICT guard against data the app itself was never meant to produce but can't
-- fully rule out on a real production array that's been read-modify-written outside a
-- transaction since whenever it was first introduced: a stray NULL element (arrays can hold one
-- even though the app only ever pushes real strings), or the same tag appearing twice (the old
-- POST /tags/:tag/bookmark read-then-wrote the array with no row lock, so two near-simultaneous
-- bookmark clicks on the same tag could in principle both pass its own "already includes" check
-- before either write lands). Either would violate the new (userId, type, tag) unique index and
-- abort this whole migration without them -- there's nothing to recover by treating that as a
-- hard failure, so this just keeps one row and moves on.
INSERT INTO "Bookmark" ("id", "userId", "type", "tag", "order", "createdAt")
SELECT
substr(md5(random()::text || clock_timestamp()::text || u."id" || t.tag), 1, 24),
@@ -45,7 +55,9 @@ SELECT
(row_number() OVER (PARTITION BY u."id" ORDER BY t.tag) - 1)::int,
now()
FROM "User" u
CROSS JOIN LATERAL unnest(u."bookmarkedTags") AS t(tag);
CROSS JOIN LATERAL unnest(u."bookmarkedTags") AS t(tag)
WHERE t.tag IS NOT NULL
ON CONFLICT ("userId", "type", "tag") DO NOTHING;
-- AlterTable
ALTER TABLE "User" DROP COLUMN "bookmarkedTags";
@@ -0,0 +1,47 @@
-- Global search: a generated tsvector column per searchable table, weighted so a name match
-- outranks a description/notes match, plus a GIN index on each -- see services/searchService.ts
-- and schema.prisma's Print.searchVector / Collection.searchVector comments. Neither the column
-- nor its index is declared in schema.prisma (both are `Unsupported` there), so this migration is
-- the only place either exists -- future schema changes to these tables won't touch it as long as
-- the generated columns themselves aren't dropped.
--
-- 'simple' text search config is used deliberately instead of 'english': model/collection names
-- and tags read more like short identifiers ("PLA+ Vase", "Baby Yoda") than prose, so English
-- stemming/stopword removal would only risk dropping or conflating tokens users actually typed --
-- predictable substring-ish matching (still with proper word-boundary tokenization and ranking)
-- fits this data better.
-- A GENERATED ALWAYS AS expression must be provably IMMUTABLE. Postgres's own array_to_string()
-- is only STABLE (conservatively, since it's polymorphic over anyarray), even though it's
-- perfectly deterministic for the concrete text[] case every caller below actually uses -- so
-- this is a thin IMMUTABLE wrapper purely to satisfy that check, not a behavior change. It has to
-- be a real, permanent function (not dropped after this migration): the generated columns below
-- call it on every future INSERT/UPDATE for as long as they exist.
CREATE OR REPLACE FUNCTION search_array_to_string(text[], text) RETURNS text AS $$
SELECT array_to_string($1, $2);
$$ LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE;
-- CreateColumn (Print)
-- The explicit ::regconfig cast on 'simple' matters too: Postgres's GENERATED ALWAYS AS validator
-- rejects to_tsvector(text, text) here as "not immutable" (the implicit text->regconfig cast it
-- would otherwise perform involves a catalog lookup) -- casting the literal ourselves resolves it
-- at parse time instead, which the immutability check accepts.
ALTER TABLE "Print" ADD COLUMN "searchVector" tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('simple'::regconfig, coalesce("name", '') || ' ' || coalesce("title", '')), 'A') ||
setweight(to_tsvector('simple'::regconfig, coalesce(search_array_to_string("tags", ' '), '')), 'B') ||
setweight(to_tsvector('simple'::regconfig, coalesce("notes", '')), 'C') ||
setweight(to_tsvector('simple'::regconfig, coalesce("creator", '')), 'D')
) STORED;
-- CreateIndex (Print)
CREATE INDEX "Print_searchVector_idx" ON "Print" USING GIN ("searchVector");
-- CreateColumn (Collection)
ALTER TABLE "Collection" ADD COLUMN "searchVector" tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('simple'::regconfig, coalesce("name", '')), 'A') ||
setweight(to_tsvector('simple'::regconfig, coalesce(search_array_to_string("tags", ' '), '')), 'B') ||
setweight(to_tsvector('simple'::regconfig, coalesce("description", '')), 'C')
) STORED;
-- CreateIndex (Collection)
CREATE INDEX "Collection_searchVector_idx" ON "Collection" USING GIN ("searchVector");
+10
View File
@@ -136,6 +136,13 @@ model Print {
collectionItems CollectionItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Generated column (name/title weight A, tags weight B, notes weight C, creator weight D) that
// backs the global search box -- see services/searchService.ts and the hand-written migration
// that actually creates it (`GENERATED ALWAYS AS (...) STORED`, plus its own GIN index).
// `Unsupported` because Prisma's schema DSL can't express a generated tsvector column; that
// also means `prisma migrate dev` won't try to diff/manage this field or its index itself, so
// there's deliberately no `@@index` line for it here -- both live only in that migration.
searchVector Unsupported("tsvector")?
@@unique([userId, categoryId, nameNormalized])
@@unique([userId, sourceProvider, sourceExternalId])
@@ -163,6 +170,9 @@ model Collection {
bookmarks Bookmark[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Same generated-tsvector search column as Print.searchVector above (name weight A, tags
// weight B, description weight C) -- see that field's comment and services/searchService.ts.
searchVector Unsupported("tsvector")?
@@unique([userId, nameNormalized])
@@index([tags], type: Gin)
+2
View File
@@ -16,6 +16,7 @@ import settingsRoutes from "./routes/settings";
import importsRoutes from "./routes/imports";
import collectionsRoutes from "./routes/collections";
import bookmarksRoutes from "./routes/bookmarks";
import searchRoutes from "./routes/search";
import notificationsRoutes from "./routes/notifications";
import adminRoutes from "./routes/admin";
import dashboardRoutes from "./routes/dashboard";
@@ -49,6 +50,7 @@ export function createApp(): Express {
app.use("/api", importsRoutes);
app.use("/api", collectionsRoutes);
app.use("/api", bookmarksRoutes);
app.use("/api", searchRoutes);
app.use("/api", notificationsRoutes);
app.use("/api", adminRoutes);
app.use("/api", dashboardRoutes);
+22
View File
@@ -0,0 +1,22 @@
import { Router } from "express";
import { requireAuth } from "../auth";
import { asyncHandler } from "../utils/asyncHandler";
import { search } from "../services/searchService";
const router = Router();
router.use(requireAuth);
// ---- GET /search?q=... -----------------------------------------------------------------------
// Backs the global search box in the top bar (models primary, collections + tags secondary --
// see searchService.ts). A blank/whitespace-only `q` just returns empty results rather than
// erroring -- the frontend already debounces and gates on a minimum length before calling this,
// but there's no reason to make that a hard requirement here too.
router.get(
"/search",
asyncHandler(async (req, res) => {
const q = typeof req.query.q === "string" ? req.query.q : "";
res.json(await search(req.userId!, q));
}),
);
export default router;
+121
View File
@@ -0,0 +1,121 @@
import { prisma } from "../db";
import { printOutsByIds } from "./printLoader";
import type { PrintOut } from "../dto";
// ---- Global search (models primary, collections + tags secondary) ----------------------------
//
// Backed by Print.searchVector / Collection.searchVector -- generated tsvector columns with
// name/title weighted 'A', tags 'B', description/notes 'C', creator 'D' (see the migration that
// creates them, 20260919164610_add_search_vectors, and their own comments in schema.prisma).
// ts_rank naturally scores a name match above a description match because of that weighting, so
// there's no scoring formula to hand-roll here for prints/collections -- Postgres does it.
// "Tag" results have no vector of their own (a tag is just a string inside Print.tags, not a row
// -- see routes/tags.ts's own comment on this), so those are matched with a plain ILIKE over the
// distinct tag values instead, ranked exact-match-first, then prefix-match, then by how many
// models carry it.
const MODEL_RESULT_LIMIT = 6;
const COLLECTION_RESULT_LIMIT = 4;
const TAG_RESULT_LIMIT = 5;
export type SearchResult = {
models: PrintOut[];
collections: { id: string; name: string; item_count: number }[];
tags: { tag: string; count: number }[];
};
const EMPTY_RESULT: SearchResult = { models: [], collections: [], tags: [] };
/** Turns free-typed search text into a tsquery string every token of which is a *prefix* match
* ("prin:* & yod:*") -- plain `websearch_to_tsquery` treats its input as a natural-language
* phrase and only ever matches whole tokens, which reads as broken in a type-ahead box (nothing
* matches until you finish typing a whole word). Each raw token is stripped down to letters/
* digits before being embedded in the query string -- to_tsquery's own syntax (`:`, `&`, `'`,
* parens, ...) would otherwise throw on stray punctuation instead of just ignoring it. Returns
* null for input with no usable tokens (blank, or punctuation-only) so callers can short-circuit
* without ever touching the database. */
function buildPrefixTsQuery(raw: string): string | null {
const tokens = raw
.split(/\s+/)
.map((token) => token.replace(/[^\p{L}\p{N}]+/gu, ""))
.filter(Boolean)
.slice(0, 8); // defensive cap -- nothing legitimate needs more than a handful of words
if (!tokens.length) return null;
return tokens.map((token) => `${token}:*`).join(" & ");
}
/** Escapes %, _, and \ so a raw search term used inside ILIKE '%...%' is matched literally --
* otherwise a term containing e.g. "50%" would have that '%' act as an ILIKE wildcard instead of
* a literal character. Paired with `ILIKE ... ESCAPE '\'` at the call site. */
function escapeLikeTerm(raw: string): string {
return raw.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
}
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}
`;
return rows.map((r) => r.id);
}
async function searchCollections(
userId: string,
tsQuery: string,
limit: number,
): Promise<{ id: string; name: string; item_count: number }[]> {
const rows = await prisma.$queryRaw<{ id: string; name: string; item_count: bigint }[]>`
SELECT c."id", c."name", count(ci."id")::bigint AS item_count
FROM "Collection" c
LEFT JOIN "CollectionItem" ci ON ci."collectionId" = c."id"
WHERE c."userId" = ${userId} AND c."searchVector" @@ to_tsquery('simple'::regconfig, ${tsQuery})
GROUP BY c."id", c."name", c."searchVector"
ORDER BY ts_rank(c."searchVector", to_tsquery('simple'::regconfig, ${tsQuery})) DESC, c."name" ASC
LIMIT ${limit}
`;
return rows.map((r) => ({ id: r.id, name: r.name, item_count: Number(r.item_count) }));
}
async function searchTags(userId: string, rawQuery: string, limit: number): Promise<{ tag: string; count: number }[]> {
const likePattern = `%${escapeLikeTerm(rawQuery)}%`;
const rows = await prisma.$queryRaw<{ tag: string; count: bigint }[]>`
SELECT tag, count(*)::bigint AS count
FROM "Print" p, unnest(p."tags") AS tag
WHERE p."userId" = ${userId} AND tag ILIKE ${likePattern} ESCAPE '\'
GROUP BY tag
ORDER BY
(lower(tag) = lower(${rawQuery})) DESC,
(lower(tag) LIKE lower(${rawQuery}) || '%') DESC,
count DESC,
tag ASC
LIMIT ${limit}
`;
return rows.map((r) => ({ tag: r.tag, count: Number(r.count) }));
}
/** The global search box's one query: models (primary -- full PrintOut, same shape ModelCard
* already renders elsewhere, via printOutsByIds), plus collections and tags carrying a lighter
* {id/name/count} shape of their own (no cover photos -- this is a compact dropdown, not a
* grid). All three run in parallel; each is independently capped and pre-sorted, so the caller
* can render them as-is. */
export async function search(userId: string, rawQuery: string): Promise<SearchResult> {
const tsQuery = buildPrefixTsQuery(rawQuery);
if (!tsQuery) return EMPTY_RESULT;
const [modelIds, collections, tags] = await Promise.all([
searchPrintIds(userId, tsQuery, MODEL_RESULT_LIMIT),
searchCollections(userId, tsQuery, COLLECTION_RESULT_LIMIT),
searchTags(userId, rawQuery.trim(), TAG_RESULT_LIMIT),
]);
const printOuts = await printOutsByIds(userId, modelIds);
// printOutsByIds returns a Map keyed by id with no guaranteed order -- re-apply the rank order
// searchPrintIds already computed, dropping any id it couldn't resolve to a full PrintOut
// (shouldn't normally happen; printOutsByIds only omits ids it can't find at all).
const models = modelIds.map((id) => printOuts.get(id)).filter((p): p is PrintOut => Boolean(p));
return { models, collections, tags };
}
+30
View File
@@ -0,0 +1,30 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
import type { Print } from "./prints";
export type SearchCollectionResult = {
id: string;
name: string;
item_count: number;
};
export type SearchTagResult = {
tag: string;
count: number;
};
export type SearchResult = {
/** Full Print objects (same shape ModelCard already renders), pre-ranked by the backend --
* name matches outrank description/tag matches, see backend's searchService.ts. */
models: Print[];
collections: SearchCollectionResult[];
tags: SearchTagResult[];
};
export const searchApi = {
search: async (q: string): Promise<SearchResult> => {
const res = await fetch(`${apiBase()}/search?q=${encodeURIComponent(q)}`, { headers: authHeaders() });
assertOk(res, "Search failed");
return res.json();
},
};
@@ -0,0 +1,239 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import InputBase from "@mui/material/InputBase";
import IconButton from "@mui/material/IconButton";
import Popover from "@mui/material/Popover";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemAvatar from "@mui/material/ListItemAvatar";
import ListItemText from "@mui/material/ListItemText";
import Avatar from "@mui/material/Avatar";
import Divider from "@mui/material/Divider";
import SearchIcon from "@mui/icons-material/Search";
import CloseIcon from "@mui/icons-material/Close";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import CollectionsIcon from "@mui/icons-material/Collections";
import LocalOfferIcon from "@mui/icons-material/LocalOffer";
import { UnauthorizedError } from "../../../api/client";
import { type SearchResult, searchApi } from "../../../api/search";
import { printsApi } from "../../../api/prints";
import { useDebouncedValue } from "../../../hooks/useDebouncedValue";
const MIN_QUERY_LENGTH = 2;
const DEBOUNCE_MS = 250;
const DROPDOWN_WIDTH = 420;
type Props = {
onUnauthorized?: () => void;
};
/** The global search box: models (primary), collections, and tags matching name/description --
* see backend's searchService.ts, which does the actual ranking (Postgres full-text search, name
* matches outrank description matches). Debounced-as-you-type, results in a dropdown below the
* box; picking one navigates straight there. Lives centered in TopBar, always mounted regardless
* of route (like AddMenu/NotificationBell/UserMenu next to it). */
export default function GlobalSearch({ onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const navigate = useNavigate();
const anchorRef = useRef<HTMLDivElement | null>(null);
const [query, setQuery] = useState("");
const [focused, setFocused] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<SearchResult | null>(null);
const [error, setError] = useState<string | null>(null);
const debouncedQuery = useDebouncedValue(query.trim(), DEBOUNCE_MS);
useEffect(() => {
if (debouncedQuery.length < MIN_QUERY_LENGTH) {
setResult(null);
setLoading(false);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
const data = await searchApi.search(debouncedQuery);
if (!cancelled) setResult(data);
} catch (err) {
if (cancelled) return;
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
setError(t("app:search.failed"));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQuery]);
const closeDropdown = () => setFocused(false);
const goTo = (path: string) => {
closeDropdown();
setQuery("");
navigate(path);
};
const trimmedQuery = query.trim();
const dropdownOpen = focused && trimmedQuery.length >= MIN_QUERY_LENGTH;
const hasResults = Boolean(result && (result.models.length || result.collections.length || result.tags.length));
return (
<Box ref={anchorRef} sx={{ position: "relative", width: "100%" }}>
<Paper
variant="outlined"
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 1.25,
py: 0.25,
borderRadius: 999,
bgcolor: (theme) => theme.thingport.pageBackground,
}}
>
<SearchIcon fontSize="small" sx={{ color: "text.disabled" }} />
<InputBase
fullWidth
size="small"
placeholder={t("app:search.placeholder") ?? undefined}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
(e.target as HTMLInputElement).blur();
closeDropdown();
}
}}
sx={{ fontSize: 14 }}
/>
{query && (
<IconButton size="small" onClick={() => setQuery("")} aria-label={t("app:search.clear") ?? undefined}>
<CloseIcon fontSize="inherit" />
</IconButton>
)}
</Paper>
<Popover
open={dropdownOpen}
anchorEl={anchorRef.current}
onClose={closeDropdown}
disableAutoFocus
disableEnforceFocus
disableRestoreFocus
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
slotProps={{ paper: { sx: { width: DROPDOWN_WIDTH, maxWidth: "90vw", mt: 0.5, maxHeight: 480, overflow: "auto" } } }}
>
{loading && (
<Stack alignItems="center" sx={{ py: 3 }}>
<CircularProgress size={20} />
</Stack>
)}
{!loading && error && (
<Typography variant="body2" color="error" sx={{ p: 2 }}>{error}</Typography>
)}
{!loading && !error && result && !hasResults && (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
{t("app:search.noResults", { query: trimmedQuery })}
</Typography>
)}
{!loading && !error && result && hasResults && (
<Stack divider={<Divider />}>
{result.models.length > 0 && (
<Box sx={{ py: 0.5 }}>
<Typography variant="caption" fontWeight={700} sx={{ display: "block", px: 2, pt: 1, pb: 0.5, color: "text.secondary" }}>
{t("app:search.sectionModels")}
</Typography>
<List disablePadding>
{result.models.map((print) => (
<ListItemButton key={print.id} onClick={() => goTo(`/models/${print.id}`)}>
<ListItemAvatar sx={{ minWidth: 44 }}>
<Avatar
variant="rounded"
src={print.thumb_url ? printsApi.fileUrl(print.thumb_url) : undefined}
sx={{ width: 36, height: 36, bgcolor: "action.hover" }}
>
<ViewInArIcon fontSize="small" sx={{ color: "text.disabled" }} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={print.title || print.name}
primaryTypographyProps={{ noWrap: true }}
secondary={print.category_name || undefined}
secondaryTypographyProps={{ noWrap: true }}
/>
</ListItemButton>
))}
</List>
</Box>
)}
{result.collections.length > 0 && (
<Box sx={{ py: 0.5 }}>
<Typography variant="caption" fontWeight={700} sx={{ display: "block", px: 2, pt: 1, pb: 0.5, color: "text.secondary" }}>
{t("app:search.sectionCollections")}
</Typography>
<List disablePadding>
{result.collections.map((collection) => (
<ListItemButton key={collection.id} onClick={() => goTo(`/models/collections/${collection.id}`)}>
<ListItemAvatar sx={{ minWidth: 44 }}>
<Avatar variant="rounded" sx={{ width: 36, height: 36, bgcolor: "action.hover" }}>
<CollectionsIcon fontSize="small" sx={{ color: "text.disabled" }} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={collection.name}
primaryTypographyProps={{ noWrap: true }}
secondary={t("app:search.modelCount", { count: collection.item_count })}
/>
</ListItemButton>
))}
</List>
</Box>
)}
{result.tags.length > 0 && (
<Box sx={{ py: 0.5 }}>
<Typography variant="caption" fontWeight={700} sx={{ display: "block", px: 2, pt: 1, pb: 0.5, color: "text.secondary" }}>
{t("app:search.sectionTags")}
</Typography>
<List disablePadding>
{result.tags.map((tagResult) => (
<ListItemButton
key={tagResult.tag}
onClick={() => goTo(`/models/tags/${encodeURIComponent(tagResult.tag)}`)}
>
<ListItemAvatar sx={{ minWidth: 44 }}>
<Avatar variant="rounded" sx={{ width: 36, height: 36, bgcolor: "action.hover" }}>
<LocalOfferIcon fontSize="small" sx={{ color: "text.disabled" }} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={tagResult.tag}
primaryTypographyProps={{ noWrap: true }}
secondary={t("app:search.modelCount", { count: tagResult.count })}
/>
</ListItemButton>
))}
</List>
</Box>
)}
</Stack>
)}
</Popover>
</Box>
);
}
+16 -5
View File
@@ -11,6 +11,7 @@ import type { ThemeSelection } from "../../constants/settingsOptions";
import AddMenu from "./AddMenu";
import NotificationBell from "./NotificationBell";
import { UserMenu } from "./UserMenu";
import GlobalSearch from "./GlobalSearch";
type Props = {
title: string;
@@ -31,7 +32,11 @@ type Props = {
};
/** The persistent header row above the active view's content: an optional back button + title
* (+ page actions) on the left, and the global Add/Notifications/User cluster on the right. */
* (+ page actions) on the left, the global search box truly centered in the middle (a CSS grid
* with two equal `1fr` side columns, not just "whatever's left between the other two" -- the
* left/right clusters are very different widths depending on the page, and only a grid keeps the
* middle column centered on the bar as a whole regardless), and the global Add/Notifications/User
* cluster on the right. */
export default function TopBar({
title,
onBack,
@@ -67,11 +72,10 @@ export default function TopBar({
<Box
ref={rootRef}
sx={{
display: "flex",
justifyContent: "space-between",
display: "grid",
gridTemplateColumns: "1fr minmax(0, 480px) 1fr",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
// Both of these used to live on `main` (pt) / as this bar's own margin (mb) -- moved to
// padding on this box itself so they're part of what's actually pinned. As margin/an
// ancestor's padding, that space isn't covered by this bar's own background, so it either
@@ -92,6 +96,10 @@ export default function TopBar({
bgcolor: (muiTheme) => muiTheme.thingport.pageBackground,
}}
>
{/* minWidth: 0 overrides the grid item default of `min-width: auto` -- without it, this
column refuses to shrink below its content's natural width (the title, mainly), which
would push the center/right columns off `justify-content` center/right well before the
window actually runs out of room. */}
<Stack direction="row" alignItems="center" spacing={1} minWidth={0}>
{onBack && (
<Tooltip title={t("shell.backToLibrary")}>
@@ -103,7 +111,10 @@ export default function TopBar({
<Typography variant="h6" fontWeight={700} noWrap>{title}</Typography>
{actions}
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
<Box sx={{ minWidth: 0, justifySelf: "center", width: "100%" }}>
<GlobalSearch onUnauthorized={onUnauthorized} />
</Box>
<Stack direction="row" alignItems="center" spacing={1} minWidth={0} justifySelf="end">
<AddMenu
categoryId={categoryId}
makerworldCookie={makerworldCookie}
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
/** Returns `value`, but only after it's stayed unchanged for `delayMs` -- e.g. a search box's
* live query text, so the query effect that actually fetches only fires once typing pauses
* instead of on every keystroke. */
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
+11
View File
@@ -192,6 +192,17 @@
"daysAgo_one": "{{count}} day ago",
"daysAgo_other": "{{count}} days ago"
},
"search": {
"placeholder": "Search models, collections, tags…",
"clear": "Clear search",
"failed": "Search failed. Please try again.",
"noResults": "No results for “{{query}}”.",
"sectionModels": "Models",
"sectionCollections": "Collections",
"sectionTags": "Tags",
"modelCount_one": "{{count}} model",
"modelCount_other": "{{count}} models"
},
"importProgress": {
"starting": "Starting import…",
"label": "Importing… {{processed}} of {{total}}",
+11
View File
@@ -192,6 +192,17 @@
"daysAgo_one": "prieš {{count}} dieną",
"daysAgo_other": "prieš {{count}} dienų"
},
"search": {
"placeholder": "Ieškoti modelių, kolekcijų, žymų…",
"clear": "Išvalyti paiešką",
"failed": "Paieška nepavyko. Bandykite dar kartą.",
"noResults": "Nerasta rezultatų pagal „{{query}}“.",
"sectionModels": "Modeliai",
"sectionCollections": "Kolekcijos",
"sectionTags": "Žymos",
"modelCount_one": "{{count}} modelis",
"modelCount_other": "{{count}} modeliai"
},
"importProgress": {
"starting": "Pradedamas importas…",
"label": "Importuojama… {{processed}} iš {{total}}",