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" ? (
+
+ ) : (
+
+ );
+
+ 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" ? (
+
+ ) : (
+
+ );
+
+ 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