mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: add first version of search
This commit is contained in:
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");
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
Reference in new issue
Block a user