feat(search): improve search UX and results

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-07 18:49:14 +02:00
1 parent 3d1c851ad7
commit ddcd3c785c
17 files changed
+1275 -193

No files matched your search

+35 -5
View File
@@ -26,6 +26,7 @@ import DriveFolderUploadIcon from "@mui/icons-material/DriveFolderUpload";
import LinkIcon from "@mui/icons-material/Link";
import { useUploadImport } from "../uploads/useUploadImport";
import { useImportJob } from "./ImportJobContext";
import { useToast } from "../ToastProvider";
import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
import {
detectImportProvider,
@@ -41,6 +42,14 @@ import type { MakerworldProfileScope } from "../../api/imports";
const IMPORT_PROVIDERS: ImportProviderKey[] = ["makerworld", "thingiverse", "printables"];
/** What the search palette's !import command calls. */
export type AddMenuHandle = {
/** No link opens the import dialog. A supported link imports straight away, unless something
* needs the dialog first (a captcha, a blocked or invalid link, several links), when it opens
* filled in with it. */
importFromCommand: (link?: string) => void;
};
type Props = {
categoryId?: string | null;
makerworldCookie?: string | null;
@@ -50,8 +59,12 @@ type Props = {
/** The top bar's "+ Add" button. MakerWorld collection links are blocked here; they can only be
* imported via the browser extension. */
export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUnauthorized }: Props) {
const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
{ categoryId, makerworldCookie, onUploaded, onUnauthorized },
ref,
) {
const { t } = useTranslation(["app", "common"]);
const showToast = useToast();
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const [importOpen, setImportOpen] = React.useState(false);
const [linkValue, setLinkValue] = React.useState("");
@@ -105,14 +118,29 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
upload.triggerFolderUpload();
};
const openImport = () => {
const openImport = (prefill = "") => {
closeMenu();
setLinkValue("");
setLinkValue(prefill);
setExampleProvider(null);
setProfileScope("url");
setImportOpen(true);
};
React.useImperativeHandle(ref, () => ({
importFromCommand: (link) => {
// The same rule as the Add button, which is disabled meanwhile.
if (upload.isBusy || isImporting) {
showToast({ message: t("addMenu.disabledWhileImporting"), severity: "info" });
return;
}
const url = link?.trim() ?? "";
const direct =
url && !/\s/.test(url) && detectImportProvider(url) && !isMakerworldCollectionUrl(url) && !needsCaptcha;
if (direct) void upload.submitImport(url);
else openImport(url);
},
}));
const closeImport = () => {
if (upload.importing) return;
setImportOpen(false);
@@ -161,7 +189,7 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
</ListItemIcon>
<ListItemText>{t("addMenu.uploadFolder")}</ListItemText>
</MenuItem>
<MenuItem onClick={openImport}>
<MenuItem onClick={() => openImport()}>
<ListItemIcon>
<LinkIcon fontSize="small" />
</ListItemIcon>
@@ -281,4 +309,6 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
{upload.modals}
</>
);
}
});
export default AddMenu;
@@ -0,0 +1,60 @@
import type { ReactNode } from "react";
import type { Theme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import KeyboardCommandKeyIcon from "@mui/icons-material/KeyboardCommandKey";
import KeyboardReturnIcon from "@mui/icons-material/KeyboardReturn";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
export const isMac =
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent);
/** Neutral grey for keycaps and icon boxes; the theme's hover colour is tinted green. */
export const chipBg = (theme: Theme) =>
theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.06)" : "rgba(0, 0, 0, 0.04)";
/** The search shortcut's modifier: ⌘ on a Mac, Ctrl everywhere else. */
export const MOD_KEY = isMac ? "⌘" : "Ctrl";
// Drawn as icons: the UI font has no ⌘ or ↵, and system fallbacks vary.
const GLYPHS: Record<string, ReactNode> = {
"⌘": <KeyboardCommandKeyIcon sx={{ fontSize: 11 }} />,
"↵": <KeyboardReturnIcon sx={{ fontSize: 11 }} />,
"↑": <ArrowUpwardIcon sx={{ fontSize: 11 }} />,
"↓": <ArrowDownwardIcon sx={{ fontSize: 11 }} />,
};
/** A shortcut like "⌘ S" as a row of keycaps. */
export default function Keys({ keys }: { keys: string }) {
return (
<Box component="span" sx={{ display: "inline-flex", gap: 0.25, flexShrink: 0 }}>
{keys
.split(" ")
.filter(Boolean)
.map((key) => (
<Box
key={key}
component="kbd"
sx={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minWidth: 18,
height: 18,
px: 0.5,
borderRadius: "5px",
border: "1px solid",
borderColor: "divider",
bgcolor: chipBg,
color: "text.secondary",
fontFamily: "inherit",
fontSize: 11,
lineHeight: 1,
}}
>
{GLYPHS[key] ?? key}
</Box>
))}
</Box>
);
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { bangArgument, bangText, isBangQuery, matchBang, type Command } from "./commands";
const COMMANDS: Command[] = [
{ id: "theme-dark", bang: "theme", arg: "dark", label: "Theme: Dark", keywords: [] },
{ id: "theme-light", bang: "theme", arg: "light", label: "Theme: Light", keywords: [] },
{ id: "theme-system", bang: "theme", arg: "system", argAlias: "auto", label: "Theme: System", keywords: [] },
{ id: "logout", bang: "logout", label: "Log out", keywords: [] },
{ id: "import", bang: "import", label: "Import", keywords: [] },
];
const ids = (query: string) => matchBang(query, COMMANDS).map((c) => c.id);
describe("matchBang", () => {
it("lists every command for a bare !", () => {
expect(ids("!")).toHaveLength(COMMANDS.length);
});
it("lists the commands a partial bang could become", () => {
expect(ids("!th")).toEqual(["theme-dark", "theme-light", "theme-system"]);
expect(ids("!lo")).toEqual(["logout"]);
expect(ids("!i")).toEqual(["import"]);
});
it("narrows a whole bang by its argument or alias", () => {
expect(ids("!theme")).toHaveLength(3);
expect(ids("!theme d")).toEqual(["theme-dark"]);
expect(ids("!THEME Light")).toEqual(["theme-light"]);
expect(ids("!theme auto")).toEqual(["theme-system"]);
});
it("keeps a command without arguments whatever follows it", () => {
expect(ids("!import https://www.printables.com/model/1-x")).toEqual(["import"]);
});
it("matches nothing for an unknown bang, a bad argument, or no bang", () => {
expect(ids("!nope")).toEqual([]);
expect(ids("!nope dark")).toEqual([]);
expect(ids("!theme purple")).toEqual([]);
expect(ids("theme")).toEqual([]);
});
});
describe("helpers", () => {
it("spots a ! query and writes a command out", () => {
expect(isBangQuery(" !th")).toBe(true);
expect(isBangQuery("dragon")).toBe(false);
expect(bangText(COMMANDS[0])).toBe("!theme dark");
expect(bangText(COMMANDS[3])).toBe("!logout");
});
it("reads what follows a bang, case kept", () => {
expect(bangArgument("!import https://MakerWorld.com/en/models/1 ")).toBe("https://MakerWorld.com/en/models/1");
expect(bangArgument("!import")).toBe("");
expect(bangArgument("import x")).toBe("");
});
});
@@ -0,0 +1,53 @@
// Bang commands: "!theme dark" runs a command directly, "!" alone lists them all.
export type Command = {
id: string;
/** Typed after "!", e.g. "theme". */
bang: string;
/** Narrows a bang with several commands, e.g. "dark" in "!theme dark". */
arg?: string;
/** Another word the argument matches, e.g. "auto" for "system". */
argAlias?: string;
label: string;
keywords: string[];
};
const FULL_BANG = /^!([a-zA-Z0-9_-]+)(?:\s+(.*))?$/;
const PARTIAL_BANG = /^!([a-zA-Z0-9_-]*)$/;
export function isBangQuery(query: string): boolean {
return query.trimStart().startsWith("!");
}
/** What follows a whole bang as typed, case kept: the link in "!import https://…". */
export function bangArgument(query: string): string {
return query.trimStart().match(FULL_BANG)?.[2]?.trim() ?? "";
}
/** How a "!" command is written out, shown as each command's subtitle. */
export function bangText(command: Command): string {
return `!${command.bang}${command.arg ? ` ${command.arg}` : ""}`;
}
/**
* The commands a "!" query matches. A whole bang ("!theme", "!theme d") gives its commands,
* filtered by the argument; a bare or partial one ("!", "!th") gives every command it could still
* become. Anything else, e.g. an unknown bang with an argument, matches nothing.
*/
export function matchBang<T extends Command>(query: string, commands: T[]): T[] {
const s = query.trimStart();
if (!s.startsWith("!")) return [];
const full = s.match(FULL_BANG);
if (full) {
const bang = full[1].toLowerCase();
const arg = (full[2] ?? "").trim().toLowerCase();
const hits = commands.filter((c) => c.bang === bang);
if (hits.length) {
return arg ? hits.filter((c) => !c.arg || c.arg.includes(arg) || c.argAlias?.includes(arg)) : hits;
}
}
const partial = s.match(PARTIAL_BANG);
if (!partial) return [];
const prefix = partial[1].toLowerCase();
return commands.filter((c) => c.bang.startsWith(prefix));
}
@@ -1,71 +1,178 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { keyframes } from "@emotion/react";
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 type { Theme } from "@mui/material/styles";
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 LightModeOutlinedIcon from "@mui/icons-material/LightModeOutlined";
import DarkModeOutlinedIcon from "@mui/icons-material/DarkModeOutlined";
import SettingsBrightnessOutlinedIcon from "@mui/icons-material/SettingsBrightnessOutlined";
import LogoutIcon from "@mui/icons-material/Logout";
import LinkIcon from "@mui/icons-material/Link";
import TranslateIcon from "@mui/icons-material/Translate";
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 { UnauthorizedError } from "../../../api/client";
import { type SearchResult, searchApi } from "../../../api/search";
import { printsApi } from "../../../api/prints";
import { IMPORT_PROVIDER_INFO } from "../../../constants/importProviders";
import { SUPPORTED_LANGUAGES, type LanguageCode } from "../../../constants/languages";
import { detectImportProvider } from "../../../utils/importLinkDetection";
import type { ThemeSelection } from "../../../constants/settingsOptions";
import { useDebouncedValue } from "../../../hooks/useDebouncedValue";
import { veilColor, VEIL_BLUR } from "../../../theme";
import Keys, { chipBg, MOD_KEY, isMac } from "./Keys";
import { bangArgument, bangText, isBangQuery, matchBang, type Command } from "./commands";
import { bestScore, minScoreFor } from "./match";
import { matchSystemCollections } from "./systemCollections";
import { matchPages, SEARCH_PAGES, type PageId, type SearchPage } from "./pages";
import { addRecent, readRecents, RECENTS_SHOWN, type RecentEntry, type RecentKind } from "./recents";
const MIN_QUERY_LENGTH = 2;
const DEBOUNCE_MS = 250;
const MAX_MATCHED_COMMANDS = 3;
const LIST_ID = "global-search-list";
const BAR_RADIUS = 18;
const PANEL_RADIUS = 14;
type Props = {
onUnauthorized?: () => void;
theme: ThemeSelection;
onThemeChange: (theme: ThemeSelection) => void;
onLogout: () => void;
/** Opens the import dialog, or with a link imports it (see AddMenuHandle). */
onImport: (link?: string) => void;
};
/** Ranking happens server-side (searchService.ts). */
export default function GlobalSearch({ onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
type Item = {
key: string;
title: string;
subtitle?: string;
accessory: string;
icon: ReactNode;
thumbUrl?: string | null;
actionLabel: string;
run: () => void;
};
type Section = { id: string; label: string; items: Item[] };
/** `subtitle` replaces the "!bang arg" shown by default. */
type PaletteCommand = Command & { icon: ReactNode; subtitle?: string; run: () => void };
const KIND_ICONS: Record<RecentKind, ReactNode> = {
model: <ViewInArIcon />,
collection: <CollectionsIcon />,
tag: <LocalOfferIcon />,
page: <WebAssetIcon />,
};
const PAGE_ICONS: Record<PageId, ReactNode> = {
dashboard: <SpaceDashboardIcon />,
models: <ViewInArIcon />,
collections: <CollectionsIcon />,
tags: <LocalOfferIcon />,
downloads: <DownloadIcon />,
myModels: <ViewInArIcon />,
profile: <PersonIcon />,
};
function pageFor(entry: RecentEntry): SearchPage | undefined {
return entry.kind === "page" ? SEARCH_PAGES.find((page) => page.path === entry.path) : undefined;
}
// Also matched: "!language lith", "!language lietuvių".
const LANGUAGE_ALIASES: Record<LanguageCode, string> = {
en: "english",
lt: "lithuanian lietuvių",
};
// "auto" also matches "system".
const THEMES: { theme: ThemeSelection; alias?: string; keywords: string[]; icon: ReactNode }[] = [
{ theme: "dark", keywords: ["dark", "dark mode", "night"], icon: <DarkModeOutlinedIcon /> },
{ theme: "light", keywords: ["light", "light mode", "day"], icon: <LightModeOutlinedIcon /> },
{ theme: "system", alias: "auto", keywords: ["system", "auto", "dynamic"], icon: <SettingsBrightnessOutlinedIcon /> },
];
const dimIn = keyframes`from { opacity: 0; }`;
const panelIn = keyframes`from { opacity: 0; transform: translateY(-4px); }`;
const selectedRowBg = (theme: Theme) =>
theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.06)";
/**
* Command palette: focusing it dims the page and lists recents and commands; typing searches
* models, collections and tags (ranked server-side, searchService.ts), pages and commands.
*/
export default function GlobalSearch({ onUnauthorized, theme, onThemeChange, onLogout, onImport }: Props) {
const { t, i18n } = useTranslation(["app", "common", "models"]);
const navigate = useNavigate();
const location = useLocation();
const anchorRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState("");
const [focused, setFocused] = useState(false);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<SearchResult | null>(null);
const [error, setError] = useState<string | null>(null);
// Tracked with ResizeObserver so the dropdown matches the fluid search box's width.
const [anchorWidth, setAnchorWidth] = useState<number>();
const [recents, setRecents] = useState<RecentEntry[]>(readRecents);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
// Until the user arrows away, the selection tracks the top row, so results that arrive late take it.
const userMovedRef = useRef(false);
// Where focus was before the search took it; Escape hands it back, as a dialog does.
const returnFocusRef = useRef<HTMLElement | null>(null);
const debouncedQuery = useDebouncedValue(query.trim(), DEBOUNCE_MS);
useEffect(() => {
const el = anchorRef.current;
if (!el) return;
const update = () => setAnchorWidth(el.offsetWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
const trimmedQuery = query.trim();
// Clear the query on route change, but not on same-page changes like search params.
useEffect(() => {
setQuery("");
setFocused(false);
setOpen(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
// Locks page scroll while open, padding for the scrollbar so the content doesn't shift.
useEffect(() => {
if (debouncedQuery.length < MIN_QUERY_LENGTH) {
if (!open) return;
const { body } = document;
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
const previous = { overflow: body.style.overflow, paddingRight: body.style.paddingRight };
body.style.overflow = "hidden";
if (scrollbar > 0) body.style.paddingRight = `${scrollbar}px`;
return () => {
body.style.overflow = previous.overflow;
body.style.paddingRight = previous.paddingRight;
};
}, [open]);
// ⌘S / Ctrl+S from anywhere, over the browser's "save page". (Windows keeps Win+S for itself.)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const mod = isMac ? e.metaKey : e.ctrlKey;
if (!mod || e.shiftKey || e.altKey || e.key.toLowerCase() !== "s") return;
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
setOpen(true);
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
useEffect(() => {
if (debouncedQuery.length < MIN_QUERY_LENGTH || isBangQuery(debouncedQuery)) {
setResult(null);
setLoading(false);
setError(null);
@@ -96,175 +203,600 @@ export default function GlobalSearch({ onUnauthorized }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQuery]);
const closeDropdown = () => setFocused(false);
const goTo = (path: string) => {
closeDropdown();
const finish = () => {
setQuery("");
navigate(path);
setOpen(false);
inputRef.current?.blur();
};
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));
/** Escape on an empty search: close it and give focus back to where it was, or to the page. */
const dismiss = () => {
const target = returnFocusRef.current;
returnFocusRef.current = null;
finish();
if (target?.isConnected && target !== document.body) target.focus({ preventScroll: true });
};
const openEntry = (entry: RecentEntry) => {
setRecents(addRecent(entry));
finish();
navigate(entry.path);
};
const entryItem = (entry: RecentEntry, accessory: string): Item => {
const page = pageFor(entry);
return {
key: entry.key,
// A recent page takes its name afresh, so it follows a language switch.
title: page ? t(page.labelKey) : entry.title,
subtitle: entry.subtitle,
accessory,
icon: page ? PAGE_ICONS[page.id] : KIND_ICONS[entry.kind],
thumbUrl: entry.thumbUrl ? printsApi.fileUrl(entry.thumbUrl) : null,
actionLabel: t("app:search.open"),
run: () => openEntry(entry),
};
};
const kindLabel = (kind: RecentKind) => t(`app:search.kind.${kind}`);
const pageItem = (page: SearchPage): Item =>
entryItem({ key: `page:${page.id}`, kind: "page", title: t(page.labelKey), path: page.path }, kindLabel("page"));
const commands: PaletteCommand[] = [
...THEMES.map(({ theme: target, alias, keywords, icon }) => ({
id: `theme-${target}`,
bang: "theme",
arg: target,
argAlias: alias,
label: t(`app:search.commands.theme.${target}`),
keywords,
icon,
run: () => onThemeChange(target),
})),
...SUPPORTED_LANGUAGES.map(({ code, label }) => ({
id: `language-${code}`,
bang: "language",
arg: code,
argAlias: LANGUAGE_ALIASES[code],
// Named in itself, as in the profile's picker, so it's findable from any UI language.
label: t("app:search.commands.language", { name: label }),
keywords: ["language", "kalba", ...LANGUAGE_ALIASES[code].split(" ")],
icon: <TranslateIcon />,
run: () => void i18n.changeLanguage(code),
})),
importCommand(),
{
id: "logout",
bang: "logout",
label: t("common:logOut"),
keywords: ["log out", "sign out", "logout"],
icon: <LogoutIcon />,
run: onLogout,
},
];
// "!import" opens the import dialog; "!import <link>" imports a supported link, or else opens the
// dialog with it.
function importCommand(): PaletteCommand {
const link = /^!import\b/i.test(trimmedQuery) ? bangArgument(trimmedQuery) : "";
const provider = link && !/\s/.test(link) ? detectImportProvider(link) : null;
const base = { id: "import", bang: "import", keywords: ["import", "add", "link", "url"], icon: <LinkIcon /> };
if (provider) {
return {
...base,
label: t("app:search.commands.importFrom", { provider: IMPORT_PROVIDER_INFO[provider].label }),
subtitle: link,
run: () => onImport(link),
};
}
return {
...base,
label: t("app:search.commands.import"),
subtitle: link ? t("app:search.commands.importNotSupported") : "!import <link>",
run: () => onImport(link || undefined),
};
}
const commandItem = (command: PaletteCommand): Item => ({
key: `command:${command.id}`,
title: command.label,
subtitle: command.subtitle ?? bangText(command),
accessory: t("app:search.kind.command"),
icon: command.icon,
actionLabel: t("app:search.runCommand"),
run: () => {
finish();
command.run();
},
});
const commandsSection = (items: PaletteCommand[]): Section => ({
id: "commands",
label: t("app:search.sectionCommands"),
items: items.map(commandItem),
});
const bang = isBangQuery(trimmedQuery);
const sections: Section[] = [];
if (bang) {
const matched = matchBang(trimmedQuery, commands);
if (matched.length) sections.push(commandsSection(matched));
} else if (!trimmedQuery) {
if (recents.length) {
sections.push({
id: "recent",
label: t("app:search.sectionRecent"),
items: recents.slice(0, RECENTS_SHOWN).map((entry) => entryItem(entry, kindLabel(entry.kind))),
});
}
// Suggests whichever of light and dark isn't picked.
const suggested = commands.find((c) => c.id === (theme === "light" ? "theme-dark" : "theme-light"));
if (suggested) sections.push(commandsSection([suggested]));
} else {
// The server never finds these (see systemCollections.ts).
const systemCollections =
trimmedQuery.length >= MIN_QUERY_LENGTH
? matchSystemCollections(trimmedQuery, {
favorites: t("models:collections.system.favorites"),
history: t("models:collections.system.history"),
}).map((key) =>
entryItem(
{
key: `collection:${key}`,
kind: "collection",
title: t(`models:collections.system.${key}`),
subtitle: t("app:search.builtInCollection"),
path: `/models/collections/${key}`,
},
kindLabel("collection"),
),
)
: [];
// Kept from the previous query while the next one loads, so the list doesn't flicker as you type.
if (result && trimmedQuery.length >= MIN_QUERY_LENGTH) {
const models = result.models.map((print) =>
entryItem(
{
key: `model:${print.id}`,
kind: "model",
title: print.title || print.name,
subtitle: print.category_name || undefined,
thumbUrl: print.thumb_url,
path: `/models/${print.id}`,
},
kindLabel("model"),
),
);
const collections = [
...systemCollections,
...result.collections.map((collection) =>
entryItem(
{
key: `collection:${collection.id}`,
kind: "collection",
title: collection.name,
subtitle: t("app:search.modelCount", { count: collection.item_count }),
path: `/models/collections/${collection.id}`,
},
kindLabel("collection"),
),
),
];
const tags = result.tags.map((tagResult) =>
entryItem(
{
key: `tag:${tagResult.tag}`,
kind: "tag",
title: tagResult.tag,
subtitle: t("app:search.modelCount", { count: tagResult.count }),
path: `/models/tags/${encodeURIComponent(tagResult.tag)}`,
},
kindLabel("tag"),
),
);
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 });
}
if (tags.length) sections.push({ id: "tags", label: t("app:search.sectionTags"), items: tags });
} else if (systemCollections.length) {
sections.push({ id: "collections", label: t("app:search.sectionCollections"), items: systemCollections });
}
const pageLabels = Object.fromEntries(SEARCH_PAGES.map((page) => [page.id, t(page.labelKey)])) as Record<
PageId,
string
>;
const pages = matchPages(trimmedQuery, pageLabels).map(pageItem);
if (pages.length) sections.push({ id: "pages", label: t("app:search.sectionPages"), items: pages });
const matched = commands
.map((command) => ({ command, score: bestScore(trimmedQuery, command.label, command.keywords) }))
.filter((c) => c.score >= minScoreFor(trimmedQuery))
.toSorted((a, b) => b.score - a.score)
.slice(0, MAX_MATCHED_COMMANDS)
.map((c) => c.command);
if (matched.length) sections.push(commandsSection(matched));
}
const flatItems = sections.flatMap((section) => section.items);
const keysSignature = flatItems.map((item) => item.key).join("\n");
const selectedIndex = flatItems.findIndex((item) => item.key === selectedKey);
const selected = selectedIndex >= 0 ? flatItems[selectedIndex] : null;
useEffect(() => {
userMovedRef.current = false;
}, [query]);
useEffect(() => {
const keys = keysSignature ? keysSignature.split("\n") : [];
setSelectedKey((current) =>
!userMovedRef.current || !current || !keys.includes(current) ? (keys[0] ?? null) : current,
);
}, [keysSignature, query]);
useEffect(() => {
if (selectedIndex >= 0)
document.getElementById(`${LIST_ID}-${selectedIndex}`)?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const moveSelection = (delta: number) => {
if (!flatItems.length) return;
const next = (selectedIndex + delta + flatItems.length) % flatItems.length;
setSelectedKey(flatItems[next].key);
userMovedRef.current = true;
};
const handleKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setOpen(true);
moveSelection(e.key === "ArrowDown" ? 1 : -1);
} else if (e.key === "Enter") {
if (!selected) return;
e.preventDefault();
selected.run();
}
};
// Escape clears the query, then closes. Handled on the window: keyboard extensions (e.g. Vimium)
// blur text fields on Escape, which would leave the next Escape unheard.
const escapeRef = useRef<() => void>(() => {});
escapeRef.current = () => {
if (!query) {
dismiss();
return;
}
setQuery("");
// After this event, so after any extension's blur.
setTimeout(() => inputRef.current?.focus({ preventScroll: true }), 0);
};
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.preventDefault();
escapeRef.current();
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [open]);
// The panel stays open while focused; with no rows it says why.
const searchPending = trimmedQuery !== debouncedQuery || loading;
let emptyText: string | null = null;
let searching = false;
if (bang) {
if (!flatItems.length) emptyText = t("app:search.noCommand");
} else if (error) {
emptyText = error;
} else if (!flatItems.length && trimmedQuery) {
if (trimmedQuery.length < MIN_QUERY_LENGTH) emptyText = t("app:search.keepTyping");
else if (searchPending) searching = true;
else emptyText = t("app:search.noResults", { query: trimmedQuery });
}
const panelOpen = open;
return (
<Box ref={anchorRef} sx={{ position: "relative", width: "100%" }}>
<Paper
variant="outlined"
<>
{open && (
// In the top bar's stacking context: covers the page and the bar, under the search box.
<Box
aria-hidden
onMouseDown={() => setOpen(false)}
sx={{
position: "fixed",
inset: 0,
zIndex: 1,
bgcolor: (muiTheme) => veilColor(muiTheme.palette.mode),
backdropFilter: VEIL_BLUR,
animation: `${dimIn} 180ms ease-out`,
}}
/>
)}
<Box
ref={containerRef}
// Closes when keyboard focus leaves the palette (Tab-out). A click outside lands on the dim.
onBlur={(e) => {
if (e.relatedTarget && !containerRef.current?.contains(e.relatedTarget as Node)) setOpen(false);
}}
sx={{ position: "relative", zIndex: open ? 2 : "auto", width: "100%" }}
>
<Paper
variant="outlined"
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 1.25,
py: 0.25,
borderRadius: panelOpen ? `${BAR_RADIUS}px ${BAR_RADIUS}px 0 0` : 999,
// No line where it joins the panel; transparent, not removed, so the bar doesn't shift.
borderBottomColor: panelOpen ? "transparent" : undefined,
bgcolor: (muiTheme) => (open ? "background.paper" : muiTheme.thingport.pageBackground),
}}
>
<SearchIcon fontSize="small" sx={{ color: "text.disabled" }} />
<InputBase
fullWidth
size="small"
inputRef={inputRef}
placeholder={t("app:search.placeholder") ?? undefined}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={(e) => {
const from = e.relatedTarget as HTMLElement | null;
// Only on opening, so refocusing after an extension's blur keeps the original.
if (!open && !containerRef.current?.contains(from)) returnFocusRef.current = from;
setOpen(true);
}}
onKeyDown={handleKeyDown}
inputProps={{
role: "combobox",
"aria-expanded": panelOpen,
"aria-controls": LIST_ID,
"aria-autocomplete": "list",
"aria-activedescendant": panelOpen && selectedIndex >= 0 ? `${LIST_ID}-${selectedIndex}` : undefined,
autoComplete: "off",
spellCheck: false,
}}
sx={{ fontSize: 14 }}
/>
{loading && trimmedQuery && <CircularProgress size={14} sx={{ flexShrink: 0 }} />}
{query ? (
<IconButton
size="small"
onClick={() => {
setQuery("");
inputRef.current?.focus();
}}
aria-label={t("app:search.clear") ?? undefined}
// Taller than the keycaps it replaces; keeps the bar from jumping on the first keystroke.
sx={{ my: -0.5 }}
>
<CloseIcon fontSize="inherit" />
</IconButton>
) : (
<Box sx={{ display: { xs: "none", md: "inline-flex" }, pr: 0.25 }}>
<Keys keys={`${MOD_KEY} S`} />
</Box>
)}
</Paper>
{panelOpen && (
<Box
// Keeps focus in the input when a row or the footer is clicked.
onMouseDown={(e) => e.preventDefault()}
sx={{
position: "absolute",
left: 0,
right: 0,
top: "100%",
display: "flex",
flexDirection: "column",
maxHeight: "min(520px, calc(100vh - var(--topbar-height, 64px) - 24px))",
bgcolor: "background.paper",
border: 1,
borderTop: 0,
borderColor: "divider",
borderRadius: `0 0 ${PANEL_RADIUS}px ${PANEL_RADIUS}px`,
overflow: "hidden",
animation: `${panelIn} 140ms ease-out`,
}}
>
<Box
id={LIST_ID}
// No native element fits a combobox popup of rich rows.
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
role="listbox"
sx={{
flex: "1 1 auto",
minHeight: 0,
overflowY: "auto",
overscrollBehavior: "contain",
px: 0.75,
pb: 0.75,
}}
>
{sections.map((section) => (
<Box key={section.id}>
<Typography
variant="caption"
sx={{
display: "block",
px: 1.25,
pt: 1.25,
pb: 0.5,
fontSize: "0.65rem",
fontWeight: 600,
letterSpacing: "0.14em",
textTransform: "uppercase",
color: "text.secondary",
}}
>
{section.label}
</Typography>
{section.items.map((item) => {
const index = flatItems.indexOf(item);
return (
<SearchRow
key={item.key}
id={`${LIST_ID}-${index}`}
item={item}
selected={index === selectedIndex}
onHover={() => {
if (index === selectedIndex) return;
setSelectedKey(item.key);
userMovedRef.current = true;
}}
/>
);
})}
</Box>
))}
{searching && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1.25, py: 1.5, color: "text.secondary" }}>
<CircularProgress size={12} color="inherit" />
<Typography variant="body2">{t("app:search.searching")}</Typography>
</Box>
)}
{emptyText && (
<Typography
variant="body2"
color={!bang && error ? "error" : "text.secondary"}
sx={{ px: 1.25, py: 1.5 }}
>
{emptyText}
</Typography>
)}
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
px: 1.5,
py: 0.75,
borderTop: 1,
borderColor: "divider",
color: "text.secondary",
fontSize: 12,
}}
>
<Box sx={{ display: "inline-flex", alignItems: "center", gap: 0.75 }}>
<Keys keys="↑ ↓" /> {t("app:search.toMove")}
</Box>
{selected && (
<Box
component="button"
type="button"
onClick={selected.run}
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.75,
border: 0,
p: 0,
bgcolor: "transparent",
color: "inherit",
font: "inherit",
cursor: "pointer",
}}
>
{selected.actionLabel} <Keys keys="↵" />
</Box>
)}
</Box>
</Box>
)}
</Box>
</>
);
}
function SearchRow({
id,
item,
selected,
onHover,
}: {
id: string;
item: Item;
selected: boolean;
onHover: () => void;
}) {
const [thumbFailed, setThumbFailed] = useState(false);
return (
<Box
id={id}
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
role="option"
aria-selected={selected}
onPointerMove={onHover}
onClick={item.run}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
minHeight: 40,
px: 1.25,
py: 0.75,
borderRadius: "9px",
cursor: "pointer",
bgcolor: selected ? selectedRowBg : "transparent",
}}
>
<Box
sx={{
position: "relative",
width: 28,
height: 28,
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 0.5,
px: 1.25,
py: 0.25,
borderRadius: 999,
bgcolor: (theme) => theme.thingport.pageBackground,
justifyContent: "center",
overflow: "hidden",
borderRadius: "7px",
border: 1,
borderColor: "divider",
bgcolor: chipBg,
color: "text.secondary",
"& .MuiSvgIcon-root": { fontSize: 16 },
}}
>
<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>
{item.icon}
{item.thumbUrl && !thumbFailed && (
<Box
component="img"
src={item.thumbUrl}
alt=""
loading="lazy"
onError={() => setThumbFailed(true)}
sx={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }}
/>
)}
</Paper>
<Popover
open={dropdownOpen}
anchorEl={anchorRef.current}
onClose={closeDropdown}
disableAutoFocus
disableEnforceFocus
disableRestoreFocus
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{ paper: { sx: { width: anchorWidth, mt: 0, 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}
</Box>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1.25, minWidth: 0, flex: "1 1 auto" }}>
<Typography
variant="body2"
noWrap
// A long subtitle gives way to the title.
sx={{ flexShrink: 0, maxWidth: "75%", color: (theme) => theme.thingport.headingText }}
>
{item.title}
</Typography>
{item.subtitle && (
<Typography variant="caption" noWrap color="text.secondary" sx={{ flexShrink: 2, minWidth: 0 }}>
{item.subtitle}
</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>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }}>
{item.accessory}
</Typography>
</Box>
);
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { bestScore, matchScore, MIN_COMMAND_SCORE, minScoreFor } from "./match";
describe("matchScore", () => {
it("ranks exact, prefix, word prefix, initials, substring, then letters in order", () => {
const scores = [
matchScore("theme: dark", "Theme: Dark"),
matchScore("the", "Theme: Dark"),
matchScore("dar", "Theme: Dark"),
matchScore("td", "Theme: Dark"),
matchScore("eme", "Theme: Dark"),
matchScore("tmd", "Theme: Dark"),
];
expect(scores).toEqual(scores.toSorted((a, b) => b - a));
expect(new Set(scores).size).toBe(scores.length);
});
it("doesn't match missing letters or an empty query", () => {
expect(matchScore("xyz", "Theme: Dark")).toBe(0);
expect(matchScore(" ", "Theme: Dark")).toBe(0);
});
});
describe("bestScore", () => {
it("lets a keyword match, below a name match", () => {
const viaKeyword = bestScore("night", "Theme: Dark", ["night mode"]);
expect(viaKeyword).toBeGreaterThanOrEqual(MIN_COMMAND_SCORE);
expect(viaKeyword).toBeLessThan(bestScore("theme", "Theme: Dark", ["night mode"]));
});
});
describe("minScoreFor", () => {
it("needs one letter to start a word, but lets longer queries match inside one", () => {
expect(matchScore("d", "Models")).toBeLessThan(minScoreFor("d"));
expect(matchScore("d", "Theme: Dark")).toBeGreaterThanOrEqual(minScoreFor("d"));
expect(matchScore("del", "Models")).toBeGreaterThanOrEqual(minScoreFor("del"));
});
});
@@ -0,0 +1,41 @@
// Fuzzy scoring, higher is better and 0 is no match. Order of preference: whole-name prefix, word
// prefix, initials ("td" → Theme: Dark), substring, then letters in order.
export function matchScore(query: string, text: string): number {
const q = query.trim().toLowerCase();
const t = (text || "").toLowerCase();
if (!q || !t) return 0;
if (t === q) return 100;
if (t.startsWith(q)) return 90 - Math.min(t.length - q.length, 20) / 4;
const words = t.split(/[\s\-_./·:]+/).filter(Boolean);
if (words.some((w) => w.startsWith(q))) return 70;
if (
words.length > 1 &&
words
.map((w) => w[0])
.join("")
.startsWith(q)
)
return 65;
const at = t.indexOf(q);
if (at >= 0) return 50 - Math.min(at, 20) / 2;
let i = 0;
for (const ch of t) if (ch === q[i]) i++;
return i === q.length && q.length >= 2 ? 20 : 0;
}
/** Best score across a label and its keywords; keywords count for a little less so a name match
* always wins. */
export function bestScore(query: string, title: string, keywords: string[] = []): number {
let best = matchScore(query, title);
for (const k of keywords) best = Math.max(best, matchScore(query, k) * 0.8);
return best;
}
/** 45+ is a substring or better; letters-in-order alone is too loose to list. */
export const MIN_COMMAND_SCORE = 45;
/** The score a match needs: from one letter even a substring is noise ("d" in "Models"), so the
* letter must start a word. */
export function minScoreFor(query: string): number {
return query.trim().length < 2 ? 65 : MIN_COMMAND_SCORE;
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { matchPages, SEARCH_PAGES, type PageId } from "./pages";
const EN: Record<PageId, string> = {
dashboard: "Dashboard",
models: "Models",
collections: "Collections",
tags: "Tags",
downloads: "Downloads",
myModels: "My models",
profile: "Profile",
};
const ids = (query: string) => matchPages(query, EN).map((page) => page.id);
describe("matchPages", () => {
it("matches the shown name from the first letter, best first", () => {
expect(ids("d")).toEqual(["dashboard", "downloads"]);
expect(ids("prof")).toEqual(["profile"]);
expect(ids("models")[0]).toBe("models");
expect(ids("my")).toEqual(["myModels"]);
});
it("matches the extra terms", () => {
expect(ids("bridge")).toEqual(["downloads"]);
expect(ids("account")).toEqual(["profile"]);
expect(ids("home")).toEqual(["dashboard"]);
});
it("leaves admin pages out", () => {
expect(ids("admin")).toEqual([]);
expect(SEARCH_PAGES.some((page) => page.path.startsWith("/admin"))).toBe(false);
});
});
@@ -0,0 +1,47 @@
import { SELF_AUTHOR_ID } from "../../../constants/selfAuthor";
import { bestScore, minScoreFor } from "./match";
// The app's own views. Admin pages are left out on purpose.
export type PageId = "dashboard" | "models" | "collections" | "tags" | "downloads" | "myModels" | "profile";
export type SearchPage = {
id: PageId;
path: string;
/** The name as the sidebar or user menu shows it. */
labelKey: string;
/** Lower case; English whatever the UI language, as people often search in it anyway. */
terms: string[];
};
export const SEARCH_PAGES: SearchPage[] = [
{ id: "dashboard", path: "/", labelKey: "app:sidebar.dashboard", terms: ["home", "overview", "stats"] },
{ id: "models", path: "/models", labelKey: "app:sidebar.models", terms: ["library", "prints", "all models"] },
{ id: "collections", path: "/models/collections", labelKey: "app:sidebar.collections", terms: ["collections"] },
{ id: "tags", path: "/models/tags", labelKey: "app:sidebar.tags", terms: ["tags", "labels"] },
{
id: "downloads",
path: "/downloads",
labelKey: "app:sidebar.downloads",
terms: ["downloads", "bridge", "extension", "browser extension", "install"],
},
{
id: "myModels",
path: `/authors/${SELF_AUTHOR_ID}`,
labelKey: "app:userMenu.myModels",
terms: ["my models", "my uploads", "uploads", "mine"],
},
{
id: "profile",
path: "/profile",
labelKey: "app:profile.title",
terms: ["profile", "account", "settings", "preferences", "email", "password"],
},
];
/** The pages `query` matches, best first. `labels` are their names as shown. */
export function matchPages(query: string, labels: Record<PageId, string>): SearchPage[] {
return SEARCH_PAGES.map((page) => ({ page, score: bestScore(query, labels[page.id], page.terms) }))
.filter((match) => match.score >= minScoreFor(query))
.toSorted((a, b) => b.score - a.score)
.map((match) => match.page);
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { withRecent, type RecentEntry } from "./recents";
const entry = (id: string): RecentEntry => ({ key: `model:${id}`, kind: "model", title: id, path: `/models/${id}` });
describe("withRecent", () => {
it("puts the newest first and doesn't repeat an entry", () => {
const list = withRecent(withRecent(withRecent([], entry("a")), entry("b")), entry("a"));
expect(list.map((e) => e.title)).toEqual(["a", "b"]);
});
it("keeps at most ten", () => {
let list: RecentEntry[] = [];
for (let i = 0; i < 15; i++) list = withRecent(list, entry(String(i)));
expect(list).toHaveLength(10);
expect(list[0].title).toBe("14");
});
});
@@ -0,0 +1,52 @@
// Opened search results, newest first, in this browser's storage.
const STORAGE_KEY = "thingport_search_recents";
// Kept beyond what's shown, so a result that's deleted or renamed doesn't leave the list short.
const MAX_STORED = 10;
export const RECENTS_SHOWN = 3;
export type RecentKind = "model" | "collection" | "tag" | "page";
export type RecentEntry = {
/** `${kind}:${id}`, unique across kinds. */
key: string;
kind: RecentKind;
title: string;
subtitle?: string;
/** Relative, like the API returns it; the token is added when it's shown. */
thumbUrl?: string | null;
path: string;
};
function isEntry(value: unknown): value is RecentEntry {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
typeof v.key === "string" &&
typeof v.title === "string" &&
typeof v.path === "string" &&
(v.kind === "model" || v.kind === "collection" || v.kind === "tag" || v.kind === "page")
);
}
/** Moves `entry` to the front, dropping its older copy and anything past the cap. */
export function withRecent(list: RecentEntry[], entry: RecentEntry): RecentEntry[] {
return [entry, ...list.filter((e) => e.key !== entry.key)].slice(0, MAX_STORED);
}
// Storage can be missing or throw (private windows, blocked site data); that just means no recents.
export function readRecents(): RecentEntry[] {
try {
const parsed: unknown = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "[]");
return Array.isArray(parsed) ? parsed.filter(isEntry) : [];
} catch {
return [];
}
}
export function addRecent(entry: RecentEntry): RecentEntry[] {
const next = withRecent(readRecents(), entry);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {}
return next;
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { matchSystemCollections } from "./systemCollections";
const EN = { favorites: "Favourites", history: "Browsing History" };
const LT = { favorites: "Mėgstamiausi", history: "Naršymo istorija" };
describe("matchSystemCollections", () => {
it("matches the shown name, by prefix or any word", () => {
expect(matchSystemCollections("fav", EN)).toEqual(["favorites"]);
expect(matchSystemCollections("brows", EN)).toEqual(["history"]);
expect(matchSystemCollections("hist", EN)).toEqual(["history"]);
expect(matchSystemCollections("istor", LT)).toEqual(["history"]);
});
it("matches the extra terms, in English whatever the language", () => {
expect(matchSystemCollections("favorites", EN)).toEqual(["favorites"]);
expect(matchSystemCollections("starred", LT)).toEqual(["favorites"]);
expect(matchSystemCollections("recently viewed", EN)).toEqual(["history"]);
});
it("matches neither for an unrelated query", () => {
expect(matchSystemCollections("dragon", EN)).toEqual([]);
});
});
@@ -0,0 +1,21 @@
import type { SystemCollectionKey } from "../../../api/collections";
import { bestScore, MIN_COMMAND_SCORE } from "./match";
// Favourites and Browsing History aren't stored as collections, so the backend search never finds
// them; they're matched here on their shown name plus these terms (lower case, English).
export const SYSTEM_COLLECTION_TERMS: Record<SystemCollectionKey, string[]> = {
favorites: ["favourites", "favorites", "favs", "starred", "stars", "liked", "likes"],
history: ["history", "browsing history", "recently viewed", "viewed", "recent", "visited"],
};
/** The system collections `query` matches, best first. `names` are their names as shown. */
export function matchSystemCollections(
query: string,
names: Record<SystemCollectionKey, string>,
): SystemCollectionKey[] {
return (Object.keys(SYSTEM_COLLECTION_TERMS) as SystemCollectionKey[])
.map((key) => ({ key, score: bestScore(query, names[key], SYSTEM_COLLECTION_TERMS[key]) }))
.filter((match) => match.score >= MIN_COMMAND_SCORE)
.toSorted((a, b) => b.score - a.score)
.map((match) => match.key);
}
+10 -2
View File
@@ -8,7 +8,7 @@ import Typography from "@mui/material/Typography";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import type { AuthUser } from "../../api/auth";
import type { ThemeSelection } from "../../constants/settingsOptions";
import AddMenu from "./AddMenu";
import AddMenu, { type AddMenuHandle } from "./AddMenu";
import NotificationBell from "./NotificationBell";
import { UserMenu } from "./UserMenu";
import GlobalSearch from "./GlobalSearch";
@@ -48,6 +48,7 @@ export default function TopBar({
}: Props) {
const { t } = useTranslation("app");
const rootRef = useRef<HTMLDivElement | null>(null);
const addMenuRef = useRef<AddMenuHandle | null>(null);
// Publishes the bar's real height (it can wrap) as a CSS var for sticky elements below it.
useLayoutEffect(() => {
@@ -103,10 +104,17 @@ export default function TopBar({
{actions}
</Stack>
<Box sx={{ minWidth: 0, justifySelf: "center", width: "100%" }}>
<GlobalSearch onUnauthorized={onUnauthorized} />
<GlobalSearch
onUnauthorized={onUnauthorized}
theme={theme}
onThemeChange={onThemeChange}
onLogout={onLogout}
onImport={(link) => addMenuRef.current?.importFromCommand(link)}
/>
</Box>
<Stack direction="row" alignItems="center" spacing={1} minWidth={0} justifySelf="end">
<AddMenu
ref={addMenuRef}
categoryId={categoryId}
makerworldCookie={makerworldCookie}
onUploaded={onUploaded}
+29 -1
View File
@@ -254,7 +254,35 @@
"sectionCollections": "Collections",
"sectionTags": "Tags",
"modelCount_one": "{{count}} model",
"modelCount_other": "{{count}} models"
"modelCount_other": "{{count}} models",
"sectionRecent": "Recent",
"sectionCommands": "Commands",
"open": "Open",
"runCommand": "Run command",
"toMove": "to move",
"kind": {
"model": "Model",
"collection": "Collection",
"tag": "Tag",
"command": "Command",
"page": "Page"
},
"commands": {
"theme": {
"dark": "Theme: Dark",
"light": "Theme: Light",
"system": "Theme: System"
},
"import": "Import from a link",
"importFrom": "Import from {{provider}}",
"importNotSupported": "Not a MakerWorld, Printables or Thingiverse link; opens the import window",
"language": "Language: {{name}}"
},
"searching": "Searching…",
"keepTyping": "Keep typing to search models, collections and tags, or type ! for commands.",
"noCommand": "No command by that name. Type ! on its own to see them all.",
"builtInCollection": "Built-in collection",
"sectionPages": "Pages"
},
"importProgress": {
"starting": "Starting import…",
+29 -1
View File
@@ -254,7 +254,35 @@
"sectionCollections": "Kolekcijos",
"sectionTags": "Žymos",
"modelCount_one": "{{count}} modelis",
"modelCount_other": "{{count}} modeliai"
"modelCount_other": "{{count}} modeliai",
"sectionRecent": "Neseniai",
"sectionCommands": "Komandos",
"open": "Atidaryti",
"runCommand": "Vykdyti komandą",
"toMove": "judėti",
"kind": {
"model": "Modelis",
"collection": "Kolekcija",
"tag": "Žyma",
"command": "Komanda",
"page": "Puslapis"
},
"commands": {
"theme": {
"dark": "Tema: tamsi",
"light": "Tema: šviesi",
"system": "Tema: pagal sistemą"
},
"import": "Importuoti iš nuorodos",
"importFrom": "Importuoti iš {{provider}}",
"importNotSupported": "Ne MakerWorld, Printables ar Thingiverse nuoroda; atidaromas importavimo langas",
"language": "Kalba: {{name}}"
},
"searching": "Ieškoma…",
"keepTyping": "Rašykite toliau, kad ieškotumėte modelių, kolekcijų ir žymų, arba įveskite ! komandoms.",
"noCommand": "Tokios komandos nėra. Įveskite tik !, kad pamatytumėte visas.",
"builtInCollection": "Įtaisyta kolekcija",
"sectionPages": "Puslapiai"
},
"importProgress": {
"starting": "Pradedamas importas…",
+13
View File
@@ -114,6 +114,12 @@ const THEME_DEFS: Record<ResolvedTheme, ThemeDef> = {
},
};
/** The veil behind modals and the open search. */
export function veilColor(mode: "light" | "dark"): string {
return mode === "dark" ? "rgba(0, 0, 0, 0.5)" : "rgba(255, 255, 255, 0.6)";
}
export const VEIL_BLUR = "blur(1.5px)";
export function buildTheme(id: ResolvedTheme): Theme {
const d = THEME_DEFS[id];
const options: ThemeOptions = {
@@ -140,6 +146,13 @@ export function buildTheme(id: ResolvedTheme): Theme {
MuiPaper: { styleOverrides: { root: { backgroundImage: "none", backgroundColor: d.panel } } },
MuiAppBar: { styleOverrides: { root: { backgroundColor: d.panelStrong, color: d.text } } },
MuiDrawer: { styleOverrides: { paper: { backgroundColor: d.panelStrong, borderColor: d.border } } },
// Menus and popovers have an invisible backdrop, which stays so.
MuiBackdrop: {
styleOverrides: {
root: ({ ownerState }) =>
ownerState.invisible ? {} : { backgroundColor: veilColor(d.mode), backdropFilter: VEIL_BLUR },
},
},
MuiButton: { styleOverrides: { root: { borderRadius: 8 } } },
MuiChip: { styleOverrides: { root: { borderRadius: 6 } } },
MuiTooltip: { styleOverrides: { tooltip: { backgroundColor: d.panelStrong, color: d.text } } },