From c840ff85bcda7b45703e25204c93f1a9531d52dd Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Fri, 6 Dec 2024 19:07:27 +0100 Subject: [PATCH] feat: add context specific search and actions (#1570) --- apps/nextjs/src/app/[locale]/layout.tsx | 2 + packages/common/src/app-url/client.ts | 1 + .../src/components/actions/group-actions.tsx | 4 + .../spotlight/src/components/spotlight.tsx | 46 +++-- packages/spotlight/src/index.ts | 5 + packages/spotlight/src/lib/mode.ts | 2 +- .../modes/command/context-specific-group.tsx | 34 ++++ .../src/modes/command/global-group.tsx | 166 +++++++++++++++++ .../spotlight/src/modes/command/index.tsx | 170 +----------------- .../src/modes/home/context-specific-group.tsx | 34 ++++ packages/spotlight/src/modes/home/context.tsx | 122 +++++++++++++ packages/spotlight/src/modes/home/index.tsx | 8 + packages/spotlight/src/modes/index.tsx | 5 +- packages/translation/src/lang/en.json | 13 ++ packages/widgets/src/app/component.tsx | 19 ++ packages/widgets/src/bookmarks/component.tsx | 21 ++- .../src/smart-home/entity-state/component.tsx | 23 +++ .../execute-automation/component.tsx | 25 +++ 18 files changed, 517 insertions(+), 183 deletions(-) create mode 100644 packages/spotlight/src/modes/command/context-specific-group.tsx create mode 100644 packages/spotlight/src/modes/command/global-group.tsx create mode 100644 packages/spotlight/src/modes/home/context-specific-group.tsx create mode 100644 packages/spotlight/src/modes/home/context.tsx create mode 100644 packages/spotlight/src/modes/home/index.tsx diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index fe4ad52d3..afa2e413e 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -13,6 +13,7 @@ import { env } from "@homarr/auth/env.mjs"; import { auth } from "@homarr/auth/next"; import { ModalProvider } from "@homarr/modals"; import { Notifications } from "@homarr/notifications"; +import { SpotlightProvider } from "@homarr/spotlight"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; import { getI18nMessages } from "@homarr/translation/server"; @@ -82,6 +83,7 @@ export default async function Layout(props: { children: React.ReactNode; params: (innerProps) => , (innerProps) => , (innerProps) => , + (innerProps) => , ]); return ( diff --git a/packages/common/src/app-url/client.ts b/packages/common/src/app-url/client.ts index e3c08268b..92d16a028 100644 --- a/packages/common/src/app-url/client.ts +++ b/packages/common/src/app-url/client.ts @@ -1,5 +1,6 @@ import { parseAppHrefWithVariables } from "./base"; export const parseAppHrefWithVariablesClient = (url: TInput): TInput => { + if (typeof window === "undefined") return url; return parseAppHrefWithVariables(url, window.location.href); }; diff --git a/packages/spotlight/src/components/actions/group-actions.tsx b/packages/spotlight/src/components/actions/group-actions.tsx index 1c208c491..0477e0ce2 100644 --- a/packages/spotlight/src/components/actions/group-actions.tsx +++ b/packages/spotlight/src/components/actions/group-actions.tsx @@ -34,6 +34,10 @@ export const SpotlightGroupActions = >({ }); if (Array.isArray(options)) { + if (options.length === 0) { + return null; + } + const filteredOptions = options .filter((option) => ("filter" in group ? group.filter(query, option) : false)) .sort((optionA, optionB) => { diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx index 02251fe32..1ab051bb7 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -1,7 +1,7 @@ "use client"; import type { Dispatch, SetStateAction } from "react"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ActionIcon, Center, Group, Kbd } from "@mantine/core"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; import { IconSearch, IconX } from "@tabler/icons-react"; @@ -12,6 +12,7 @@ import { useI18n } from "@homarr/translation/client"; import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { SearchMode } from "../lib/mode"; import { searchModes } from "../modes"; +import { useSpotlightContextResults } from "../modes/home/context"; import { selectAction, spotlightStore } from "../spotlight-store"; import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightActionGroups } from "./actions/groups/action-group"; @@ -19,24 +20,45 @@ import { SpotlightActionGroups } from "./actions/groups/action-group"; type SearchModeKey = keyof TranslationObject["search"]["mode"]; export const Spotlight = () => { - const searchModeState = useState("help"); + const items = useSpotlightContextResults(); + // We fallback to help if no context results are available + const defaultMode = items.length >= 1 ? "home" : "help"; + const searchModeState = useState(defaultMode); const mode = searchModeState[0]; const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]); + /** + * The below logic is used to switch to home page if any context results are registered + * or to help page if context results are unregistered + */ + const previousLengthRef = useRef(items.length); + useEffect(() => { + if (items.length >= 1 && previousLengthRef.current === 0) { + searchModeState[1]("home"); + } else if (items.length === 0 && previousLengthRef.current >= 1) { + searchModeState[1]("help"); + } + + previousLengthRef.current = items.length; + }, [items.length, searchModeState]); + if (!activeMode) { return null; } // We use the "key" below to prevent the 'Different amounts of hooks' error - return ; + return ( + + ); }; interface SpotlightWithActiveModeProps { modeState: [SearchModeKey, Dispatch>]; activeMode: SearchMode; + defaultMode: SearchModeKey; } -const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => { +const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: SpotlightWithActiveModeProps) => { const [query, setQuery] = useState(""); const [mode, setMode] = modeState; const [childrenOptions, setChildrenOptions] = useState | null>(null); @@ -50,12 +72,12 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM { - setMode("help"); + setMode(defaultMode); setChildrenOptions(null); }} query={query} onQueryChange={(query) => { - if (mode !== "help" || query.length !== 1) { + if ((mode !== "help" && mode !== "home") || query.length !== 1) { setQuery(query); } @@ -73,13 +95,13 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
- {activeMode.modeKey !== "help" ? {activeMode.character} : null} + {activeMode.modeKey !== defaultMode ? {activeMode.character} : null} } styles={{ @@ -88,10 +110,10 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM }, }} rightSection={ - mode === "help" ? undefined : ( + mode === defaultMode ? undefined : ( { - setMode("help"); + setMode(defaultMode); setChildrenOptions(null); inputRef.current?.focus(); }} @@ -103,8 +125,8 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM } value={query} onKeyDown={(event) => { - if (query.length === 0 && mode !== "help" && event.key === "Backspace") { - setMode("help"); + if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") { + setMode(defaultMode); setChildrenOptions(null); } }} diff --git a/packages/spotlight/src/index.ts b/packages/spotlight/src/index.ts index 2d4207075..18298a57b 100644 --- a/packages/spotlight/src/index.ts +++ b/packages/spotlight/src/index.ts @@ -4,5 +4,10 @@ import { spotlightActions } from "./spotlight-store"; export { Spotlight } from "./components/spotlight"; export { openSpotlight }; +export { + SpotlightProvider, + useRegisterSpotlightContextResults, + useRegisterSpotlightContextActions, +} from "./modes/home/context"; const openSpotlight = spotlightActions.open; diff --git a/packages/spotlight/src/lib/mode.ts b/packages/spotlight/src/lib/mode.ts index 358432107..54b7e5ffc 100644 --- a/packages/spotlight/src/lib/mode.ts +++ b/packages/spotlight/src/lib/mode.ts @@ -4,7 +4,7 @@ import type { SearchGroup } from "./group"; export type SearchMode = { modeKey: keyof TranslationObject["search"]["mode"]; - character: string; + character: string | undefined; } & ( | { groups: SearchGroup[]; diff --git a/packages/spotlight/src/modes/command/context-specific-group.tsx b/packages/spotlight/src/modes/command/context-specific-group.tsx new file mode 100644 index 000000000..845c70b59 --- /dev/null +++ b/packages/spotlight/src/modes/command/context-specific-group.tsx @@ -0,0 +1,34 @@ +import { Group, Text } from "@mantine/core"; + +import { createGroup } from "../../lib/group"; +import type { ContextSpecificItem } from "../home/context"; +import { useSpotlightContextActions } from "../home/context"; + +export const contextSpecificActionsSearchGroups = createGroup({ + title: (t) => t("search.mode.command.group.localCommand.title"), + keyPath: "id", + Component(option) { + const icon = + typeof option.icon !== "string" ? ( + + ) : ( + {option.name} + ); + + return ( + + {icon} + {option.name} + + ); + }, + useInteraction(option) { + return option.interaction(); + }, + filter(query, option) { + return option.name.toLowerCase().includes(query.toLowerCase()); + }, + useOptions() { + return useSpotlightContextActions(); + }, +}); diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx new file mode 100644 index 000000000..4b13c6b0f --- /dev/null +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -0,0 +1,166 @@ +import { Group, Text, useMantineColorScheme } from "@mantine/core"; +import type { TablerIcon } from "@tabler/icons-react"; +import { + IconBox, + IconCategoryPlus, + IconFileImport, + IconLanguage, + IconMailForward, + IconMoon, + IconPlug, + IconSun, + IconUserPlus, + IconUsersGroup, +} from "@tabler/icons-react"; + +import { useSession } from "@homarr/auth/client"; +import { useModalAction } from "@homarr/modals"; +import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { createGroup } from "../../lib/group"; +import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; +import { interaction } from "../../lib/interaction"; +import { languageChildrenOptions } from "./children/language"; +import { newIntegrationChildrenOptions } from "./children/new-integration"; + +// This has to be type so it can be interpreted as Record. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Command = { + commandKey: string; + icon: TablerIcon; + name: string; + useInteraction: ( + _c: Command, + query: string, + ) => inferSearchInteractionDefinition; +}; + +export const globalCommandGroup = createGroup({ + keyPath: "commandKey", + title: "Global commands", + useInteraction: (option, query) => option.useInteraction(option, query), + Component: ({ icon: Icon, name }) => ( + + + {name} + + ), + filter(query, option) { + return option.name.toLowerCase().includes(query.toLowerCase()); + }, + useOptions() { + const tOption = useScopedI18n("search.mode.command.group.globalCommand.option"); + const { colorScheme } = useMantineColorScheme(); + const { data: session } = useSession(); + + const commands: (Command & { hidden?: boolean })[] = [ + { + commandKey: "colorScheme", + icon: colorScheme === "dark" ? IconSun : IconMoon, + name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`), + useInteraction: () => { + const { toggleColorScheme } = useMantineColorScheme(); + + return { + type: "javaScript", + onSelect: toggleColorScheme, + }; + }, + }, + { + commandKey: "language", + icon: IconLanguage, + name: tOption("language.label"), + useInteraction: interaction.children(languageChildrenOptions), + }, + { + commandKey: "newBoard", + icon: IconCategoryPlus, + name: tOption("newBoard.label"), + useInteraction() { + const { openModal } = useModalAction(AddBoardModal); + + return { + type: "javaScript", + onSelect() { + openModal(undefined); + }, + }; + }, + hidden: !session?.user.permissions.includes("board-create"), + }, + { + commandKey: "importBoard", + icon: IconFileImport, + name: tOption("importBoard.label"), + useInteraction() { + const { openModal } = useModalAction(ImportBoardModal); + + return { + type: "javaScript", + onSelect() { + openModal(undefined); + }, + }; + }, + hidden: !session?.user.permissions.includes("board-create"), + }, + { + commandKey: "newApp", + icon: IconBox, + name: tOption("newApp.label"), + useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })), + hidden: !session?.user.permissions.includes("app-create"), + }, + { + commandKey: "newIntegration", + icon: IconPlug, + name: tOption("newIntegration.label"), + useInteraction: interaction.children(newIntegrationChildrenOptions), + hidden: !session?.user.permissions.includes("integration-create"), + }, + { + commandKey: "newUser", + icon: IconUserPlus, + name: tOption("newUser.label"), + useInteraction: interaction.link(() => ({ href: "/manage/users/new" })), + hidden: !session?.user.permissions.includes("admin"), + }, + { + commandKey: "newInvite", + icon: IconMailForward, + name: tOption("newInvite.label"), + useInteraction() { + const { openModal } = useModalAction(InviteCreateModal); + + return { + type: "javaScript", + onSelect() { + openModal(undefined); + }, + }; + }, + hidden: !session?.user.permissions.includes("admin"), + }, + { + commandKey: "newGroup", + icon: IconUsersGroup, + name: tOption("newGroup.label"), + useInteraction() { + const { openModal } = useModalAction(AddGroupModal); + + return { + type: "javaScript", + onSelect() { + openModal(undefined); + }, + }; + }, + hidden: !session?.user.permissions.includes("admin"), + }, + ]; + + return commands.filter((command) => !command.hidden); + }, +}); diff --git a/packages/spotlight/src/modes/command/index.tsx b/packages/spotlight/src/modes/command/index.tsx index 4290c7c1e..eee549dd8 100644 --- a/packages/spotlight/src/modes/command/index.tsx +++ b/packages/spotlight/src/modes/command/index.tsx @@ -1,173 +1,9 @@ -import { Group, Text, useMantineColorScheme } from "@mantine/core"; -import { - IconBox, - IconCategoryPlus, - IconFileImport, - IconLanguage, - IconMailForward, - IconMoon, - IconPlug, - IconSun, - IconUserPlus, - IconUsersGroup, -} from "@tabler/icons-react"; - -import { useSession } from "@homarr/auth/client"; -import { useModalAction } from "@homarr/modals"; -import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection"; -import { useScopedI18n } from "@homarr/translation/client"; -import type { TablerIcon } from "@homarr/ui"; - -import { createGroup } from "../../lib/group"; -import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; -import { interaction } from "../../lib/interaction"; import type { SearchMode } from "../../lib/mode"; -import { languageChildrenOptions } from "./children/language"; -import { newIntegrationChildrenOptions } from "./children/new-integration"; - -// This has to be type so it can be interpreted as Record. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type Command = { - commandKey: string; - icon: TablerIcon; - name: string; - useInteraction: ( - _c: Command, - query: string, - ) => inferSearchInteractionDefinition; -}; +import { contextSpecificActionsSearchGroups } from "./context-specific-group"; +import { globalCommandGroup } from "./global-group"; export const commandMode = { modeKey: "command", character: ">", - groups: [ - createGroup({ - keyPath: "commandKey", - title: "Global commands", - useInteraction: (option, query) => option.useInteraction(option, query), - Component: ({ icon: Icon, name }) => ( - - - {name} - - ), - filter(query, option) { - return option.name.toLowerCase().includes(query.toLowerCase()); - }, - useOptions() { - const tOption = useScopedI18n("search.mode.command.group.globalCommand.option"); - const { colorScheme } = useMantineColorScheme(); - const { data: session } = useSession(); - - const commands: (Command & { hidden?: boolean })[] = [ - { - commandKey: "colorScheme", - icon: colorScheme === "dark" ? IconSun : IconMoon, - name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`), - useInteraction: () => { - const { toggleColorScheme } = useMantineColorScheme(); - - return { - type: "javaScript", - onSelect: toggleColorScheme, - }; - }, - }, - { - commandKey: "language", - icon: IconLanguage, - name: tOption("language.label"), - useInteraction: interaction.children(languageChildrenOptions), - }, - { - commandKey: "newBoard", - icon: IconCategoryPlus, - name: tOption("newBoard.label"), - useInteraction() { - const { openModal } = useModalAction(AddBoardModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, - hidden: !session?.user.permissions.includes("board-create"), - }, - { - commandKey: "importBoard", - icon: IconFileImport, - name: tOption("importBoard.label"), - useInteraction() { - const { openModal } = useModalAction(ImportBoardModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, - hidden: !session?.user.permissions.includes("board-create"), - }, - { - commandKey: "newApp", - icon: IconBox, - name: tOption("newApp.label"), - useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })), - hidden: !session?.user.permissions.includes("app-create"), - }, - { - commandKey: "newIntegration", - icon: IconPlug, - name: tOption("newIntegration.label"), - useInteraction: interaction.children(newIntegrationChildrenOptions), - hidden: !session?.user.permissions.includes("integration-create"), - }, - { - commandKey: "newUser", - icon: IconUserPlus, - name: tOption("newUser.label"), - useInteraction: interaction.link(() => ({ href: "/manage/users/new" })), - hidden: !session?.user.permissions.includes("admin"), - }, - { - commandKey: "newInvite", - icon: IconMailForward, - name: tOption("newInvite.label"), - useInteraction() { - const { openModal } = useModalAction(InviteCreateModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, - hidden: !session?.user.permissions.includes("admin"), - }, - { - commandKey: "newGroup", - icon: IconUsersGroup, - name: tOption("newGroup.label"), - useInteraction() { - const { openModal } = useModalAction(AddGroupModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, - hidden: !session?.user.permissions.includes("admin"), - }, - ]; - - return commands.filter((command) => !command.hidden); - }, - }), - ], + groups: [contextSpecificActionsSearchGroups, globalCommandGroup], } satisfies SearchMode; diff --git a/packages/spotlight/src/modes/home/context-specific-group.tsx b/packages/spotlight/src/modes/home/context-specific-group.tsx new file mode 100644 index 000000000..336e944dd --- /dev/null +++ b/packages/spotlight/src/modes/home/context-specific-group.tsx @@ -0,0 +1,34 @@ +import { Group, Text } from "@mantine/core"; + +import { createGroup } from "../../lib/group"; +import type { ContextSpecificItem } from "./context"; +import { useSpotlightContextResults } from "./context"; + +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} + + ); + }, + useInteraction(option) { + return option.interaction(); + }, + filter(query, option) { + return option.name.toLowerCase().includes(query.toLowerCase()); + }, + useOptions() { + return useSpotlightContextResults(); + }, +}); diff --git a/packages/spotlight/src/modes/home/context.tsx b/packages/spotlight/src/modes/home/context.tsx new file mode 100644 index 000000000..3a681499b --- /dev/null +++ b/packages/spotlight/src/modes/home/context.tsx @@ -0,0 +1,122 @@ +import type { DependencyList, PropsWithChildren } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; + +import type { TablerIcon } from "@homarr/ui"; + +import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ContextSpecificItem = { + id: string; + name: string; + icon: TablerIcon | string; + interaction: () => inferSearchInteractionDefinition; + disabled?: boolean; +}; + +interface SpotlightContextProps { + items: ContextSpecificItem[]; + registerItems: (key: string, results: ContextSpecificItem[]) => void; + unregisterItems: (key: string) => void; +} + +const createSpotlightContext = (displayName: string) => { + const SpotlightContext = createContext(null); + SpotlightContext.displayName = displayName; + + const Provider = ({ children }: PropsWithChildren) => { + const [itemsMap, setItemsMap] = useState>(new Map()); + + const registerItems = useCallback((key: string, newItems: ContextSpecificItem[]) => { + setItemsMap((prevItems) => { + const newItemsMap = new Map(prevItems); + newItemsMap.set(key, { items: newItems, count: (newItemsMap.get(key)?.count ?? 0) + 1 }); + return newItemsMap; + }); + }, []); + + const unregisterItems = useCallback((key: string) => { + setItemsMap((prevItems) => { + const registrationCount = prevItems.get(key)?.count ?? 0; + + if (registrationCount <= 1) { + const newItemsMap = new Map(prevItems); + newItemsMap.delete(key); + return newItemsMap; + } + + const newItemsMap = new Map(prevItems); + newItemsMap.set(key, { items: newItemsMap.get(key)?.items ?? [], count: registrationCount - 1 }); + + return prevItems; + }); + }, []); + + const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]); + + return ( + + {children} + + ); + }; + + const useSpotlightContextItems = () => { + const context = useContext(SpotlightContext); + + if (!context) { + throw new Error(`useSpotlightContextItems must be used within SpotlightContext[displayName=${displayName}]`); + } + + return context.items; + }; + + const useRegisterSpotlightContextItems = ( + key: string, + items: ContextSpecificItem[], + dependencyArray: DependencyList, + ) => { + const context = useContext(SpotlightContext); + + if (!context) { + throw new Error( + `useRegisterSpotlightContextItems must be used within SpotlightContext[displayName=${displayName}]`, + ); + } + + useEffect(() => { + context.registerItems( + key, + items.filter((item) => !item.disabled), + ); + + return () => { + context.unregisterItems(key); + }; + // We ignore the results + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...dependencyArray, key]); + }; + + return [SpotlightContext, Provider, useSpotlightContextItems, useRegisterSpotlightContextItems] as const; +}; + +const [_ResultContext, ResultProvider, useSpotlightContextResults, useRegisterSpotlightContextResults] = + createSpotlightContext("SpotlightContextSpecificResults"); +const [_ActionContext, ActionProvider, useSpotlightContextActions, useRegisterSpotlightContextActions] = + createSpotlightContext("SpotlightContextSpecificActions"); + +export { + useRegisterSpotlightContextActions, + useRegisterSpotlightContextResults, + useSpotlightContextActions, + useSpotlightContextResults, +}; + +export const SpotlightProvider = ({ children }: PropsWithChildren) => { + return ( + + {children} + + ); +}; diff --git a/packages/spotlight/src/modes/home/index.tsx b/packages/spotlight/src/modes/home/index.tsx new file mode 100644 index 000000000..e306437f2 --- /dev/null +++ b/packages/spotlight/src/modes/home/index.tsx @@ -0,0 +1,8 @@ +import type { SearchMode } from "../../lib/mode"; +import { contextSpecificSearchGroups } from "./context-specific-group"; + +export const homeMode = { + character: undefined, + modeKey: "home", + groups: [contextSpecificSearchGroups], +} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/index.tsx b/packages/spotlight/src/modes/index.tsx index f95b1145e..6571e7ac6 100644 --- a/packages/spotlight/src/modes/index.tsx +++ b/packages/spotlight/src/modes/index.tsx @@ -11,10 +11,11 @@ import type { SearchMode } from "../lib/mode"; import { appIntegrationBoardMode } from "./app-integration-board"; import { commandMode } from "./command"; import { externalMode } from "./external"; +import { homeMode } from "./home"; import { pageMode } from "./page"; import { userGroupMode } from "./user-group"; -const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const; +const searchModesForHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const; const helpMode = { modeKey: "help", @@ -82,4 +83,4 @@ const helpMode = { }, } satisfies SearchMode; -export const searchModes = [...searchModesWithoutHelp, helpMode] as const; +export const searchModes = [...searchModesForHelp, helpMode, homeMode] as const; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index cac220e9f..3ad0c7e85 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1159,6 +1159,9 @@ "automationId": { "label": "Automation ID" } + }, + "spotlightAction": { + "run": "Run {name}" } }, "calendar": { @@ -2450,6 +2453,9 @@ "command": { "help": "Activate command mode", "group": { + "localCommand": { + "title": "Local commands" + }, "globalCommand": { "title": "Global commands", "option": { @@ -2559,6 +2565,13 @@ } } }, + "home": { + "group": { + "local": { + "title": "Local results" + } + } + }, "page": { "help": "Search for pages", "group": { diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 4b4cbe309..989de06c3 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -8,6 +8,7 @@ import combineClasses from "clsx"; import { clientApi } from "@homarr/api/client"; import { parseAppHrefWithVariablesClient } from "@homarr/common/client"; +import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; @@ -28,6 +29,24 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps< retry: false, }, ); + useRegisterSpotlightContextResults( + `app-${app.id}`, + [ + { + id: app.id, + name: app.name, + icon: app.iconUrl, + interaction() { + return { + type: "link", + href: parseAppHrefWithVariablesClient(app.href ?? ""), + newTab: options.openInNewTab, + }; + }, + }, + ], + [app, options.openInNewTab], + ); return ( ) { +export default function BookmarksWidget({ options, width, height, itemId }: WidgetComponentProps<"bookmarks">) { const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, { select(data) { return data.sort((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id)); }, }); + useRegisterSpotlightContextResults( + `bookmark-${itemId}`, + data.map((app) => ({ + id: app.id, + name: app.name, + icon: app.iconUrl, + interaction() { + return { + type: "link", + href: parseAppHrefWithVariablesClient(app.href ?? ""), + newTab: false, + }; + }, + })), + [data], + ); + return ( diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index 410b6596c..60dcdb9db 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -2,8 +2,10 @@ import { useCallback } from "react"; import { Center, Stack, Text, UnstyledButton } from "@mantine/core"; +import { IconBinaryTree } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationSelectedError } from "../../errors"; @@ -60,6 +62,27 @@ const InnerComponent = ({ options, integrationId, isEditMode }: InnerComponentPr }); }, [integrationId, isEditMode, mutate, options.clickable, options.entityId]); + useRegisterSpotlightContextActions( + `smartHome-entityState-${options.entityId}`, + [ + { + id: options.entityId, + name: options.displayName, + icon: IconBinaryTree, + interaction() { + return { + type: "javaScript", + onSelect() { + handleClick(); + }, + }; + }, + disabled: !options.clickable, + }, + ], + [handleClick, options.clickable, options.displayName, options.entityId], + ); + return ( <UnstyledButton onClick={handleClick} diff --git a/packages/widgets/src/smart-home/execute-automation/component.tsx b/packages/widgets/src/smart-home/execute-automation/component.tsx index a2cd856d9..9777138bc 100644 --- a/packages/widgets/src/smart-home/execute-automation/component.tsx +++ b/packages/widgets/src/smart-home/execute-automation/component.tsx @@ -6,6 +6,8 @@ import { useDisclosure, useTimeout } from "@mantine/hooks"; import { IconAutomation, IconCheck } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; +import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; @@ -34,6 +36,29 @@ export default function SmartHomeTriggerAutomationWidget({ integrationId: integrationIds[0] ?? "", }); }, [integrationIds, isEditMode, mutateAsync, options.automationId]); + + const t = useI18n(); + useRegisterSpotlightContextActions( + `smartHome-automation-${options.automationId}`, + [ + { + id: options.automationId, + name: t("widget.smartHome-executeAutomation.spotlightAction.run", { name: options.displayName }), + icon: IconAutomation, + interaction() { + return { + type: "javaScript", + // eslint-disable-next-line no-restricted-syntax + async onSelect() { + await handleClick(); + }, + }; + }, + }, + ], + [handleClick, options.automationId, options.displayName], + ); + return ( <UnstyledButton onClick={handleClick} style={{ cursor: !isEditMode ? "pointer" : "initial" }} w="100%" h="100%"> {isShowSuccess && (