diff --git a/frontend/src/components/Layout/AddMenu.tsx b/frontend/src/components/Layout/AddMenu.tsx index 5fc5a3b..09087a5 100644 --- a/frontend/src/components/Layout/AddMenu.tsx +++ b/frontend/src/components/Layout/AddMenu.tsx @@ -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(function AddMenu( + { categoryId, makerworldCookie, onUploaded, onUnauthorized }, + ref, +) { const { t } = useTranslation(["app", "common"]); + const showToast = useToast(); const [anchorEl, setAnchorEl] = React.useState(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 {t("addMenu.uploadFolder")} - + openImport()}> @@ -281,4 +309,6 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn {upload.modals} ); -} +}); + +export default AddMenu; diff --git a/frontend/src/components/Layout/GlobalSearch/Keys.tsx b/frontend/src/components/Layout/GlobalSearch/Keys.tsx new file mode 100644 index 0000000..49eeb98 --- /dev/null +++ b/frontend/src/components/Layout/GlobalSearch/Keys.tsx @@ -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 = { + "⌘": , + "↵": , + "↑": , + "↓": , +}; + +/** A shortcut like "⌘ S" as a row of keycaps. */ +export default function Keys({ keys }: { keys: string }) { + return ( + + {keys + .split(" ") + .filter(Boolean) + .map((key) => ( + + {GLYPHS[key] ?? key} + + ))} + + ); +} diff --git a/frontend/src/components/Layout/GlobalSearch/commands.test.ts b/frontend/src/components/Layout/GlobalSearch/commands.test.ts new file mode 100644 index 0000000..e4f0fd9 --- /dev/null +++ b/frontend/src/components/Layout/GlobalSearch/commands.test.ts @@ -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(""); + }); +}); diff --git a/frontend/src/components/Layout/GlobalSearch/commands.ts b/frontend/src/components/Layout/GlobalSearch/commands.ts new file mode 100644 index 0000000..2c321aa --- /dev/null +++ b/frontend/src/components/Layout/GlobalSearch/commands.ts @@ -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(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)); +} diff --git a/frontend/src/components/Layout/GlobalSearch/index.tsx b/frontend/src/components/Layout/GlobalSearch/index.tsx index d58a56a..2225c3c 100644 --- a/frontend/src/components/Layout/GlobalSearch/index.tsx +++ b/frontend/src/components/Layout/GlobalSearch/index.tsx @@ -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 = { + model: , + collection: , + tag: , + page: , +}; + +const PAGE_ICONS: Record = { + dashboard: , + models: , + collections: , + tags: , + downloads: , + myModels: , + profile: , +}; + +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 = { + 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: }, + { theme: "light", keywords: ["light", "light mode", "day"], icon: }, + { theme: "system", alias: "auto", keywords: ["system", "auto", "dynamic"], icon: }, +]; + +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(null); + const containerRef = useRef(null); + const inputRef = useRef(null); const [query, setQuery] = useState(""); - const [focused, setFocused] = useState(false); + const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); - // Tracked with ResizeObserver so the dropdown matches the fluid search box's width. - const [anchorWidth, setAnchorWidth] = useState(); + const [recents, setRecents] = useState(readRecents); + const [selectedKey, setSelectedKey] = useState(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(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: , + run: () => void i18n.changeLanguage(code), + })), + importCommand(), + { + id: "logout", + bang: "logout", + label: t("common:logOut"), + keywords: ["log out", "sign out", "logout"], + icon: , + run: onLogout, + }, + ]; + + // "!import" opens the import dialog; "!import " 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: }; + 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 ", + 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) => { + 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 ( - - + {open && ( + // In the top bar's stacking context: covers the page and the bar, under the search box. + setOpen(false)} + sx={{ + position: "fixed", + inset: 0, + zIndex: 1, + bgcolor: (muiTheme) => veilColor(muiTheme.palette.mode), + backdropFilter: VEIL_BLUR, + animation: `${dimIn} 180ms ease-out`, + }} + /> + )} + { + if (e.relatedTarget && !containerRef.current?.contains(e.relatedTarget as Node)) setOpen(false); + }} + sx={{ position: "relative", zIndex: open ? 2 : "auto", width: "100%" }} + > + (open ? "background.paper" : muiTheme.thingport.pageBackground), + }} + > + + { + 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 && } + {query ? ( + { + 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 }} + > + + + ) : ( + + + + )} + + + {panelOpen && ( + 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`, + }} + > + + {sections.map((section) => ( + + + {section.label} + + {section.items.map((item) => { + const index = flatItems.indexOf(item); + return ( + { + if (index === selectedIndex) return; + setSelectedKey(item.key); + userMovedRef.current = true; + }} + /> + ); + })} + + ))} + {searching && ( + + + {t("app:search.searching")} + + )} + {emptyText && ( + + {emptyText} + + )} + + + + {t("app:search.toMove")} + + {selected && ( + + {selected.actionLabel} + + )} + + + )} + + + ); +} + +function SearchRow({ + id, + item, + selected, + onHover, +}: { + id: string; + item: Item; + selected: boolean; + onHover: () => void; +}) { + const [thumbFailed, setThumbFailed] = useState(false); + return ( + + theme.thingport.pageBackground, + justifyContent: "center", + overflow: "hidden", + borderRadius: "7px", + border: 1, + borderColor: "divider", + bgcolor: chipBg, + color: "text.secondary", + "& .MuiSvgIcon-root": { fontSize: 16 }, }} > - - setQuery(e.target.value)} - onFocus={() => setFocused(true)} - onKeyDown={(e) => { - if (e.key === "Escape") { - (e.target as HTMLInputElement).blur(); - closeDropdown(); - } - }} - sx={{ fontSize: 14 }} - /> - {query && ( - setQuery("")} aria-label={t("app:search.clear") ?? undefined}> - - + {item.icon} + {item.thumbUrl && !thumbFailed && ( + setThumbFailed(true)} + sx={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }} + /> )} - - - - {loading && ( - - - - )} - {!loading && error && ( - - {error} + + + theme.thingport.headingText }} + > + {item.title} + + {item.subtitle && ( + + {item.subtitle} )} - {!loading && !error && result && !hasResults && ( - - {t("app:search.noResults", { query: trimmedQuery })} - - )} - {!loading && !error && result && hasResults && ( - }> - {result.models.length > 0 && ( - - - {t("app:search.sectionModels")} - - - {result.models.map((print) => ( - goTo(`/models/${print.id}`)}> - - - - - - - - ))} - - - )} - {result.collections.length > 0 && ( - - - {t("app:search.sectionCollections")} - - - {result.collections.map((collection) => ( - goTo(`/models/collections/${collection.id}`)}> - - - - - - - - ))} - - - )} - {result.tags.length > 0 && ( - - - {t("app:search.sectionTags")} - - - {result.tags.map((tagResult) => ( - goTo(`/models/tags/${encodeURIComponent(tagResult.tag)}`)} - > - - - - - - - - ))} - - - )} - - )} - + + + {item.accessory} + ); } diff --git a/frontend/src/components/Layout/GlobalSearch/match.test.ts b/frontend/src/components/Layout/GlobalSearch/match.test.ts new file mode 100644 index 0000000..eabe9b2 --- /dev/null +++ b/frontend/src/components/Layout/GlobalSearch/match.test.ts @@ -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")); + }); +}); diff --git a/frontend/src/components/Layout/GlobalSearch/match.ts b/frontend/src/components/Layout/GlobalSearch/match.ts new file mode 100644 index 0000000..23fa3ad --- /dev/null +++ b/frontend/src/components/Layout/GlobalSearch/match.ts @@ -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; +} diff --git a/frontend/src/components/Layout/GlobalSearch/pages.test.ts b/frontend/src/components/Layout/GlobalSearch/pages.test.ts new file mode 100644 index 0000000..779e87b --- /dev/null +++ b/frontend/src/components/Layout/GlobalSearch/pages.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { matchPages, SEARCH_PAGES, type PageId } from "./pages"; + +const EN: Record = { + 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); + }); +}); diff --git a/frontend/src/components/Layout/GlobalSearch/pages.ts b/frontend/src/components/Layout/GlobalSearch/pages.ts new file mode 100644 index 0000000..ddde4f9 --- /dev/null +++ b/frontend/src/components/Layout/GlobalSearch/pages.ts @@ -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): 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); +} diff --git a/frontend/src/components/Layout/GlobalSearch/recents.test.ts b/frontend/src/components/Layout/GlobalSearch/recents.test.ts new file mode 100644 index 0000000..9653247 --- /dev/null +++ b/frontend/src/components/Layout/GlobalSearch/recents.test.ts @@ -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"); + }); +}); diff --git a/frontend/src/components/Layout/GlobalSearch/recents.ts b/frontend/src/components/Layout/GlobalSearch/recents.ts new file mode 100644 index 0000000..7c21a62 --- /dev/null +++ b/frontend/src/components/Layout/GlobalSearch/recents.ts @@ -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; + 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; +} diff --git a/frontend/src/components/Layout/GlobalSearch/systemCollections.test.ts b/frontend/src/components/Layout/GlobalSearch/systemCollections.test.ts new file mode 100644 index 0000000..e6f8c6d --- /dev/null +++ b/frontend/src/components/Layout/GlobalSearch/systemCollections.test.ts @@ -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([]); + }); +}); diff --git a/frontend/src/components/Layout/GlobalSearch/systemCollections.ts b/frontend/src/components/Layout/GlobalSearch/systemCollections.ts new file mode 100644 index 0000000..e9948f5 --- /dev/null +++ b/frontend/src/components/Layout/GlobalSearch/systemCollections.ts @@ -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 = { + 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[] { + 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); +} diff --git a/frontend/src/components/Layout/TopBar.tsx b/frontend/src/components/Layout/TopBar.tsx index fbe9568..fc9d7f3 100644 --- a/frontend/src/components/Layout/TopBar.tsx +++ b/frontend/src/components/Layout/TopBar.tsx @@ -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(null); + const addMenuRef = useRef(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} - + addMenuRef.current?.importFromCommand(link)} + /> = { }, }; +/** 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 } } },