mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
feat(search): improve search UX and results
This commit is contained in:
1 parent
3d1c851ad7
commit
ddcd3c785c
17 files changed
+1275
-193
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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 } } },
|
||||
|
||||
Reference in new issue
Block a user