From 769204ca3bb5394ac4f0936b07404aad6e72fc5f Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 24 Aug 2026 02:23:12 +0200 Subject: [PATCH] feat: rebuild command menu --- .../advanced/keyboard-shortcuts/index.mdx | 16 +- apps/docs/docs/management/assistant.mdx | 2 +- .../app/[locale]/boards/(content)/_client.tsx | 2 + .../assistant/assistant-provider.tsx | 1 + .../assistant-spotlight-placeholder.ts | 1 + .../board/board-apps-spotlight-registrar.tsx | 69 ++++ .../src/components/board/board-switcher.tsx | 16 + .../layout/header/lazy-spotlight.tsx | 22 +- .../src/components/layout/header/search.tsx | 4 +- packages/definitions/src/hotkeys.ts | 3 + .../src/components/actions/group-actions.tsx | 23 +- .../actions/groups/action-group.tsx | 6 +- .../actions/items/group-action-item.tsx | 9 +- .../spotlight/src/components/mode-rail.tsx | 80 ++++ .../spotlight/src/components/no-results.tsx | 4 +- .../src/components/spotlight.module.css | 106 ++++++ .../spotlight/src/components/spotlight.tsx | 272 ++++++++------ packages/spotlight/src/index.ts | 1 + packages/spotlight/src/lib/group.ts | 32 +- packages/spotlight/src/lib/interaction.ts | 4 +- packages/spotlight/src/lib/mode.ts | 7 +- packages/spotlight/src/lib/remote-query.ts | 46 +++ .../apps-search-group.tsx | 7 +- .../boards-search-group.tsx | 5 +- .../src/modes/app-integration-board/index.tsx | 15 +- .../integrations-search-group.tsx | 8 +- .../spotlight/src/modes/assistant/index.tsx | 10 + .../preferences/settings-children.tsx | 32 +- .../modes/command/context-specific-group.tsx | 1 + .../src/modes/command/global-group.tsx | 3 +- .../spotlight/src/modes/command/index.tsx | 6 +- .../spotlight/src/modes/external/index.tsx | 4 +- .../external/search-engines-search-group.tsx | 343 +++++++++--------- .../src/modes/help/home-empty-groups.tsx | 138 ------- .../src/modes/home/context-specific-group.tsx | 81 +++-- packages/spotlight/src/modes/home/context.tsx | 2 + .../modes/home/home-search-engine-group.tsx | 13 +- packages/spotlight/src/modes/home/index.tsx | 36 +- packages/spotlight/src/modes/index.tsx | 16 +- packages/spotlight/src/modes/media/index.tsx | 4 +- .../src/modes/media/media-fallback-group.tsx | 87 +++++ .../media/media-request-search-group.tsx | 52 ++- packages/spotlight/src/modes/page/index.tsx | 8 - .../src/modes/page/pages-search-group.tsx | 1 + .../src/modes/preferences/groups.tsx | 92 +---- .../spotlight/src/modes/preferences/index.tsx | 10 + .../modes/user-group/groups-search-group.tsx | 5 +- .../spotlight/src/modes/user-group/index.tsx | 12 +- .../modes/user-group/users-search-group.tsx | 5 +- packages/spotlight/src/open.spec.ts | 18 +- packages/spotlight/src/open.ts | 46 ++- packages/translation/src/lang/en.json | 48 +++ packages/widgets/src/app/component.tsx | 21 -- 53 files changed, 1188 insertions(+), 667 deletions(-) create mode 100644 apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx create mode 100644 packages/spotlight/src/components/mode-rail.tsx create mode 100644 packages/spotlight/src/components/spotlight.module.css create mode 100644 packages/spotlight/src/lib/remote-query.ts create mode 100644 packages/spotlight/src/modes/assistant/index.tsx delete mode 100644 packages/spotlight/src/modes/help/home-empty-groups.tsx create mode 100644 packages/spotlight/src/modes/media/media-fallback-group.tsx delete mode 100644 packages/spotlight/src/modes/page/index.tsx create mode 100644 packages/spotlight/src/modes/preferences/index.tsx diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index 07765cf11..e1eb51562 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -8,13 +8,27 @@ Homarr has the following keyboard shortcuts: | Shortcut | Description | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `MOD` + `K` | Opens the search bar | +| `MOD` + `K` | Opens search, including local apps, boards, integrations, pages, commands, and preferences | +| `MOD` + `P` | Opens app search | +| `MOD` + `/` | Opens Homarr Assistant inside search | +| `MOD` + `Shift` + `/` (`MOD` + `?`) | Opens preference search | +| `Shift` + `A` | Opens the full Homarr Assistant | | `MOD` + `J` | Toggles Dark Mode | | `MOD` + `E` | Toggles Edit Mode | | Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view | | Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed | | `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu | +## Search and commands + +Search is designed as the fastest route to Homarr. Start typing after `MOD` + `K` to find local apps, boards, +integrations, pages, commands, and preferences without entering a separate mode first. Local Homarr results appear +before external search and Assistant fallbacks. Commands include opening the board switcher without leaving the +keyboard. + +Use the dedicated shortcuts above to start in apps, Assistant, or preferences. Inside search, choose a mode to narrow +the same query when needed. Media request search is available only when a supported Seerr integration is configured. + ## Widget advanced view Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl` diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index aec1bde4e..3bda33bca 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -38,7 +38,7 @@ host gateway address. ## Use it - Select **Assistant** in the user menu or press `Shift + A`. -- Ask from `Cmd/Ctrl + K`. +- Press `Cmd/Ctrl + /` to ask from search, or use `Cmd/Ctrl + K` and choose the Assistant fallback. - Add the [Assistant widget](../widgets/assistant) to a board. The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 395178901..5ecd8be66 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client"; import { FloatingTip } from "@homarr/ui"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; +import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar"; import { BoardEmptyState } from "~/components/board/board-empty-state"; import { BoardSetupChecklist } from "~/components/board/board-setup-checklist"; import { @@ -79,6 +80,7 @@ export const ClientBoard = () => { + diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index a7f7f6440..7a43ab1aa 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -856,6 +856,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { : t("busy.description"), unavailable: !canSend, alwaysVisible: true, + placement: "fallback" as const, interaction: (query: string) => createAssistantPromptInteraction({ sendPrompt, diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts b/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts index f74aea66d..1bc428f78 100644 --- a/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts +++ b/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts @@ -19,6 +19,7 @@ export const useRegisterAssistantSpotlightPlaceholder = (description: string) => description, unavailable: true, alwaysVisible: true, + placement: "fallback" as const, interaction: (_query: string) => ({ type: "none" as const }), }), [description, t], diff --git a/apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx b/apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx new file mode 100644 index 000000000..157d14e94 --- /dev/null +++ b/apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { getSafeAppHref } from "@homarr/common"; +import type { ContextSpecificItem } from "@homarr/spotlight"; +import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; +import { hasPendingSpotlightOpen, spotlightOpenEvent } from "@homarr/spotlight/open"; + +export const BoardAppsSpotlightRegistrar = () => { + const board = useRequiredBoard(); + const [isSpotlightActivated, setIsSpotlightActivated] = useState(() => hasPendingSpotlightOpen()); + + useEffect(() => { + if (isSpotlightActivated) return; + + const activateSpotlightQueries = () => setIsSpotlightActivated(true); + window.addEventListener(spotlightOpenEvent, activateSpotlightQueries); + + if (hasPendingSpotlightOpen()) activateSpotlightQueries(); + + return () => window.removeEventListener(spotlightOpenEvent, activateSpotlightQueries); + }, [isSpotlightActivated]); + + const openInNewTabByAppId = useMemo(() => { + const result = new Map(); + + for (const item of board.items) { + if (item.kind !== "app" || typeof item.options.appId !== "string" || item.options.appId.length === 0) continue; + + const existingOpenInNewTab = result.get(item.options.appId) ?? false; + result.set(item.options.appId, existingOpenInNewTab || item.options.openInNewTab === true); + } + + return result; + }, [board.items]); + const appIds = useMemo(() => Array.from(openInNewTabByAppId.keys()), [openInNewTabByAppId]); + const activeAppIds = isSpotlightActivated ? appIds : []; + const appQueries = clientApi.useQueries((trpc) => activeAppIds.map((id) => trpc.app.byId({ id }))); + const apps = appQueries.flatMap((query) => query.data ?? []); + const spotlightItems = useMemo(() => { + return apps.flatMap((app) => { + const href = getSafeAppHref(app.href); + if (!href) return []; + + return [ + { + id: app.id, + name: app.name, + icon: app.iconUrl, + interaction() { + return { + type: "link" as const, + href, + // Opening a new tab wins if any widget instance requests it. + newTab: openInNewTabByAppId.get(app.id) ?? false, + }; + }, + }, + ]; + }); + }, [apps, openInNewTabByAppId]); + + useRegisterSpotlightContextResults("board-apps", spotlightItems, [spotlightItems]); + + return null; +}; diff --git a/apps/nextjs/src/components/board/board-switcher.tsx b/apps/nextjs/src/components/board/board-switcher.tsx index 7d20f5bfc..2454eda9f 100644 --- a/apps/nextjs/src/components/board/board-switcher.tsx +++ b/apps/nextjs/src/components/board/board-switcher.tsx @@ -26,6 +26,7 @@ import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@ import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useOptionalBoard } from "@homarr/boards/context"; +import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; import { Link, UserAvatar } from "@homarr/ui"; @@ -41,6 +42,7 @@ interface BoardSwitcherProps { export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { const t = useI18n("board.action.switcher"); + const tBoard = useI18n("board"); const manageBoardsT = useI18n("management.page.board"); const currentBoard = useOptionalBoard(); const [isOpen, setIsOpen] = useState(false); @@ -85,6 +87,20 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { }, []); const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true }); + const spotlightAction = useMemo( + () => ({ + id: "open-board-switcher", + name: tBoard("action.switch"), + icon: IconLayoutBoard, + interaction: () => ({ + type: "javaScript" as const, + onSelect: openSwitcher, + }), + }), + [openSwitcher, tBoard], + ); + useRegisterSpotlightContextActions("board-switcher", [spotlightAction], [spotlightAction]); + useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]); useEffect(() => { diff --git a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx index 5fbc9f5ed..823ec01bd 100644 --- a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx +++ b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; +import type { HotkeyItem } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks"; import { hotkeys } from "@homarr/definitions"; @@ -13,23 +14,18 @@ const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) loading: () => null, }); +const spotlightHotkeys: HotkeyItem[] = [ + [hotkeys.openSpotlight, () => openSpotlight(), { preventDefault: true }], + [hotkeys.openSpotlightApps, () => openSpotlight({ mode: "apps" }), { preventDefault: true }], + [hotkeys.openSpotlightAssistant, () => openSpotlight({ mode: "assistant" }), { preventDefault: true }], + [hotkeys.openSpotlightPreferences, () => openSpotlight({ mode: "preferences" }), { preventDefault: true }], +]; + export const LazySpotlight = () => { const [isMounted, setIsMounted] = useState(false); const [isPreloaded, setIsPreloaded] = useState(false); - useHotkeys( - isMounted - ? [] - : [ - [ - hotkeys.openSpotlight, - () => { - setIsMounted(true); - openSpotlight(); - }, - ], - ], - ); + useHotkeys(spotlightHotkeys, [], true); useEffect(() => { let isActive = true; diff --git a/apps/nextjs/src/components/layout/header/search.tsx b/apps/nextjs/src/components/layout/header/search.tsx index a7963fc07..ad837e272 100644 --- a/apps/nextjs/src/components/layout/header/search.tsx +++ b/apps/nextjs/src/components/layout/header/search.tsx @@ -20,7 +20,7 @@ export const DesktopSearchInput = () => { w={400} size="sm" leftSection={} - onClick={openSpotlight} + onClick={() => openSpotlight()} radius="xl" > {`${t("search.placeholder")}...`} @@ -32,7 +32,7 @@ export const MobileSearchButton = () => { return ( openSpotlight()} className={classes.mobileSearch} > diff --git a/packages/definitions/src/hotkeys.ts b/packages/definitions/src/hotkeys.ts index ffa630fcf..bd568b34b 100644 --- a/packages/definitions/src/hotkeys.ts +++ b/packages/definitions/src/hotkeys.ts @@ -3,5 +3,8 @@ export const hotkeys = { toggleColorScheme: "mod+j", saveNotebook: "mod+s", openSpotlight: "mod+k", + openSpotlightApps: "mod+p", + openSpotlightAssistant: "mod+/", + openSpotlightPreferences: "mod+shift+?", openAssistant: "shift+a", }; diff --git a/packages/spotlight/src/components/actions/group-actions.tsx b/packages/spotlight/src/components/actions/group-actions.tsx index cc71591b9..56a12a3ad 100644 --- a/packages/spotlight/src/components/actions/group-actions.tsx +++ b/packages/spotlight/src/components/actions/group-actions.tsx @@ -1,19 +1,17 @@ import { Center, Loader } from "@mantine/core"; -import { useWindowEvent } from "@mantine/hooks"; -import type { TranslationObject } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import type { SearchGroup } from "../../lib/group"; import type { inferSearchInteractionOptions } from "../../lib/interaction"; -import { SpotlightNoResults } from "../no-results"; +import type { SpotlightMode } from "../../open"; import { SpotlightGroupActionItem } from "./items/group-action-item"; interface GroupActionsProps> { group: SearchGroup; query: string; setQuery: (query: string) => void; - setMode: (mode: keyof TranslationObject["search"]["mode"]) => void; + setMode: (mode: SpotlightMode) => void; setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; } @@ -30,11 +28,6 @@ export const SpotlightGroupActions = >({ const options = useOptions(query); const t = useI18n(); - useWindowEvent("keydown", (event) => { - const optionsArray = Array.isArray(options) ? options : (options.data ?? []); - group.onKeyDown?.(event, optionsArray, query, { setChildrenOptions }); - }); - if (Array.isArray(options)) { if (options.length === 0) { return null; @@ -51,7 +44,7 @@ export const SpotlightGroupActions = >({ }); if (filteredOptions.length === 0) { - return ; + return null; } return filteredOptions.map((option) => ( @@ -69,14 +62,18 @@ export const SpotlightGroupActions = >({ if (options.isLoading) { return ( -
+
); } if (options.isError) { - return
{t("search.error.fetch")}
; + return ( +
+ {t("search.error.fetch")} +
+ ); } if (!options.data) { @@ -84,7 +81,7 @@ export const SpotlightGroupActions = >({ } if (options.data.length === 0) { - return ; + return null; } return options.data.map((option) => ( diff --git a/packages/spotlight/src/components/actions/groups/action-group.tsx b/packages/spotlight/src/components/actions/groups/action-group.tsx index 2b3e282ec..90cc7f2fd 100644 --- a/packages/spotlight/src/components/actions/groups/action-group.tsx +++ b/packages/spotlight/src/components/actions/groups/action-group.tsx @@ -1,18 +1,18 @@ import { Spotlight } from "@mantine/spotlight"; -import type { TranslationObject } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import type { SearchGroup } from "../../../lib/group"; import type { inferSearchInteractionOptions } from "../../../lib/interaction"; +import type { SpotlightMode } from "../../../open"; import { SpotlightGroupActions } from "../group-actions"; interface SpotlightActionGroupsProps { groups: SearchGroup[]; query: string; setQuery: (query: string) => void; - setMode: (mode: keyof TranslationObject["search"]["mode"]) => void; + setMode: (mode: SpotlightMode) => void; setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; } @@ -23,6 +23,8 @@ export const SpotlightActionGroups = ({ groups, ...others }: SpotlightActionGrou {/*eslint-disable-next-line @typescript-eslint/no-explicit-any */} group={group} {...others} /> diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx index 700b106ca..f0874172a 100644 --- a/packages/spotlight/src/components/actions/items/group-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx @@ -1,11 +1,11 @@ import { Spotlight } from "@mantine/spotlight"; import { getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common"; -import type { TranslationObject } from "@homarr/translation"; import { Link } from "@homarr/ui"; import type { SearchGroup } from "../../../lib/group"; import type { inferSearchInteractionOptions } from "../../../lib/interaction"; +import type { SpotlightMode } from "../../../open"; import { selectAction, spotlightStore } from "../../../spotlight-store"; import classes from "./action-item.module.css"; @@ -13,7 +13,7 @@ interface SpotlightGroupActionItemProps> option: TOption; query: string; setQuery: (query: string) => void; - setMode: (mode: keyof TranslationObject["search"]["mode"]) => void; + setMode: (mode: SpotlightMode) => void; setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; group: SearchGroup; } @@ -34,7 +34,7 @@ export const SpotlightGroupActionItem = const safeHref = interaction.type === "link" ? getSafeAppHref(interaction.href) : undefined; const renderRoot = - interaction.type === "link" && safeHref + interaction.type === "link" && safeHref && !unavailable ? (props: Record) => { return ( : undefined; const handleClickAsync = async () => { + if (unavailable) return; + if (interaction.type === "javaScript") { await interaction.onSelect(); } else if (interaction.type === "setQuery") { @@ -76,6 +78,7 @@ export const SpotlightGroupActionItem = } className={classes.spotlightAction} aria-disabled={unavailable || undefined} + disabled={unavailable} > diff --git a/packages/spotlight/src/components/mode-rail.tsx b/packages/spotlight/src/components/mode-rail.tsx new file mode 100644 index 000000000..03d747269 --- /dev/null +++ b/packages/spotlight/src/components/mode-rail.tsx @@ -0,0 +1,80 @@ +import { Group, Kbd, ScrollArea, Text, UnstyledButton } from "@mantine/core"; +import { + IconApps, + IconCommand, + IconMovie, + IconRobot, + IconSearch, + IconSettings, + IconUsers, + IconWorldSearch, +} from "@tabler/icons-react"; + +import { useSession } from "@homarr/auth/client"; + +import type { SpotlightMode } from "../open"; +import classes from "./spotlight.module.css"; + +interface ModeRailEntry { + ariaLabel: string; + character?: string; + label: string; + mode: SpotlightMode; +} + +interface SpotlightModeRailProps { + activeMode: SpotlightMode; + entries: ModeRailEntry[]; + navigationLabel: string; + onModeChange: (mode: SpotlightMode) => void; +} + +const modeIcons: Record = { + search: IconSearch, + apps: IconApps, + command: IconCommand, + preferences: IconSettings, + assistant: IconRobot, + external: IconWorldSearch, + media: IconMovie, + userGroup: IconUsers, +}; + +export const SpotlightModeRail = ({ activeMode, entries, navigationLabel, onModeChange }: SpotlightModeRailProps) => { + const { data: session } = useSession(); + const canManageUsers = session?.user.permissions.includes("admin") ?? false; + + return ( + + + {entries.map((entry) => { + if (entry.mode === "userGroup" && !canManageUsers) return null; + const Icon = modeIcons[entry.mode]; + const active = entry.mode === activeMode; + + return ( + onModeChange(entry.mode)} + > + + + {entry.label} + + {entry.character ? ( + + {entry.character} + + ) : null} + + ); + })} + + + ); +}; diff --git a/packages/spotlight/src/components/no-results.tsx b/packages/spotlight/src/components/no-results.tsx index e5fc52f99..f4d8b8137 100644 --- a/packages/spotlight/src/components/no-results.tsx +++ b/packages/spotlight/src/components/no-results.tsx @@ -2,8 +2,8 @@ import { Spotlight } from "@mantine/spotlight"; import { useI18n } from "@homarr/translation/client"; -export const SpotlightNoResults = () => { +export const SpotlightNoResults = ({ className }: { className?: string }) => { const t = useI18n(); - return {t("search.nothingFound")}; + return {t("search.nothingFound")}; }; diff --git a/packages/spotlight/src/components/spotlight.module.css b/packages/spotlight/src/components/spotlight.module.css new file mode 100644 index 000000000..cc71a7c1e --- /dev/null +++ b/packages/spotlight/src/components/spotlight.module.css @@ -0,0 +1,106 @@ +.content { + overflow: hidden; + border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + box-shadow: var(--mantine-shadow-xl); +} + +.search { + border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); +} + +.modeRailViewport { + border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); +} + +.modeRail { + min-width: max-content; + padding: var(--mantine-spacing-xs); +} + +.modeButton { + display: flex; + align-items: center; + gap: 4px; + min-height: 30px; + padding-inline: 6px; + border-radius: var(--mantine-radius-sm); + color: var(--mantine-color-dimmed); + transition: + color 100ms ease, + background-color 100ms ease; +} + +.modeKey { + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font-size: 10px; +} + +.modeButton:hover { + color: var(--mantine-color-text); + background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6)); +} + +.modeButton[data-active] { + color: var(--mantine-primary-color-filled); + background: var(--mantine-primary-color-light); +} + +.modeButton:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; +} + +.actionsList { + padding-block: 4px; +} + +.actionsList [data-search-source="fallback"] { + margin-top: var(--mantine-spacing-xs); + padding-top: var(--mantine-spacing-xs); + border-top: 1px dashed light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); +} + +.actionsList > [data-search-source="fallback"]:first-child { + margin-top: 0; + padding-top: 0; + border-top: 0; +} + +.actionsList [data-search-loading] ~ [data-search-loading] { + display: none; +} + +.actionsList:has([data-action]) .emptyState, +.actionsList:has([data-search-loading]) .emptyState, +.actionsList:has([data-search-status]) .emptyState { + display: none; +} + +.footer { + min-height: 42px; + border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); +} + +.footerHint { + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4)); +} + +@media (max-width: 48em) { + .modeLabel { + display: none; + } + + .footerSecondary { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .modeButton { + transition: none; + } +} diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx index 6ed32c5be..52aaf4ee3 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -2,13 +2,12 @@ import type { Dispatch, SetStateAction } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActionIcon, Center, Group, Kbd } from "@mantine/core"; +import { ActionIcon, Center, Group, Kbd, Text } from "@mantine/core"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; -import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react"; +import { IconArrowDown, IconArrowLeft, IconArrowUp, IconCornerDownLeft, IconSearch, IconX } from "@tabler/icons-react"; import { useSetAtom } from "jotai"; -import { hotkeys } from "@homarr/definitions"; -import type { TranslationObject } from "@homarr/translation"; +import { translateIfNecessary } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { @@ -17,74 +16,139 @@ import { mediaRequestSearchEvent, spotlightOpenEvent, } from "../open"; -import type { OpenMediaRequestSearchOptions } from "../open"; +import type { OpenMediaRequestSearchOptions, SpotlightMode, SpotlightOpenIntent } from "../open"; import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { SearchMode } from "../lib/mode"; import { searchModes } from "../modes"; -import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups"; -import { contextSpecificSearchGroups } from "../modes/home/context-specific-group"; import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store"; import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightActionGroups } from "./actions/groups/action-group"; +import { SpotlightModeRail } from "./mode-rail"; +import { SpotlightNoResults } from "./no-results"; +import classes from "./spotlight.module.css"; -type SearchModeKey = keyof TranslationObject["search"]["mode"]; type ChildrenOptions = inferSearchInteractionOptions<"children">; -const defaultMode = "home"; -export const Spotlight = () => { - const searchModeState = useState(defaultMode); - const queryState = useState(""); - const mode = searchModeState[0]; - const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]); +const defaultMode: SpotlightMode = "search"; - if (!activeMode) { - return null; - } +const resetSelection = () => { + requestAnimationFrame(() => selectAction(0, spotlightStore)); +}; + +export const Spotlight = () => { + const [mode, setMode] = useState(defaultMode); + const [query, setQuery] = useState(""); + const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom); + const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.mode === mode), [mode]); + + const applyOpenOptions = useCallback( + (options: SpotlightOpenIntent) => { + setMode(options.mode); + setQuery(options.query ?? ""); + if (options.mode !== "media") setMediaRequestSearchScope({}); + spotlightActions.open(); + resetSelection(); + }, + [setMediaRequestSearchScope], + ); + + useEffect(() => { + const handleOpen = (event: Event) => { + const pendingOptions = consumePendingSpotlightOpen(); + const eventOptions = (event as CustomEvent).detail; + applyOpenOptions(eventOptions ?? pendingOptions ?? { mode: defaultMode }); + }; + + window.addEventListener(spotlightOpenEvent, handleOpen); + const pendingOptions = consumePendingSpotlightOpen(); + if (pendingOptions) applyOpenOptions(pendingOptions); + return () => window.removeEventListener(spotlightOpenEvent, handleOpen); + }, [applyOpenOptions]); + + useEffect(() => { + const applyMediaRequestSearch = ({ integrationIds, query: mediaQuery }: OpenMediaRequestSearchOptions) => { + setMediaRequestSearchScope({ integrationIds }); + applyOpenOptions({ mode: "media", query: mediaQuery }); + }; + + const handleMediaRequestSearch = (event: Event) => { + const pendingOptions = consumePendingMediaRequestSearch(); + const eventOptions = (event as CustomEvent).detail; + applyMediaRequestSearch(eventOptions ?? pendingOptions ?? {}); + }; + + window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch); + const pendingOptions = consumePendingMediaRequestSearch(); + if (pendingOptions) applyMediaRequestSearch(pendingOptions); + return () => window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch); + }, [applyOpenOptions, setMediaRequestSearchScope]); + + if (!activeMode) return null; return ( - + ); }; interface SpotlightWithActiveModeProps { - modeState: [SearchModeKey, Dispatch>]; - queryState: [string, Dispatch>]; + mode: SpotlightMode; + setMode: Dispatch>; + query: string; + setQuery: Dispatch>; activeMode: SearchMode; } -const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => { - const [query, setQuery] = queryState; - const [mode, setMode] = modeState; +const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }: SpotlightWithActiveModeProps) => { const [childrenStack, setChildrenStack] = useState([]); const childrenOptions = childrenStack.at(-1) ?? null; const t = useI18n(); const inputRef = useRef(null); - const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom); const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups; const groups = useGroups(); - const homeEmptyGroups = useHomeEmptyGroupsWithPreferences(); - const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined; + const hasModeCharacter = activeMode.character !== undefined; + const activeModeLabel = translateIfNecessary(t, activeMode.label) ?? activeMode.mode; + const placeholder = translateIfNecessary(t, activeMode.placeholder) ?? t("search.placeholder"); + const modeRailEntries = searchModes.map((searchMode) => { + const label = translateIfNecessary(t, searchMode.label) ?? searchMode.mode; + return { + mode: searchMode.mode, + character: searchMode.character, + label, + ariaLabel: t("search.modePicker.modeAriaLabel", { mode: label }), + }; + }); useEffect(() => { - const handleOpen = () => { - consumePendingSpotlightOpen(); - spotlightActions.open(); - }; - - window.addEventListener(spotlightOpenEvent, handleOpen); - if (consumePendingSpotlightOpen()) spotlightActions.open(); - return () => window.removeEventListener(spotlightOpenEvent, handleOpen); + requestAnimationFrame(() => inputRef.current?.focus()); }, []); const clearChildrenStack = useCallback(() => { setChildrenStack([]); }, []); + const changeMode = useCallback( + (nextMode: SpotlightMode, preserveQuery = true) => { + setMode(nextMode); + clearChildrenStack(); + if (!preserveQuery) setQuery(""); + resetSelection(); + requestAnimationFrame(() => inputRef.current?.focus()); + }, + [clearChildrenStack, setMode, setQuery], + ); + const pushChildrenOptions = useCallback( (options: ChildrenOptions) => { setChildrenStack((currentStack) => [...currentStack, options]); setQuery(""); - setTimeout(() => selectAction(0, spotlightStore)); + resetSelection(); }, [setQuery], ); @@ -92,44 +156,19 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh const popChildrenOptions = useCallback(() => { setChildrenStack((currentStack) => currentStack.slice(0, -1)); setQuery(""); - setTimeout(() => selectAction(0, spotlightStore)); + resetSelection(); }, [setQuery]); - useEffect(() => { - const applyMediaRequestSearch = ({ integrationIds, query: searchQuery }: OpenMediaRequestSearchOptions) => { - setMediaRequestSearchScope({ integrationIds }); - setMode("media"); - clearChildrenStack(); - setQuery(searchQuery ?? ""); - spotlightActions.open(); - setTimeout(() => selectAction(0, spotlightStore)); - }; - - const handleMediaRequestSearch = (event: Event) => { - consumePendingMediaRequestSearch(); - const { integrationIds, query: searchQuery } = (event as CustomEvent).detail ?? {}; - applyMediaRequestSearch({ integrationIds, query: searchQuery }); - }; - - window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch); - const pendingSearch = consumePendingMediaRequestSearch(); - if (pendingSearch) applyMediaRequestSearch(pendingSearch); - return () => { - window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch); - }; - }, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]); - - useEffect(() => { - if (mode !== "media") { - setMediaRequestSearchScope({}); - } - }, [mode, setMediaRequestSearchScope]); - return ( { requestAnimationFrame(() => inputRef.current?.focus()); }} @@ -143,22 +182,24 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery; setQuery(sanitizedQuery); - if (sanitizedQuery.length !== 1) return; + if (sanitizedQuery.length === 1) { + const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery); + if (modeToActivate) { + changeMode(modeToActivate.mode, false); + return; + } + } - const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery); - if (!modeToActivate) return; - - setMode(modeToActivate.modeKey); - clearChildrenStack(); - setQuery(""); - setTimeout(() => selectAction(0, spotlightStore)); + resetSelection(); }} store={spotlightStore} > @@ -168,11 +209,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh {hasModeCharacter ? {activeMode.character} : null} } - styles={{ - section: { - pointerEvents: "all", - }, - }} + styles={{ section: { pointerEvents: "all" } }} rightSection={ mode === defaultMode && childrenStack.length === 0 ? null : ( + + {childrenOptions ? ( ) : null} - + {childrenOptions ? ( { - setMode(nextMode); - clearChildrenStack(); - setTimeout(() => selectAction(0, spotlightStore)); - }} - setChildrenOptions={(options) => { - pushChildrenOptions(options); - }} + setMode={(nextMode) => changeMode(nextMode, true)} + setChildrenOptions={pushChildrenOptions} query={query} - groups={ - mode === defaultMode && query.length === 0 ? [...homeEmptyGroups, contextSpecificSearchGroups] : groups - } + groups={groups} /> )} + + + + + + + + + + + + + + {t("search.modePicker.footer.navigate")} + + + + + + + + {t("search.modePicker.footer.open")} + + + + + esc + + {t("search.modePicker.footer.close")} + + + + ); }; diff --git a/packages/spotlight/src/index.ts b/packages/spotlight/src/index.ts index 706480f7c..2b2fd40f6 100644 --- a/packages/spotlight/src/index.ts +++ b/packages/spotlight/src/index.ts @@ -6,3 +6,4 @@ export { useRegisterSpotlightContextResults, useRegisterSpotlightContextActions, } from "./modes/home/context"; +export type { ContextSpecificItem } from "./modes/home/context"; diff --git a/packages/spotlight/src/lib/group.ts b/packages/spotlight/src/lib/group.ts index d68a1e05c..2c0e620ec 100644 --- a/packages/spotlight/src/lib/group.ts +++ b/packages/spotlight/src/lib/group.ts @@ -2,26 +2,34 @@ import type { JSX } from "react"; import type { stringOrTranslation } from "@homarr/translation"; -import type { inferSearchInteractionDefinition, inferSearchInteractionOptions, SearchInteraction } from "./interaction"; +import type { inferSearchInteractionDefinition, SearchInteraction } from "./interaction"; -type CommonSearchGroup, TOptionProps extends Record> = { +export type RemoteSearchSource = + | "apps" + | "boards" + | "integrations" + | "users" + | "groups" + | "search-engines" + | "integration-search" + | "media"; + +export type SearchGroupSource = + | { kind: "local" } + | { kind: "remote"; source: RemoteSearchSource } + | { kind: "fallback" }; + +type CommonSearchGroup = { // key path is used to define the path to a unique key in the option object keyPath: keyof TOption; title: stringOrTranslation; + source?: SearchGroupSource; Component: (option: TOption) => JSX.Element; useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition; - onKeyDown?: ( - event: KeyboardEvent, - options: TOption[], - query: string, - actions: { - setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; - }, - ) => void; } & TOptionProps; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type SearchGroup = any> = +export type SearchGroup = | CommonSearchGroup boolean; options: TOption[] }> | CommonSearchGroup< TOption, @@ -36,4 +44,4 @@ export type SearchGroup = any> = { useQueryOptions: (query: string) => { data: TOption[] | undefined; isLoading: boolean; isError: boolean } } >; -export const createGroup = >(group: SearchGroup) => group; +export const createGroup = (group: SearchGroup) => group; diff --git a/packages/spotlight/src/lib/interaction.ts b/packages/spotlight/src/lib/interaction.ts index c4b8ca212..caa0600a7 100644 --- a/packages/spotlight/src/lib/interaction.ts +++ b/packages/spotlight/src/lib/interaction.ts @@ -1,7 +1,7 @@ import type { MaybePromise } from "@homarr/common/types"; -import type { TranslationObject } from "@homarr/translation"; import type { CreateChildrenOptionsProps } from "./children"; +import type { SpotlightMode } from "../open"; const createSearchInteraction = (type: TType) => ({ optionsType: | undefined>() => ({ type, _inferOptions: {} as TOption }), @@ -15,7 +15,7 @@ const searchInteractions = [ closeSpotlightOnTrigger?: boolean; }>(), createSearchInteraction("setQuery").optionsType<{ query: string }>(), - createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(), + createSearchInteraction("mode").optionsType<{ mode: SpotlightMode; query?: string }>(), createSearchInteraction("children").optionsType<{ // eslint-disable-next-line @typescript-eslint/no-explicit-any useActions: CreateChildrenOptionsProps["useActions"]; diff --git a/packages/spotlight/src/lib/mode.ts b/packages/spotlight/src/lib/mode.ts index 54b7e5ffc..f0f3dcfda 100644 --- a/packages/spotlight/src/lib/mode.ts +++ b/packages/spotlight/src/lib/mode.ts @@ -1,10 +1,13 @@ -import type { TranslationObject } from "@homarr/translation"; +import type { stringOrTranslation } from "@homarr/translation"; import type { SearchGroup } from "./group"; +import type { SpotlightMode } from "../open"; export type SearchMode = { - modeKey: keyof TranslationObject["search"]["mode"]; + mode: SpotlightMode; character: string | undefined; + label: stringOrTranslation; + placeholder: stringOrTranslation; } & ( | { groups: SearchGroup[]; diff --git a/packages/spotlight/src/lib/remote-query.ts b/packages/spotlight/src/lib/remote-query.ts new file mode 100644 index 000000000..e215cd40e --- /dev/null +++ b/packages/spotlight/src/lib/remote-query.ts @@ -0,0 +1,46 @@ +"use client"; + +import { useDebouncedValue } from "@mantine/hooks"; + +import type { RemoteSearchSource } from "./group"; + +const remoteSearchPolicy: Record = { + apps: { delay: 140, minimumLength: 1 }, + boards: { delay: 140, minimumLength: 1 }, + integrations: { delay: 160, minimumLength: 1 }, + users: { delay: 180, minimumLength: 1 }, + groups: { delay: 180, minimumLength: 1 }, + "search-engines": { delay: 160, minimumLength: 1 }, + "integration-search": { delay: 220, minimumLength: 1 }, + media: { delay: 220, minimumLength: 1 }, +}; + +interface RemoteQueryOptions { + minimumLength?: number; + trim?: boolean; +} + +export interface RemoteQuery { + enabled: boolean; + query: string; +} + +/** + * Keeps every network-backed spotlight provider on the same query cadence. Local providers still + * receive the immediate query and therefore render before remote results can replace the selection. + */ +export const useRemoteQuery = ( + query: string, + source: RemoteSearchSource, + options: RemoteQueryOptions = {}, +): RemoteQuery => { + const policy = remoteSearchPolicy[source]; + const minimumLength = options.minimumLength ?? policy.minimumLength; + const normalizedQuery = options.trim === false ? query : query.trim(); + const [debouncedQuery] = useDebouncedValue(normalizedQuery, policy.delay); + + return { + enabled: debouncedQuery.length >= minimumLength, + query: debouncedQuery, + }; +}; diff --git a/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx index fa4cbd6eb..a86444deb 100644 --- a/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx +++ b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx @@ -7,6 +7,7 @@ import { useI18n } from "@homarr/translation/client"; import { createChildrenOptions } from "../../lib/children"; import { createGroup } from "../../lib/group"; import { interaction } from "../../lib/interaction"; +import { useRemoteQuery } from "../../lib/remote-query"; // This has to be type so it can be interpreted as Record. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -58,6 +59,7 @@ const appChildrenOptions = createChildrenOptions({ ({ export const appsSearchGroup = createGroup({ keyPath: "id", title: (t) => t("common.entity.apps"), + source: { kind: "remote", source: "apps" }, Component: (app) => ( ({ ), useInteraction: interaction.children(appChildrenOptions), useQueryOptions(query) { - return clientApi.app.search.useQuery({ query, limit: 5 }); + const remoteQuery = useRemoteQuery(query, "apps"); + return clientApi.app.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled }); }, }); diff --git a/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx index 7b602f134..a5d6fe299 100644 --- a/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx +++ b/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx @@ -8,6 +8,7 @@ import type { ChildrenAction } from "../../lib/children"; import { createChildrenOptions } from "../../lib/children"; import { createGroup } from "../../lib/group"; import { interaction } from "../../lib/interaction"; +import { useRemoteQuery } from "../../lib/remote-query"; // This has to be type so it can be interpreted as Record. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -126,6 +127,7 @@ const boardChildrenOptions = createChildrenOptions({ export const boardsSearchGroup = createGroup({ keyPath: "id", title: (t) => t("common.entity.boards"), + source: { kind: "remote", source: "boards" }, Component: (board) => ( {board.logoImageUrl ? ( @@ -139,6 +141,7 @@ export const boardsSearchGroup = createGroup({ ), useInteraction: interaction.children(boardChildrenOptions), useQueryOptions(query) { - return clientApi.board.search.useQuery({ query, limit: 5 }); + const remoteQuery = useRemoteQuery(query, "boards"); + return clientApi.board.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled }); }, }); diff --git a/packages/spotlight/src/modes/app-integration-board/index.tsx b/packages/spotlight/src/modes/app-integration-board/index.tsx index a773923ef..0be8ab42a 100644 --- a/packages/spotlight/src/modes/app-integration-board/index.tsx +++ b/packages/spotlight/src/modes/app-integration-board/index.tsx @@ -2,19 +2,22 @@ import { useSession } from "@homarr/auth/client"; import type { SearchGroup } from "../../lib/group"; import type { SearchMode } from "../../lib/mode"; +import { contextSpecificSearchGroups } from "../home/context-specific-group"; import { appsSearchGroup } from "./apps-search-group"; import { boardsSearchGroup } from "./boards-search-group"; import { integrationsSearchGroup } from "./integrations-search-group"; export const appIntegrationBoardMode = { - modeKey: "appIntegrationBoard", + mode: "apps", character: "#", + label: (t) => t("search.modePicker.apps.label"), + placeholder: (t) => t("search.modePicker.apps.placeholder"), useGroups() { const { data: session } = useSession(); - const groups: SearchGroup[] = [boardsSearchGroup]; + const groups: SearchGroup[] = [contextSpecificSearchGroups]; if (!session?.user) { - return groups; + return groups.concat(boardsSearchGroup); } // Mirrors the gate on app.search: either permission may browse the app catalogue, so an app @@ -22,6 +25,10 @@ export const appIntegrationBoardMode = { const canSearchApps = session.user.permissions.includes("board-modify-all") || session.user.permissions.includes("app-modify-all"); - return groups.concat(canSearchApps ? [appsSearchGroup, integrationsSearchGroup] : [integrationsSearchGroup]); + if (canSearchApps) { + return groups.concat(appsSearchGroup, boardsSearchGroup, integrationsSearchGroup); + } + + return groups.concat(boardsSearchGroup, integrationsSearchGroup); }, } satisfies SearchMode; diff --git a/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx index 177126f61..ff0e6cf17 100644 --- a/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx +++ b/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx @@ -6,10 +6,12 @@ import { IntegrationAvatar } from "@homarr/ui"; import { createGroup } from "../../lib/group"; import { interaction } from "../../lib/interaction"; +import { useRemoteQuery } from "../../lib/remote-query"; export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({ keyPath: "id", title: (t) => t("common.entity.integrations"), + source: { kind: "remote", source: "integrations" }, Component: (integration) => ( @@ -19,6 +21,10 @@ export const integrationsSearchGroup = createGroup<{ id: string; kind: Integrati ), useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })), useQueryOptions(query) { - return clientApi.integration.search.useQuery({ query, limit: 5 }); + const remoteQuery = useRemoteQuery(query, "integrations"); + return clientApi.integration.search.useQuery( + { query: remoteQuery.query, limit: 8 }, + { enabled: remoteQuery.enabled }, + ); }, }); diff --git a/packages/spotlight/src/modes/assistant/index.tsx b/packages/spotlight/src/modes/assistant/index.tsx new file mode 100644 index 000000000..dec4cf1a5 --- /dev/null +++ b/packages/spotlight/src/modes/assistant/index.tsx @@ -0,0 +1,10 @@ +import type { SearchMode } from "../../lib/mode"; +import { contextSpecificFallbackSearchGroup } from "../home/context-specific-group"; + +export const assistantMode = { + mode: "assistant", + character: undefined, + label: (t) => t("search.modePicker.assistant.label"), + placeholder: (t) => t("search.modePicker.assistant.placeholder"), + groups: [contextSpecificFallbackSearchGroup], +} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx index 5bd3f4705..6dbc2b00c 100644 --- a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx +++ b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx @@ -2,10 +2,10 @@ import { Group, Stack, Switch, Text } from "@mantine/core"; import type { DayOfWeek } from "@mantine/dates"; +import { IconChevronRight } from "@tabler/icons-react"; import dayjs from "dayjs"; import localeData from "dayjs/plugin/localeData"; -import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import type { ColorScheme } from "@homarr/definitions"; import { visiblePreferenceDefinitions } from "@homarr/settings"; @@ -47,15 +47,19 @@ const BooleanRow = ({ ); -const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel: string }) => ( +const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel?: string }) => ( {label} - - {valueLabel} - + {valueLabel ? ( + + {valueLabel} + + ) : ( + + )} ); @@ -68,7 +72,7 @@ const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => ( const SELECT_KINDS = new Set(["select", "searchEngine", "board"]); -const useSettingsActions = (_: Record, query: string): SettingsAction[] => { +export const useSettingsActions = (_: Record, query: string): SettingsAction[] => { const t = useI18n("search.mode.command.group.preferences.option"); const tUserField = useI18n("user.field"); const tColorScheme = useI18n("common.colorScheme.options"); @@ -77,25 +81,13 @@ const useSettingsActions = (_: Record, query: string): Settings const normalizedQuery = query.trim().toLowerCase(); const preferences = useUserPreferences(); - const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated }); - const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated }); - const valueLabelResolvers: Record string> = { colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme), locale: () => { const val = preferences.getPreference("locale").value; return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val); }, - defaultSearchEngineId: () => - searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value) - ?.label ?? t("searchEngine.none" as never), firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "", - homeBoardId: () => - boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ?? - t("board.none" as never), - mobileHomeBoardId: () => - boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ?? - t("board.none" as never), }; return visiblePreferenceDefinitions(isAuthenticated) @@ -134,9 +126,7 @@ const useSettingsActions = (_: Record, query: string): Settings if (!children) return null; return { key: definition.key, - Component: () => ( - - ), + Component: () => , useInteraction: interaction.children(children), }; } diff --git a/packages/spotlight/src/modes/command/context-specific-group.tsx b/packages/spotlight/src/modes/command/context-specific-group.tsx index c5cebd219..ae3850e00 100644 --- a/packages/spotlight/src/modes/command/context-specific-group.tsx +++ b/packages/spotlight/src/modes/command/context-specific-group.tsx @@ -7,6 +7,7 @@ import { useSpotlightContextActions } from "../home/context"; export const contextSpecificActionsSearchGroups = createGroup({ title: (t) => t("search.mode.command.group.localCommand.title"), keyPath: "id", + source: { kind: "local" }, Component(option) { const icon = typeof option.icon !== "string" ? ( diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx index 3a6f29dd8..58622ef47 100644 --- a/packages/spotlight/src/modes/command/global-group.tsx +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -36,7 +36,8 @@ type Command = export const globalCommandGroup = createGroup({ keyPath: "commandKey", - title: "Global commands", + title: (t) => t("search.mode.command.group.globalCommand.title"), + source: { kind: "local" }, useInteraction: (option, query) => option.useInteraction(option, query), Component: ({ icon: Icon, name }) => ( diff --git a/packages/spotlight/src/modes/command/index.tsx b/packages/spotlight/src/modes/command/index.tsx index 30d8db633..a40289668 100644 --- a/packages/spotlight/src/modes/command/index.tsx +++ b/packages/spotlight/src/modes/command/index.tsx @@ -4,7 +4,9 @@ import { globalCommandGroup } from "./global-group"; import { preferencesGroup } from "../preferences/groups"; export const commandMode = { - modeKey: "command", + mode: "command", character: ">", - groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup], + label: (t) => t("search.modePicker.command.label"), + placeholder: (t) => t("search.modePicker.command.placeholder"), + groups: [contextSpecificActionsSearchGroups, globalCommandGroup, preferencesGroup], } satisfies SearchMode; diff --git a/packages/spotlight/src/modes/external/index.tsx b/packages/spotlight/src/modes/external/index.tsx index 204d01e1d..7160648bb 100644 --- a/packages/spotlight/src/modes/external/index.tsx +++ b/packages/spotlight/src/modes/external/index.tsx @@ -2,7 +2,9 @@ import type { SearchMode } from "../../lib/mode"; import { searchEnginesSearchGroups } from "./search-engines-search-group"; export const externalMode = { - modeKey: "external", + mode: "external", character: "!", + label: (t) => t("search.modePicker.external.label"), + placeholder: (t) => t("search.modePicker.external.placeholder"), groups: [searchEnginesSearchGroups], } satisfies SearchMode; diff --git a/packages/spotlight/src/modes/external/search-engines-search-group.tsx b/packages/spotlight/src/modes/external/search-engines-search-group.tsx index e19104e6f..5f6add703 100644 --- a/packages/spotlight/src/modes/external/search-engines-search-group.tsx +++ b/packages/spotlight/src/modes/external/search-engines-search-group.tsx @@ -1,7 +1,5 @@ import { Group, Image, Kbd, Stack, Text } from "@mantine/core"; -import { useDebouncedValue } from "@mantine/hooks"; import { IconDownload, IconSearch } from "@tabler/icons-react"; -import { keepPreviousData } from "@tanstack/react-query"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; @@ -16,6 +14,7 @@ import { createChildrenOptions } from "../../lib/children"; import { createGroup } from "../../lib/group"; import type { inferSearchInteractionDefinition } from "../../lib/interaction"; import { interaction } from "../../lib/interaction"; +import { useRemoteQuery } from "../../lib/remote-query"; type SearchEngine = RouterOutputs["searchEngine"]["search"][number]; type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number]; @@ -187,7 +186,7 @@ export const mediaRequestsChildrenOptions = createChildrenOptions {options.result.image ? ( - + ) : ( )} @@ -206,11 +205,12 @@ export const mediaRequestsChildrenOptions = createChildrenOptions({ useActions: (searchEngine, query) => { + const remoteQuery = useRemoteQuery(query, "integration-search"); const { data } = clientApi.integration.searchInIntegration.useQuery( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - { integrationId: searchEngine.integrationId!, query }, + { integrationId: searchEngine.integrationId!, query: remoteQuery.query }, { - enabled: searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && query.length > 0, + enabled: remoteQuery.enabled && searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null, }, ); const { openSearchInNewTab } = useSettings(); @@ -244,7 +244,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions( return ( {searchResult.image ? ( - + ) : ( )} @@ -270,7 +270,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions( {options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")} - {options.name} + {options.name} @@ -296,36 +296,23 @@ const buildSearchUrl = (template: string, query: string) => { return template.replaceAll("%s", encoded); }; -export const searchEnginesSearchGroups = createGroup({ - keyPath: "key", - title: (t) => t("common.entity.searchEngines"), - Component: (option) => { - if (option.kind === "hint") { - return ( - - - - {option.label} - {option.description ? ( - - {option.description} - - ) : null} - - - ); - } +interface SearchEngineGroupOptions { + minimumLength: number; + showEmptyHint: boolean; + source: "focused" | "fallback"; +} - if (option.kind === "search") { - return ( - - - {option.iconUrl ? ( - {option.label} - ) : ( - - )} - +const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }: SearchEngineGroupOptions) => + createGroup({ + keyPath: "key", + title: (t) => t("common.entity.searchEngines"), + source: source === "focused" ? { kind: "remote", source: "search-engines" } : { kind: "fallback" }, + Component: (option) => { + if (option.kind === "hint") { + return ( + + + {option.label} {option.description ? ( @@ -334,22 +321,61 @@ export const searchEnginesSearchGroups = createGroup({ ) : null} + ); + } - !{option.bang} - - ); - } + if (option.kind === "search") { + return ( + + + {option.iconUrl ? ( + + ) : ( + + )} + + {option.label} + {option.description ? ( + + {option.description} + + ) : null} + + - if (option.kind === "engine") { - const { iconUrl, name, short, description } = option.engine; + !{option.bang} + + ); + } + + if (option.kind === "engine") { + const { iconUrl, name, short, description } = option.engine; + return ( + + + + + {name} + + {description} + + + + + !{short} + + ); + } + + const { s: name, t: short, d: domain } = option.bang; return ( - {name} + {name} - {description} + {domain ?? "DuckDuckGo bang"} @@ -357,138 +383,131 @@ export const searchEnginesSearchGroups = createGroup({ !{short} ); - } + }, + useInteraction(option, query) { + const { openSearchInNewTab } = useSettings(); + const { bangToken, searchText } = parseBangQuery(query); - const { s: name, t: short, d: domain } = option.bang; - return ( - - - - - {name} - - {domain ?? "DuckDuckGo bang"} - - - + if (option.kind === "search") { + return { + type: "link", + href: buildSearchUrl(option.urlTemplate, option.searchText), + newTab: openSearchInNewTab, + }; + } - !{short} - - ); - }, - useInteraction(option, query) { - const { openSearchInNewTab } = useSettings(); - const { bangToken, searchText } = parseBangQuery(query); + if (option.kind === "engine") { + const nextBang = option.engine.short; + const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `; + return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery }; + } - if (option.kind === "search") { - return { - type: "link", - href: buildSearchUrl(option.urlTemplate, option.searchText), - newTab: openSearchInNewTab, - }; - } + if (option.kind === "ddg") { + const nextBang = option.bang.t; + const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `; + return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery }; + } - if (option.kind === "engine") { - const nextBang = option.engine.short; - const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `; - return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery }; - } + return { type: "none" }; + }, + useQueryOptions(query) { + const tExternal = useI18n("search.mode.external.group.searchEngine"); + const { ddgBangs } = useSettings(); + const { bangToken, searchText, locked } = parseBangQuery(query); + const remoteQuery = useRemoteQuery(bangToken, "search-engines", { minimumLength }); - if (option.kind === "ddg") { - const nextBang = option.bang.t; - const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `; - return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery }; - } + const enginesQuery = clientApi.searchEngine.search.useQuery( + { query: remoteQuery.query, limit: 10 }, + { enabled: remoteQuery.enabled }, + ); - return { type: "none" }; - }, - useQueryOptions(query) { - const tExternal = useI18n("search.mode.external.group.searchEngine"); - const { ddgBangs } = useSettings(); - const { bangToken, searchText, locked } = parseBangQuery(query); - const [debouncedBangToken] = useDebouncedValue(bangToken, 150); + const ddgQuery = clientApi.bangs.search.useQuery( + { query: remoteQuery.query, limit: 10 }, + { + enabled: remoteQuery.enabled && ddgBangs && remoteQuery.query.length >= 1, + }, + ); - const enginesQuery = clientApi.searchEngine.search.useQuery( - { query: debouncedBangToken, limit: 10 }, - { placeholderData: keepPreviousData }, - ); + const isLoading = enginesQuery.isLoading || ddgQuery.isLoading; + const isError = enginesQuery.isError || ddgQuery.isError; - const ddgQuery = clientApi.bangs.search.useQuery( - { query: debouncedBangToken, limit: 10 }, - { - enabled: ddgBangs && debouncedBangToken.length >= 1, - placeholderData: keepPreviousData, - }, - ); - - const isLoading = enginesQuery.isLoading || ddgQuery.isLoading; - const isError = enginesQuery.isError || ddgQuery.isError; - - const engineOptions = (enginesQuery.data ?? []).map( - (engine): ExternalOption => ({ - key: `engine-${engine.short}`, - kind: "engine", - engine, - }), - ); - - const ddgOptions = (ddgQuery.data ?? []) - .filter((bang) => !engineOptions.some((option) => option.kind === "engine" && option.engine.short === bang.t)) - .map( - (bang): ExternalOption => ({ - key: `ddg-${bang.t}`, - kind: "ddg", - bang, + const engineOptions = (enginesQuery.data ?? []).map( + (engine): ExternalOption => ({ + key: `engine-${engine.short}`, + kind: "engine", + engine, }), ); - const searchActions: ExternalOption[] = []; - if (locked && bangToken.length > 0) { - const matchedEngine = (enginesQuery.data ?? []).find((engine) => engine.short === bangToken); - const matchedDdg = (ddgQuery.data ?? []).find((bang) => bang.t === bangToken); + const ddgOptions = (ddgQuery.data ?? []) + .filter((bang) => !engineOptions.some((option) => option.kind === "engine" && option.engine.short === bang.t)) + .map( + (bang): ExternalOption => ({ + key: `ddg-${bang.t}`, + kind: "ddg", + bang, + }), + ); - const label = matchedEngine?.name ?? matchedDdg?.s; - const iconUrl = matchedEngine?.iconUrl; - const urlTemplate = matchedEngine?.type === "generic" ? matchedEngine.urlTemplate : matchedDdg?.u; + const searchActions: ExternalOption[] = []; + if (locked && bangToken.length > 0) { + const matchedEngine = (enginesQuery.data ?? []).find((engine) => engine.short === bangToken); + const matchedDdg = (ddgQuery.data ?? []).find((bang) => bang.t === bangToken); - if (label && urlTemplate) { - if (searchText.trim().length > 0) { - searchActions.push({ - key: "search-action", - kind: "search", - label: tExternal("bang.searchWithQuery", { - query: searchText.trim(), - label, - }), - description: tExternal("bang.pressEnterToOpen"), - bang: bangToken, - iconUrl, - urlTemplate, - searchText: searchText.trim(), - }); - } else { - searchActions.push({ - key: "search-hint", - kind: "hint", - label: tExternal("bang.engineSelected", { label, bang: bangToken }), - description: tExternal("bang.typeQueryToContinue"), - }); + const label = matchedEngine?.name ?? matchedDdg?.s; + const iconUrl = matchedEngine?.iconUrl; + const urlTemplate = matchedEngine?.type === "generic" ? matchedEngine.urlTemplate : matchedDdg?.u; + + if (label && urlTemplate) { + if (searchText.trim().length > 0) { + searchActions.push({ + key: "search-action", + kind: "search", + label: tExternal("bang.searchWithQuery", { + query: searchText.trim(), + label, + }), + description: tExternal("bang.pressEnterToOpen"), + bang: bangToken, + iconUrl, + urlTemplate, + searchText: searchText.trim(), + }); + } else { + searchActions.push({ + key: "search-hint", + kind: "hint", + label: tExternal("bang.engineSelected", { label, bang: bangToken }), + description: tExternal("bang.typeQueryToContinue"), + }); + } } } - } - if (query.length === 0) { - searchActions.push({ - key: "hint", - kind: "hint", - label: tExternal("bang.emptyBangHint"), - description: ddgBangs ? tExternal("tipDdgBangs") : undefined, - }); - } + if (showEmptyHint && query.length === 0) { + searchActions.push({ + key: "hint", + kind: "hint", + label: tExternal("bang.emptyBangHint"), + description: ddgBangs ? tExternal("tipDdgBangs") : undefined, + }); + } - return { - isLoading, - isError, - data: [...searchActions, ...engineOptions, ...ddgOptions].slice(0, 10), - }; - }, + return { + isLoading, + isError, + data: [...searchActions, ...engineOptions, ...ddgOptions].slice(0, 10), + }; + }, + }); + +export const searchEnginesSearchGroups = createSearchEnginesSearchGroup({ + minimumLength: 0, + showEmptyHint: true, + source: "focused", +}); + +export const globalSearchEnginesSearchGroup = createSearchEnginesSearchGroup({ + minimumLength: 1, + showEmptyHint: false, + source: "fallback", }); diff --git a/packages/spotlight/src/modes/help/home-empty-groups.tsx b/packages/spotlight/src/modes/help/home-empty-groups.tsx deleted file mode 100644 index e417b47de..000000000 --- a/packages/spotlight/src/modes/help/home-empty-groups.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { Group, Kbd, Text } from "@mantine/core"; -import { - IconBook2, - IconBrandDiscord, - IconBrandGithub, - IconLayoutDashboard, - IconSearch, - IconSettings, -} from "@tabler/icons-react"; - -import { useSession } from "@homarr/auth/client"; -import { createDocumentationLink } from "@homarr/definitions"; -import { useI18n } from "@homarr/translation/client"; -import type { TablerIcon } from "@homarr/ui"; - -import { createGroup } from "../../lib/group"; -import { interaction } from "../../lib/interaction"; -import type { SearchMode } from "../../lib/mode"; -import { appIntegrationBoardMode } from "../app-integration-board"; -import { commandMode } from "../command"; -import { externalMode } from "../external"; -import { mediaMode } from "../media"; -import { pageMode } from "../page"; -import { preferencesGroup } from "../preferences/groups"; -import { userGroupMode } from "../user-group"; - -type QuickLinkOption = { - key: string; - icon: TablerIcon; - name: string; - path: string; -}; - -export const useHomeEmptyGroups = () => { - const { data: session } = useSession(); - const tPages = useI18n("search.mode.page.group.page.option"); - const tHelp = useI18n("management.navbar.items.help.items"); - const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode]; - - if (session?.user.permissions.includes("admin")) { - visibleSearchModes.unshift(userGroupMode); - } - - return [ - createGroup({ - keyPath: "key", - title: () => "Quick links", - options: (() => { - const quickLinks: QuickLinkOption[] = []; - - quickLinks.push({ - key: "manageBoards", - icon: IconLayoutDashboard, - path: "/manage/boards", - name: tPages("manageBoard.label"), - }); - - if (session?.user.permissions.includes("admin")) { - quickLinks.push({ - key: "manageSettings", - icon: IconSettings, - path: "/manage/settings", - name: tPages("manageSettings.label"), - }); - } - - if (session) { - quickLinks.push({ - key: "manageSearchEngines", - icon: IconSearch, - path: "/manage/search-engines", - name: tPages("manageSearchEngine.label"), - }); - } - - return quickLinks; - })(), - Component: ({ name, icon: Icon }) => ( - - - {name} - - ), - filter: () => true, - useInteraction: interaction.link(({ path }) => ({ href: path })), - }), - createGroup({ - keyPath: "modeKey", - title: (t) => t("search.mode.help.group.mode.title"), - options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })), - Component: ({ modeKey, character }) => { - const t = useI18n(`search.mode.${modeKey}`); - - return ( - - {t("help")} - {character && {character}} - - ); - }, - filter: () => true, - useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })), - }), - createGroup({ - keyPath: "href", - title: (t) => t("management.navbar.items.help.label"), - useOptions() { - return [ - { - href: createDocumentationLink("/docs/getting-started"), - icon: IconBook2, - label: tHelp("documentation"), - }, - { - href: "https://github.com/homarr-labs/homarr/issues/new/choose", - icon: IconBrandGithub, - label: tHelp("submitIssue"), - }, - { - href: "https://discord.com/invite/aCsmEV5RgA", - icon: IconBrandDiscord, - label: tHelp("discord"), - }, - ]; - }, - Component: (props) => ( - - - {props.label} - - ), - filter: () => true, - useInteraction: interaction.link(({ href }) => ({ href, newTab: true })), - }), - ] as const; -}; - -export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()]; diff --git a/packages/spotlight/src/modes/home/context-specific-group.tsx b/packages/spotlight/src/modes/home/context-specific-group.tsx index 2978b37d8..9c0dd326d 100644 --- a/packages/spotlight/src/modes/home/context-specific-group.tsx +++ b/packages/spotlight/src/modes/home/context-specific-group.tsx @@ -7,39 +7,72 @@ import { useSpotlightContextResults } from "./context"; export const shouldShowContextSpecificResult = (query: string, option: ContextSpecificItem) => { if (option.alwaysVisible) return true; if (query.length === 0) return false; - return option.name.toLowerCase().includes(query.toLowerCase()); + const normalizedQuery = query.trim().toLowerCase(); + const searchableValues = [option.name, option.description ?? "", ...(option.aliases ?? [])]; + return searchableValues.some((value) => value.toLowerCase().includes(normalizedQuery)); +}; + +const localResultSort = (query: string, [first, second]: [ContextSpecificItem, ContextSpecificItem]) => { + const normalizedQuery = query.trim().toLowerCase(); + const score = (option: ContextSpecificItem) => { + const normalizedName = option.name.toLowerCase(); + if (normalizedName === normalizedQuery) return 0; + if (normalizedName.startsWith(normalizedQuery)) return 1; + return 2; + }; + + return score(first) - score(second); +}; + +const renderContextSpecificItem = (option: ContextSpecificItem) => { + const icon = + typeof option.icon !== "string" ? ( + + ) : ( + + ); + + return ( + + {icon} + + {option.name} + {option.description ? ( + + {option.description} + + ) : null} + + + ); }; export const contextSpecificSearchGroups = createGroup({ title: (t) => t("search.mode.home.group.local.title"), keyPath: "id", - Component(option) { - const icon = - typeof option.icon !== "string" ? ( - - ) : ( - {option.name} - ); - - return ( - - {icon} - - {option.name} - {option.description && ( - - {option.description} - - )} - - - ); - }, + source: { kind: "local" }, + Component: renderContextSpecificItem, useInteraction(option, query) { return option.interaction(query); }, filter: shouldShowContextSpecificResult, + sort: localResultSort, useOptions() { - return useSpotlightContextResults(); + return useSpotlightContextResults().filter((item) => item.placement !== "fallback"); + }, +}); + +export const contextSpecificFallbackSearchGroup = createGroup({ + title: (t) => t("search.modePicker.continueWith"), + keyPath: "id", + source: { kind: "fallback" }, + Component: renderContextSpecificItem, + useInteraction(option, query) { + return option.interaction(query); + }, + filter: shouldShowContextSpecificResult, + sort: localResultSort, + useOptions() { + return useSpotlightContextResults().filter((item) => item.placement === "fallback"); }, }); diff --git a/packages/spotlight/src/modes/home/context.tsx b/packages/spotlight/src/modes/home/context.tsx index 697a8c220..1268608a6 100644 --- a/packages/spotlight/src/modes/home/context.tsx +++ b/packages/spotlight/src/modes/home/context.tsx @@ -11,6 +11,8 @@ export type ContextSpecificItem = { name: string; icon: TablerIcon | string; interaction: (query: string) => inferSearchInteractionDefinition; + aliases?: string[]; + placement?: "primary" | "fallback"; disabled?: boolean; description?: string; unavailable?: boolean; diff --git a/packages/spotlight/src/modes/home/home-search-engine-group.tsx b/packages/spotlight/src/modes/home/home-search-engine-group.tsx index 799d2216d..f78239576 100644 --- a/packages/spotlight/src/modes/home/home-search-engine-group.tsx +++ b/packages/spotlight/src/modes/home/home-search-engine-group.tsx @@ -12,6 +12,7 @@ import type { TablerIcon } from "@homarr/ui"; import { createGroup } from "../../lib/group"; import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; +import { useRemoteQuery } from "../../lib/remote-query"; import { useFromIntegrationSearchInteraction } from "../external/search-engines-search-group"; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -27,13 +28,14 @@ type GroupItem = { export const homeSearchEngineGroup = createGroup({ title: (t) => t("search.mode.home.group.search.title"), keyPath: "id", + source: { kind: "fallback" }, Component(item) { const icon = typeof item.icon !== "string" ? ( ) : ( - {item.name} + ); @@ -60,14 +62,15 @@ export const homeSearchEngineGroup = createGroup({ useQueryOptions(query) { const t = useI18n(); const { data: session, status } = useSession(); + const remoteQuery = useRemoteQuery(query, "integration-search"); const { data: defaultSearchEngine, ...defaultSearchEngineQuery } = clientApi.searchEngine.getDefaultSearchEngine.useQuery(undefined, { - enabled: status !== "loading", + enabled: status !== "loading" && remoteQuery.enabled, }); - const fromIntegrationEnabled = defaultSearchEngine?.type === "fromIntegration" && query.length > 0; + const fromIntegrationEnabled = defaultSearchEngine?.type === "fromIntegration" && remoteQuery.enabled; const { data: results, ...resultQuery } = clientApi.integration.searchInIntegration.useQuery( { - query, + query: remoteQuery.query, integrationId: defaultSearchEngine?.integrationId ?? "", }, { @@ -80,7 +83,7 @@ export const homeSearchEngineGroup = createGroup({ isLoading: defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading", isError: defaultSearchEngineQuery.isError || (resultQuery.isError && fromIntegrationEnabled), - data: createDefaultSearchEntries(defaultSearchEngine, results, session, query, t), + data: remoteQuery.enabled ? createDefaultSearchEntries(defaultSearchEngine, results, session, query, t) : [], }; }, }); diff --git a/packages/spotlight/src/modes/home/index.tsx b/packages/spotlight/src/modes/home/index.tsx index 67ef6db2a..d737f4335 100644 --- a/packages/spotlight/src/modes/home/index.tsx +++ b/packages/spotlight/src/modes/home/index.tsx @@ -1,10 +1,36 @@ import type { SearchMode } from "../../lib/mode"; -import { preferencesGroup } from "../preferences/groups"; -import { contextSpecificSearchGroups } from "./context-specific-group"; +import { appIntegrationBoardMode } from "../app-integration-board"; +import { contextSpecificActionsSearchGroups } from "../command/context-specific-group"; +import { globalCommandGroup } from "../command/global-group"; +import { globalSearchEnginesSearchGroup } from "../external/search-engines-search-group"; import { homeSearchEngineGroup } from "./home-search-engine-group"; +import { contextSpecificFallbackSearchGroup, contextSpecificSearchGroups } from "./context-specific-group"; +import { mediaFallbackGroup } from "../media/media-fallback-group"; +import { pagesSearchGroup } from "../page/pages-search-group"; +import { preferencesGroup } from "../preferences/groups"; +import { userGroupMode } from "../user-group"; export const homeMode = { - character: undefined, - modeKey: "home", - groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups], + mode: "search", + character: "/", + label: (t) => t("search.modePicker.search.label"), + placeholder: (t) => t("search.modePicker.search.placeholder"), + useGroups() { + const appGroups = appIntegrationBoardMode.useGroups().filter((group) => group !== contextSpecificSearchGroups); + const directoryGroups = userGroupMode.useGroups(); + + return [ + contextSpecificSearchGroups, + pagesSearchGroup, + contextSpecificActionsSearchGroups, + globalCommandGroup, + preferencesGroup, + ...appGroups, + ...directoryGroups, + globalSearchEnginesSearchGroup, + mediaFallbackGroup, + homeSearchEngineGroup, + contextSpecificFallbackSearchGroup, + ]; + }, } satisfies SearchMode; diff --git a/packages/spotlight/src/modes/index.tsx b/packages/spotlight/src/modes/index.tsx index 0c860069f..9865384d1 100644 --- a/packages/spotlight/src/modes/index.tsx +++ b/packages/spotlight/src/modes/index.tsx @@ -1,17 +1,19 @@ import { appIntegrationBoardMode } from "./app-integration-board"; +import { assistantMode } from "./assistant"; import { commandMode } from "./command"; import { externalMode } from "./external"; import { homeMode } from "./home"; import { mediaMode } from "./media"; -import { pageMode } from "./page"; +import { preferencesMode } from "./preferences"; import { userGroupMode } from "./user-group"; export const searchModes = [ - userGroupMode, - appIntegrationBoardMode, - externalMode, - commandMode, - pageMode, - mediaMode, homeMode, + appIntegrationBoardMode, + commandMode, + preferencesMode, + assistantMode, + externalMode, + mediaMode, + userGroupMode, ] as const; diff --git a/packages/spotlight/src/modes/media/index.tsx b/packages/spotlight/src/modes/media/index.tsx index 46b7a661f..f511d4b5d 100644 --- a/packages/spotlight/src/modes/media/index.tsx +++ b/packages/spotlight/src/modes/media/index.tsx @@ -2,7 +2,9 @@ import type { SearchMode } from "../../lib/mode"; import { mediaRequestSearchGroup } from "./media-request-search-group"; export const mediaMode = { - modeKey: "media", + mode: "media", character: undefined, + label: (t) => t("search.modePicker.media.label"), + placeholder: (t) => t("search.modePicker.media.placeholder"), groups: [mediaRequestSearchGroup], } satisfies SearchMode; diff --git a/packages/spotlight/src/modes/media/media-fallback-group.tsx b/packages/spotlight/src/modes/media/media-fallback-group.tsx new file mode 100644 index 000000000..fd7c56d25 --- /dev/null +++ b/packages/spotlight/src/modes/media/media-fallback-group.tsx @@ -0,0 +1,87 @@ +import { Group, Stack, Text } from "@mantine/core"; +import { IconMovie } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; +import { useI18n } from "@homarr/translation/client"; + +import { createGroup } from "../../lib/group"; +import { useRemoteQuery } from "../../lib/remote-query"; + +type MediaFallbackOption = { + key: string; + label: string; + description: string; + query: string; + unavailable?: boolean; +}; + +/** Discovers whether media search is usable, but intentionally never performs a media search. */ +export const mediaFallbackGroup = createGroup({ + keyPath: "key", + title: (t) => t("search.modePicker.continueWith"), + source: { kind: "fallback" }, + Component(option) { + return ( + + + + {option.label} + + {option.description} + + + + ); + }, + useInteraction(option) { + if (option.unavailable) return { type: "none" }; + return { type: "mode", mode: "media", query: option.query }; + }, + useQueryOptions(query) { + const t = useI18n(); + const remoteQuery = useRemoteQuery(query, "media"); + const { data: session, status } = useSession(); + const canDiscoverTargets = remoteQuery.enabled && Boolean(session?.user); + const targets = clientApi.integration.mediaRequestSearchTargets.useQuery(undefined, { + enabled: canDiscoverTargets, + staleTime: 60_000, + }); + + if (!remoteQuery.enabled) return { data: [], isLoading: false, isError: false }; + if (status === "loading") return { data: [], isLoading: true, isError: false }; + if (!session?.user) { + return { + data: [ + { + key: "media-fallback", + label: t("search.modePicker.media.fallback", { query: remoteQuery.query }), + description: t("search.mode.media.action.search.disabled.noIntegration"), + query: remoteQuery.query, + unavailable: true, + }, + ], + isLoading: false, + isError: false, + }; + } + if (!targets.data) return { data: [], isLoading: targets.isLoading, isError: targets.isError }; + + const unavailable = targets.data.length === 0; + return { + isLoading: targets.isLoading, + isError: targets.isError, + data: [ + { + key: "media-fallback", + label: t("search.modePicker.media.fallback", { query: remoteQuery.query }), + description: unavailable + ? t("search.mode.media.action.search.disabled.noIntegration") + : t("search.mode.media.action.search.description"), + query: remoteQuery.query, + unavailable, + }, + ], + }; + }, +}); diff --git a/packages/spotlight/src/modes/media/media-request-search-group.tsx b/packages/spotlight/src/modes/media/media-request-search-group.tsx index 1af19a904..13a2f15d9 100644 --- a/packages/spotlight/src/modes/media/media-request-search-group.tsx +++ b/packages/spotlight/src/modes/media/media-request-search-group.tsx @@ -1,17 +1,17 @@ import { Badge, Group, Image, Stack, Text } from "@mantine/core"; -import { useDebouncedValue } from "@mantine/hooks"; import { IconMovie, IconSearch } from "@tabler/icons-react"; -import { keepPreviousData } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; import { getIntegrationName } from "@homarr/definitions"; import type { MediaAvailability } from "@homarr/integrations/types"; import { mediaAvailabilityConfiguration } from "@homarr/integrations/types"; import { useI18n } from "@homarr/translation/client"; import { createGroup } from "../../lib/group"; +import { useRemoteQuery } from "../../lib/remote-query"; import { mediaRequestSearchScopeAtom } from "../../spotlight-store"; import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group"; @@ -23,7 +23,14 @@ type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequest type MediaRequestSearchOption = | { key: string; - kind: "disabled" | "hint"; + kind: "disabled"; + label: string; + description: string; + unavailable: true; + } + | { + key: string; + kind: "hint"; label: string; description: string; } @@ -36,6 +43,7 @@ type MediaRequestSearchOption = export const mediaRequestSearchGroup = createGroup({ keyPath: "key", title: (t) => t("search.mode.media.group.title"), + source: { kind: "remote", source: "media" }, Component(option) { const tMedia = useI18n("search.mode.media"); @@ -66,7 +74,7 @@ export const mediaRequestSearchGroup = createGroup({ return ( {result.image ? ( - + ) : ( )} @@ -98,21 +106,24 @@ export const mediaRequestSearchGroup = createGroup({ useQueryOptions(query) { const tMedia = useI18n("search.mode.media"); const scope = useAtomValue(mediaRequestSearchScopeAtom); - const [debouncedQuery] = useDebouncedValue(query.trim(), 150); - const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery(); + const remoteQuery = useRemoteQuery(query, "media"); + const { data: session, status } = useSession(); + const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery(undefined, { + enabled: status !== "loading" && Boolean(session?.user), + staleTime: 60_000, + }); const scopedTargets = (targetsQuery.data ?? []).filter((target) => scope.integrationIds ? scope.integrationIds.includes(target.id) : true, ); const hasScopedTargets = scopedTargets.length > 0; - const shouldSearch = debouncedQuery.length > 0 && hasScopedTargets; + const shouldSearch = remoteQuery.enabled && hasScopedTargets; const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined; const resultsQuery = clientApi.integration.searchMediaRequests.useQuery( - { query: debouncedQuery, integrationIds }, + { query: remoteQuery.query, integrationIds }, { enabled: shouldSearch, - placeholderData: keepPreviousData, select: (data) => data.slice(0, 10), }, ); @@ -120,6 +131,26 @@ export const mediaRequestSearchGroup = createGroup({ const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading); const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError); + if (status === "loading") { + return { data: [], isLoading: true, isError: false }; + } + + if (!session?.user) { + return { + data: [ + { + key: "disabled", + kind: "disabled", + label: tMedia("action.search.label"), + description: tMedia("action.search.disabled.noIntegration"), + unavailable: true, + }, + ], + isLoading: false, + isError: false, + }; + } + if (!targetsQuery.data) { return { isLoading, @@ -140,12 +171,13 @@ export const mediaRequestSearchGroup = createGroup({ description: scope.integrationIds ? tMedia("action.search.disabled.scoped") : tMedia("action.search.disabled.noIntegration"), + unavailable: true, }, ], }; } - if (debouncedQuery.length === 0) { + if (!remoteQuery.enabled) { return { isLoading, isError, diff --git a/packages/spotlight/src/modes/page/index.tsx b/packages/spotlight/src/modes/page/index.tsx deleted file mode 100644 index 39be2cb19..000000000 --- a/packages/spotlight/src/modes/page/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { SearchMode } from "../../lib/mode"; -import { pagesSearchGroup } from "./pages-search-group"; - -export const pageMode = { - modeKey: "page", - character: "/", - groups: [pagesSearchGroup], -} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/page/pages-search-group.tsx b/packages/spotlight/src/modes/page/pages-search-group.tsx index c51892fad..393fd1309 100644 --- a/packages/spotlight/src/modes/page/pages-search-group.tsx +++ b/packages/spotlight/src/modes/page/pages-search-group.tsx @@ -30,6 +30,7 @@ export const pagesSearchGroup = createGroup<{ }>({ keyPath: "path", title: (t) => t("search.mode.page.group.page.title"), + source: { kind: "local" }, Component: ({ name, icon: Icon }) => ( diff --git a/packages/spotlight/src/modes/preferences/groups.tsx b/packages/spotlight/src/modes/preferences/groups.tsx index 75ff85f66..ea86af3e7 100644 --- a/packages/spotlight/src/modes/preferences/groups.tsx +++ b/packages/spotlight/src/modes/preferences/groups.tsx @@ -1,90 +1,22 @@ -import { Group, Text } from "@mantine/core"; -import { IconSettings } from "@tabler/icons-react"; - -import { useSession } from "@homarr/auth/client"; -import { colorSchemes } from "@homarr/definitions"; -import type { UserPreferenceKey } from "@homarr/settings"; -import { visiblePreferenceDefinitions } from "@homarr/settings"; -import { useI18n } from "@homarr/translation/client"; - import { createGroup } from "../../lib/group"; -import { - preferenceChildrenOptionsByKey, - preferenceIcons, - userFieldPreferenceLabels, -} from "../../preferences/preference-registry"; -import { settingsChildrenOptions } from "../command/children/preferences/settings-children"; +import type { ChildrenAction } from "../../lib/children"; +import { useSettingsActions } from "../command/children/preferences/settings-children"; -type PreferencesGroupOption = - | { key: "settings"; preferenceKey: null } - | { key: UserPreferenceKey; preferenceKey: UserPreferenceKey }; +type PreferenceAction = ChildrenAction>; -const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null }; - -export const preferencesGroup = createGroup({ +/** Preferences are normal command-menu actions, not a gateway into another hidden list. */ +export const preferencesGroup = createGroup({ keyPath: "key", title: (t) => t("search.mode.command.group.preferences.title"), + source: { kind: "local" }, useOptions(query) { - const t = useI18n(); - const tColorScheme = useI18n("common.colorScheme.options"); - const tUserField = useI18n("user.field"); - const { data: session } = useSession(); - const definitions = visiblePreferenceDefinitions(Boolean(session?.user)); - const q = query.trim().toLowerCase(); - - if (q.length === 0) return [settingsOption]; - - const matching = definitions.flatMap((def): PreferencesGroupOption[] => { - if (!preferenceChildrenOptionsByKey[def.key]) return []; - const userFieldKey = userFieldPreferenceLabels[def.key]; - const label = userFieldKey - ? tUserField(`${userFieldKey}.label` as never) - : t(`search.mode.command.group.preferences.option.${def.key}.label` as never); - const searchValues = [ - label, - ...def.aliases, - ...(def.key === "colorScheme" ? colorSchemes.map((s) => tColorScheme(s)) : []), - ]; - if (!searchValues.some((v) => v.toLowerCase().includes(q))) return []; - return [{ key: def.key, preferenceKey: def.key }]; - }); - - if (matching.length > 0) return matching; - - const allSearchValues = [ - t("search.mode.command.group.preferences.title"), - ...definitions.flatMap((d) => d.aliases), - ]; - return allSearchValues.some((v) => v.toLowerCase().includes(q)) ? [settingsOption] : []; + return useSettingsActions({}, query); }, - Component: (option) => { - const t = useI18n("search.mode.command.group.preferences"); - const tOption = useI18n("search.mode.command.group.preferences.option"); - const tUserField = useI18n("user.field"); - const Icon = option.preferenceKey - ? preferenceIcons[option.preferenceKey as keyof typeof preferenceIcons] - : IconSettings; - let label = t("title"); - if (option.preferenceKey) { - const userFieldKey = userFieldPreferenceLabels[option.preferenceKey]; - label = userFieldKey - ? tUserField(`${userFieldKey}.label` as never) - : tOption(`${option.preferenceKey}.label` as never); - } - - return ( - - - {label} - - ); + Component(option) { + return ; + }, + useInteraction(option, query) { + return option.useInteraction({}, query); }, filter: () => true, - useInteraction: (option) => ({ - type: "children", - ...(option.preferenceKey - ? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ?? - settingsChildrenOptions({})) - : settingsChildrenOptions({})), - }), }); diff --git a/packages/spotlight/src/modes/preferences/index.tsx b/packages/spotlight/src/modes/preferences/index.tsx new file mode 100644 index 000000000..dda06c528 --- /dev/null +++ b/packages/spotlight/src/modes/preferences/index.tsx @@ -0,0 +1,10 @@ +import type { SearchMode } from "../../lib/mode"; +import { preferencesGroup } from "./groups"; + +export const preferencesMode = { + mode: "preferences", + character: "?", + label: (t) => t("search.modePicker.preferences.label"), + placeholder: (t) => t("search.modePicker.preferences.placeholder"), + groups: [preferencesGroup], +} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/user-group/groups-search-group.tsx b/packages/spotlight/src/modes/user-group/groups-search-group.tsx index 4f296e3ff..be20499e7 100644 --- a/packages/spotlight/src/modes/user-group/groups-search-group.tsx +++ b/packages/spotlight/src/modes/user-group/groups-search-group.tsx @@ -7,6 +7,7 @@ import { useI18n } from "@homarr/translation/client"; import { createChildrenOptions } from "../../lib/children"; import { createGroup } from "../../lib/group"; import { interaction } from "../../lib/interaction"; +import { useRemoteQuery } from "../../lib/remote-query"; // This has to be type so it can be interpreted as Record. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -71,6 +72,7 @@ const groupChildrenOptions = createChildrenOptions({ export const groupsSearchGroup = createGroup({ keyPath: "id", title: (t) => t("common.entity.groups"), + source: { kind: "remote", source: "groups" }, Component: ({ name }) => ( {name} @@ -78,6 +80,7 @@ export const groupsSearchGroup = createGroup({ ), useInteraction: interaction.children(groupChildrenOptions), useQueryOptions(query) { - return clientApi.group.search.useQuery({ query, limit: 5 }); + const remoteQuery = useRemoteQuery(query, "groups"); + return clientApi.group.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled }); }, }); diff --git a/packages/spotlight/src/modes/user-group/index.tsx b/packages/spotlight/src/modes/user-group/index.tsx index e9faa6ab5..71db54fd7 100644 --- a/packages/spotlight/src/modes/user-group/index.tsx +++ b/packages/spotlight/src/modes/user-group/index.tsx @@ -1,9 +1,17 @@ +import { useSession } from "@homarr/auth/client"; + import type { SearchMode } from "../../lib/mode"; import { groupsSearchGroup } from "./groups-search-group"; import { usersSearchGroup } from "./users-search-group"; export const userGroupMode = { - modeKey: "userGroup", + mode: "userGroup", character: "@", - groups: [usersSearchGroup, groupsSearchGroup], + label: (t) => t("search.modePicker.userGroup.label"), + placeholder: (t) => t("search.modePicker.userGroup.placeholder"), + useGroups() { + const { data: session } = useSession(); + if (!session?.user.permissions.includes("admin")) return []; + return [usersSearchGroup, groupsSearchGroup]; + }, } satisfies SearchMode; diff --git a/packages/spotlight/src/modes/user-group/users-search-group.tsx b/packages/spotlight/src/modes/user-group/users-search-group.tsx index 82a990200..59ac8677d 100644 --- a/packages/spotlight/src/modes/user-group/users-search-group.tsx +++ b/packages/spotlight/src/modes/user-group/users-search-group.tsx @@ -8,6 +8,7 @@ import { UserAvatar } from "@homarr/ui"; import { createChildrenOptions } from "../../lib/children"; import { createGroup } from "../../lib/group"; import { interaction } from "../../lib/interaction"; +import { useRemoteQuery } from "../../lib/remote-query"; // This has to be type so it can be interpreted as Record. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -49,6 +50,7 @@ const userChildrenOptions = createChildrenOptions({ export const usersSearchGroup = createGroup({ keyPath: "id", title: (t) => t("common.entity.users"), + source: { kind: "remote", source: "users" }, Component: (user) => ( @@ -57,6 +59,7 @@ export const usersSearchGroup = createGroup({ ), useInteraction: interaction.children(userChildrenOptions), useQueryOptions(query) { - return clientApi.user.search.useQuery({ query, limit: 5 }); + const remoteQuery = useRemoteQuery(query, "users"); + return clientApi.user.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled }); }, }); diff --git a/packages/spotlight/src/open.spec.ts b/packages/spotlight/src/open.spec.ts index 9b9c07f83..88fa1abc7 100644 --- a/packages/spotlight/src/open.spec.ts +++ b/packages/spotlight/src/open.spec.ts @@ -20,19 +20,22 @@ describe("lazy Spotlight open bridge", () => { const listener = vi.fn(); window.addEventListener(spotlightOpenEvent, listener); - openSpotlight(); + openSpotlight({ mode: "apps", query: "rada" }); expect(listener).toHaveBeenCalledOnce(); - expect(consumePendingSpotlightOpen()).toBe(true); - expect(consumePendingSpotlightOpen()).toBe(false); + expect(listener.mock.calls[0]?.[0]).toMatchObject({ + detail: { mode: "apps", query: "rada" }, + }); + expect(consumePendingSpotlightOpen()).toEqual({ mode: "apps", query: "rada" }); + expect(consumePendingSpotlightOpen()).toBeNull(); window.removeEventListener(spotlightOpenEvent, listener); }); - it("lets a listener installed after the request discover the pending open", () => { + it("defaults to search and lets a listener installed after the request discover the pending intent", () => { openSpotlight(); expect(hasPendingSpotlightOpen()).toBe(true); - expect(consumePendingSpotlightOpen()).toBe(true); + expect(consumePendingSpotlightOpen()).toEqual({ mode: "search" }); expect(hasPendingSpotlightOpen()).toBe(false); }); @@ -45,8 +48,11 @@ describe("lazy Spotlight open bridge", () => { openMediaRequestSearch({ integrationIds: ["integration-a"], query: "movie" }); expect(openListener).toHaveBeenCalledOnce(); + expect(openListener.mock.calls[0]?.[0]).toMatchObject({ + detail: { mode: "media", query: "movie" }, + }); expect(mediaListener).toHaveBeenCalledOnce(); - expect(consumePendingSpotlightOpen()).toBe(true); + expect(consumePendingSpotlightOpen()).toEqual({ mode: "media", query: "movie" }); expect(consumePendingMediaRequestSearch()).toEqual({ integrationIds: ["integration-a"], query: "movie" }); window.removeEventListener(spotlightOpenEvent, openListener); window.removeEventListener(mediaRequestSearchEvent, mediaListener); diff --git a/packages/spotlight/src/open.ts b/packages/spotlight/src/open.ts index 53aa5173f..1705577eb 100644 --- a/packages/spotlight/src/open.ts +++ b/packages/spotlight/src/open.ts @@ -3,30 +3,58 @@ export const spotlightOpenEvent = "homarr:spotlight:open"; export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search"; +export type SpotlightMode = + | "search" + | "apps" + | "command" + | "preferences" + | "assistant" + | "external" + | "media" + | "userGroup"; + +export interface OpenSpotlightOptions { + mode?: SpotlightMode; + query?: string; +} + +export interface SpotlightOpenIntent { + mode: SpotlightMode; + query?: string; +} + export interface OpenMediaRequestSearchOptions { integrationIds?: string[]; query?: string; } let pendingMediaRequestSearch: OpenMediaRequestSearchOptions | null = null; -let pendingSpotlightOpen = false; +let pendingSpotlightOpen: SpotlightOpenIntent | null = null; -const requestSpotlightMount = () => { - if (typeof window !== "undefined") window.dispatchEvent(new Event(spotlightOpenEvent)); +const requestSpotlightMount = (intent: SpotlightOpenIntent) => { + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(spotlightOpenEvent, { detail: intent })); + } }; -export const openSpotlight = () => { - pendingSpotlightOpen = true; - requestSpotlightMount(); +export const openSpotlight = (options: OpenSpotlightOptions = {}) => { + const intent: SpotlightOpenIntent = { + mode: options.mode ?? "search", + }; + if (options.query !== undefined) intent.query = options.query; + + if (intent.mode !== "media") pendingMediaRequestSearch = null; + pendingSpotlightOpen = intent; + requestSpotlightMount(intent); }; export const consumePendingSpotlightOpen = () => { const pending = pendingSpotlightOpen; - pendingSpotlightOpen = false; + pendingSpotlightOpen = null; return pending; }; -export const hasPendingSpotlightOpen = () => pendingSpotlightOpen; +export const hasPendingSpotlightOpen = () => pendingSpotlightOpen !== null; export const consumePendingMediaRequestSearch = () => { const pending = pendingMediaRequestSearch; @@ -36,7 +64,7 @@ export const consumePendingMediaRequestSearch = () => { export const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => { pendingMediaRequestSearch = options; - openSpotlight(); + openSpotlight({ mode: "media", query: options.query }); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent(mediaRequestSearchEvent, { detail: options })); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 2652ca33d..746560932 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -6314,6 +6314,9 @@ "toggleColorScheme": "Toggle light/dark mode", "saveNotebook": "Save notebook (only inside notebook widget)", "openSpotlight": "Open search", + "openSpotlightApps": "Open app search", + "openSpotlightAssistant": "Open Assistant mode", + "openSpotlightPreferences": "Open preferences mode", "openAssistant": "Open Homarr Assistant" }, "note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS" @@ -6779,6 +6782,51 @@ "search": { "placeholder": "Search for anything", "nothingFound": "Nothing found", + "modePicker": { + "search": { + "label": "Search", + "placeholder": "Search apps, boards, pages, commands, and preferences" + }, + "apps": { + "label": "Apps", + "placeholder": "Search apps, boards, and integrations" + }, + "command": { + "label": "Commands", + "placeholder": "Search actions and commands" + }, + "preferences": { + "label": "Preferences", + "placeholder": "Search and change preferences" + }, + "assistant": { + "label": "Assistant", + "placeholder": "Ask Homarr Assistant" + }, + "external": { + "label": "Web", + "placeholder": "Search with an external search engine" + }, + "media": { + "label": "Media", + "placeholder": "Search for a movie or show", + "fallback": "Search media for \"{query}\"" + }, + "userGroup": { + "label": "People", + "placeholder": "Search users and groups" + }, + "continueWith": "Continue with", + "dialogAriaLabel": "{mode} command menu", + "navigationLabel": "Search modes", + "modeAriaLabel": "{mode} mode", + "resultsAriaLabel": "{mode} results", + "footer": { + "navigate": "Navigate", + "open": "Open", + "close": "Close" + } + }, "error": { "fetch": "An error occurred while fetching data" }, diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 8b95ad157..be8b936e1 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -9,7 +9,6 @@ import combineClasses from "clsx"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useSettings } from "@homarr/settings"; -import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; @@ -29,26 +28,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) }); const app = getUsableWidgetQueryData(appQuery); const href = getSafeAppHref(app?.href); - useRegisterSpotlightContextResults( - `app-${app?.id ?? options.appId}`, - app && href - ? [ - { - id: app.id, - name: app.name, - icon: app.iconUrl, - interaction() { - return { - type: "link", - href, - newTab: options.openInNewTab, - }; - }, - }, - ] - : [], - [app, href, options.openInNewTab], - ); if (!options.appId) return ; if (isInitialWidgetQueryPending(appQuery)) return ;