Compare commits

..
61 changed files with 1572 additions and 892 deletions

No files matched your search

@@ -8,13 +8,28 @@ 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 configured search-engine actions and Assistant fallbacks. Commands include opening the board switcher without
leaving the keyboard. Type `!` to enter Web mode when you want to browse configured engine shortcuts or discover
DuckDuckGo bangs; bang discovery is excluded from ordinary Search results.
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`
+1 -1
View File
@@ -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
@@ -7,9 +7,13 @@ tags:
# Search Engines
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`.
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `MOD + K`.
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
The default **Search** mode lists every configured search engine as a fallback action for the current query. To discover
engines by shortcut, switch to **Web** with `!`. DuckDuckGo bang discovery is intentionally limited to Web mode so an
ordinary search term never expands into similarly named bang providers.
You can add any search engine that has a search URL that accepts a search term as a parameter.
## Create search engine
@@ -26,6 +30,8 @@ The name can be chosen freely. It will be displayed in the search box popover.
Prefix that is used to choose the search engine in the popover using the keyboard.
It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia).
Type `!` in Spotlight to browse these shortcuts and, when DuckDuckGo bangs are enabled in preferences, discover
additional bang providers.
### Configuration
@@ -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 = () => {
<BoardSelectionGridProvider>
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
@@ -53,11 +53,13 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
<EditModeMenu demoReadOnly={demoReadOnly} />
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
{!demoReadOnly && (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
)}
</>
);
};
@@ -856,6 +856,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
: t("busy.description"),
unavailable: !canSend,
alwaysVisible: true,
placement: "fallback" as const,
interaction: (query: string) =>
createAssistantPromptInteraction({
sendPrompt,
@@ -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],
@@ -0,0 +1,70 @@
"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<string, boolean>();
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<ContextSpecificItem[]>(() => {
return apps.flatMap((app) => {
const href = getSafeAppHref(app.href);
if (!href) return [];
return [
{
id: app.id,
dedupeKey: `app:${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;
};
@@ -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(() => {
@@ -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;
@@ -61,12 +57,8 @@ export const LazySpotlight = () => {
return isMounted || isPreloaded ? (
<>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && (
<>
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
<Spotlight />
</>
)}
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
<Spotlight />
</>
) : null;
};
@@ -20,7 +20,7 @@ export const DesktopSearchInput = () => {
w={400}
size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight}
onClick={() => openSpotlight()}
radius="xl"
>
{`${t("search.placeholder")}...`}
@@ -32,7 +32,7 @@ export const MobileSearchButton = () => {
return (
<HeaderButton
data-homarr-dev-benchmark-interaction="search"
onClick={openSpotlight}
onClick={() => openSpotlight()}
className={classes.mobileSearch}
>
<IconSearch size={20} stroke={1.5} />
+83 -72
View File
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { createId, generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
@@ -123,6 +124,78 @@ const findFirstAvailableBoardItemPosition = (
return null;
};
const searchAccessibleBoardsAsync = async (
ctx: { db: Database; session: Session | null },
query: string,
limit?: number,
) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const foundBoards = await ctx.db.query.boards.findMany({
where: and(
like(boards.name, `%${query}%`),
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
),
limit,
orderBy: asc(boards.name),
columns: {
id: true,
name: true,
creatorId: true,
isPublic: true,
logoImageUrl: true,
},
with: {
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: getBoardGroupPermissionWhere(permissionsOfCurrentUserGroupsWhenPresent),
},
},
});
return foundBoards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
};
export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure
.requiresPermission("board-create")
@@ -482,80 +555,18 @@ export const boardRouter = createTRPCRouter({
};
});
}),
catalog: publicProcedure
.meta({ mcp: { enabled: true, description: "List every board the current user can access." } })
.query(async ({ ctx }) => searchAccessibleBoardsAsync(ctx, "")),
search: publicProcedure
.meta({
mcp: {
enabled: true,
description: "Search accessible boards by name. REQUIRED: query (string). OPTIONAL: limit (number).",
},
})
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const foundBoards = await ctx.db.query.boards.findMany({
where: and(
like(boards.name, `%${input.query}%`),
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
),
limit: input.limit,
columns: {
id: true,
name: true,
creatorId: true,
isPublic: true,
logoImageUrl: true,
},
with: {
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
},
});
return foundBoards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
.query(async ({ ctx, input }) => searchAccessibleBoardsAsync(ctx, input.query, input.limit)),
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.meta({
@@ -130,6 +130,28 @@ export const searchEngineRouter = createTRPCRouter({
return null;
}),
catalog: publicProcedure
.meta({
mcp: {
enabled: true,
description: "List search engines available to the current user.",
},
})
.query(async ({ ctx }) => {
return await ctx.db.query.searchEngines.findMany({
where: ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
orderBy: asc(searchEngines.name),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
}),
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({
// Public dashboards have no session: restrict anonymous users to generic
+2
View File
@@ -19,8 +19,10 @@ const MCP_TOOL_ALLOWLIST = {
"beszel_getAlerts",
"beszel_getSystems",
"beszel_getSystemStats",
"board_catalog",
"board_getAllBoards",
"board_getBoardSettings",
"board_search",
"calendar_findAllEvents",
"customWidget_get",
"customWidget_getAuthoringPrompt",
+3
View File
@@ -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",
};
@@ -1,22 +1,32 @@
import { Center, Loader } from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import { Center } from "@mantine/core";
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<TOption extends Record<string, unknown>> {
group: SearchGroup<TOption>;
query: string;
setQuery: (query: string) => void;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setMode: (mode: SpotlightMode) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
const getUniqueOptions = <TOption extends Record<string, unknown>>(options: TOption[], keyPath: keyof TOption) => {
const optionKeys = new Set<unknown>();
return options.filter((option) => {
const optionKey = option[keyPath];
if (optionKeys.has(optionKey)) return false;
optionKeys.add(optionKey);
return true;
});
};
export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
group,
query,
@@ -30,28 +40,26 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
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;
}
const filteredOptions = options
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
.sort((optionA, optionB) => {
if ("sort" in group) {
return group.sort?.(query, [optionA, optionB]) ?? 0;
}
const filteredOptions = getUniqueOptions(
options
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
.sort((optionA, optionB) => {
if ("sort" in group) {
return group.sort?.(query, [optionA, optionB]) ?? 0;
}
return 0;
});
return 0;
}),
group.keyPath,
);
if (filteredOptions.length === 0) {
return <SpotlightNoResults />;
return null;
}
return filteredOptions.map((option) => (
@@ -68,15 +76,15 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
}
if (options.isLoading) {
return (
<Center w="100%" py="sm">
<Loader size="sm" />
</Center>
);
return <span hidden data-search-pending />;
}
if (options.isError) {
return <Center py="sm">{t("search.error.fetch")}</Center>;
return (
<Center py="sm" data-search-status>
{t("search.error.fetch")}
</Center>
);
}
if (!options.data) {
@@ -84,10 +92,10 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
}
if (options.data.length === 0) {
return <SpotlightNoResults />;
return null;
}
return options.data.map((option) => (
return getUniqueOptions(options.data, group.keyPath).map((option) => (
<SpotlightGroupActionItem
key={option[group.keyPath] as never}
option={option}
@@ -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
<Spotlight.ActionsGroup
key={`${translateIfNecessary(t, group.title)}::${String(group.keyPath)}`}
label={translateIfNecessary(t, group.title)}
data-search-source={group.source?.kind ?? "local"}
data-remote-source={group.source?.kind === "remote" ? group.source.source : undefined}
>
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<SpotlightGroupActions<any> group={group} {...others} />
@@ -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<TOption extends Record<string, unknown>>
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<TOption>;
}
@@ -34,7 +34,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
const safeHref = interaction.type === "link" ? getSafeAppHref(interaction.href) : undefined;
const renderRoot =
interaction.type === "link" && safeHref
interaction.type === "link" && safeHref && !unavailable
? (props: Record<string, unknown>) => {
return (
<Link
@@ -48,6 +48,8 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
: 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 = <TOption extends Record<string, unknown>
}
className={classes.spotlightAction}
aria-disabled={unavailable || undefined}
disabled={unavailable}
>
<group.Component {...(optionProps as TOption)} />
</Spotlight.Action>
@@ -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<SpotlightMode, typeof IconSearch> = {
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 (
<ScrollArea type="never" scrollbarSize={0} className={classes.modeRailViewport}>
<Group component="nav" aria-label={navigationLabel} wrap="nowrap" gap={4} className={classes.modeRail}>
{entries.map((entry) => {
if (entry.mode === "userGroup" && !canManageUsers) return null;
const Icon = modeIcons[entry.mode];
const active = entry.mode === activeMode;
return (
<UnstyledButton
type="button"
key={entry.mode}
className={classes.modeButton}
data-active={active || undefined}
aria-pressed={active}
aria-label={entry.ariaLabel}
onClick={() => onModeChange(entry.mode)}
>
<Icon size={15} stroke={1.7} />
<Text component="span" size="xs" fw={600} className={classes.modeLabel}>
{entry.label}
</Text>
{entry.character ? (
<Kbd size="xs" className={classes.modeKey}>
{entry.character}
</Kbd>
) : null}
</UnstyledButton>
);
})}
</Group>
</ScrollArea>
);
};
@@ -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 <Spotlight.Empty>{t("search.nothingFound")}</Spotlight.Empty>;
return <Spotlight.Empty className={className}>{t("search.nothingFound")}</Spotlight.Empty>;
};
@@ -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:has([data-action]) .emptyState,
.actionsList:has([data-search-pending]) .emptyState,
.actionsList:has([data-search-status]) .emptyState {
display: none;
}
.actionsList [data-search-source]:has([data-search-pending]) {
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;
}
}
+173 -102
View File
@@ -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,142 @@ 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 { useSpotlightCatalogs } from "../lib/catalog";
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<SearchModeKey>(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 = () => {
useSpotlightCatalogs();
const [mode, setMode] = useState<SpotlightMode>(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<SpotlightOpenIntent>).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<OpenMediaRequestSearchOptions>).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 (
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
<SpotlightWithActiveMode
key={mode}
mode={mode}
setMode={setMode}
query={query}
setQuery={setQuery}
activeMode={activeMode}
/>
);
};
interface SpotlightWithActiveModeProps {
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
queryState: [string, Dispatch<SetStateAction<string>>];
mode: SpotlightMode;
setMode: Dispatch<SetStateAction<SpotlightMode>>;
query: string;
setQuery: Dispatch<SetStateAction<string>>;
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<ChildrenOptions[]>([]);
const childrenOptions = childrenStack.at(-1) ?? null;
const t = useI18n();
const inputRef = useRef<HTMLInputElement>(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 +159,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<OpenMediaRequestSearchOptions>).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 (
<MantineSpotlight.Root
shortcut={hotkeys.openSpotlight}
yOffset={8}
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
shortcut={null}
size={760}
yOffset={64}
scrollable
maxHeight="min(62vh, 560px)"
overlayProps={{ blur: 3, backgroundOpacity: 0.24 }}
attributes={{ content: { "aria-label": t("search.modePicker.dialogAriaLabel", { mode: activeModeLabel }) } }}
classNames={{ content: classes.content }}
onSpotlightOpen={() => {
requestAnimationFrame(() => inputRef.current?.focus());
}}
@@ -143,22 +185,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}
>
<MantineSpotlight.Search
data-homarr-dev-benchmark-spotlight
placeholder={`${t("search.placeholder")}...`}
aria-label={placeholder}
placeholder={placeholder}
ref={inputRef}
className={classes.search}
leftSectionWidth={hasModeCharacter ? 80 : 48}
leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
@@ -168,11 +212,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group>
}
styles={{
section: {
pointerEvents: "all",
},
}}
styles={{ section: { pointerEvents: "all" } }}
rightSection={
mode === defaultMode && childrenStack.length === 0 ? null : (
<ActionIcon
@@ -188,9 +228,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
return;
}
setMode(defaultMode);
clearChildrenStack();
inputRef.current?.focus();
changeMode(defaultMode, true);
}}
variant="subtle"
aria-label={
@@ -212,20 +250,28 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
return;
}
if (mode !== defaultMode) {
setMode(defaultMode);
clearChildrenStack();
}
if (mode !== defaultMode) changeMode(defaultMode, true);
}}
/>
<SpotlightModeRail
activeMode={mode}
entries={modeRailEntries}
navigationLabel={t("search.modePicker.navigationLabel")}
onModeChange={changeMode}
/>
{childrenOptions ? (
<Group key={childrenStack.length}>
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
</Group>
) : null}
<MantineSpotlight.ActionsList>
<MantineSpotlight.ActionsList
component="section"
className={classes.actionsList}
aria-label={t("search.modePicker.resultsAriaLabel", { mode: activeModeLabel })}
>
{childrenOptions ? (
<SpotlightChildrenActions
key={childrenStack.length}
@@ -236,21 +282,46 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
) : (
<SpotlightActionGroups
setQuery={setQuery}
setMode={(nextMode) => {
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}
/>
)}
<SpotlightNoResults className={classes.emptyState} />
</MantineSpotlight.ActionsList>
<MantineSpotlight.Footer className={classes.footer} px="md" py="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Group gap={4} wrap="nowrap">
<Kbd size="xs">
<IconArrowUp size={11} />
</Kbd>
<Kbd size="xs">
<IconArrowDown size={11} />
</Kbd>
<Text size="xs" className={classes.footerHint}>
{t("search.modePicker.footer.navigate")}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<Kbd size="xs">
<IconCornerDownLeft size={11} />
</Kbd>
<Text size="xs" className={classes.footerHint}>
{t("search.modePicker.footer.open")}
</Text>
</Group>
</Group>
<Group gap={4} wrap="nowrap" className={classes.footerSecondary}>
<Kbd size="xs">esc</Kbd>
<Text size="xs" className={classes.footerHint}>
{t("search.modePicker.footer.close")}
</Text>
</Group>
</Group>
</MantineSpotlight.Footer>
</MantineSpotlight.Root>
);
};
+1
View File
@@ -6,3 +6,4 @@ export {
useRegisterSpotlightContextResults,
useRegisterSpotlightContextActions,
} from "./modes/home/context";
export type { ContextSpecificItem } from "./modes/home/context";
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect } from "react";
import { clientApi } from "@homarr/api/client";
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
import { useSession } from "@homarr/auth/client";
import { spotlightOpenEvent } from "../open";
const catalogQueryOptions = (enabled = true) => ({
enabled,
refetchOnWindowFocus: false,
staleTime: queryCacheDefaultStaleTimeMs,
});
export const useAppsCatalogQuery = (enabled = true) => {
const { status } = useSession();
return clientApi.app.selectable.useQuery(undefined, catalogQueryOptions(enabled && status === "authenticated"));
};
export const useBoardsCatalogQuery = () => {
const { status } = useSession();
return clientApi.board.catalog.useQuery(undefined, catalogQueryOptions(status !== "loading"));
};
export const useIntegrationsCatalogQuery = (enabled = true) => {
const { status } = useSession();
return clientApi.integration.all.useQuery(undefined, catalogQueryOptions(enabled && status === "authenticated"));
};
export const useSearchEnginesCatalogQuery = () => {
const { status } = useSession();
return clientApi.searchEngine.catalog.useQuery(undefined, catalogQueryOptions(status !== "loading"));
};
export const useSpotlightCatalogs = () => {
const { data: session, status } = useSession();
const isAuthenticated = status === "authenticated";
const isSessionResolved = status !== "loading";
const canBrowseApps =
session?.user.permissions.includes("board-modify-all") === true ||
session?.user.permissions.includes("app-modify-all") === true;
const appsQuery = useAppsCatalogQuery(isAuthenticated && canBrowseApps);
const boardsQuery = useBoardsCatalogQuery();
const integrationsQuery = useIntegrationsCatalogQuery(isAuthenticated);
const searchEnginesQuery = useSearchEnginesCatalogQuery();
useEffect(() => {
const refreshCatalogs = () => {
if (canBrowseApps) void appsQuery.refetch();
if (isSessionResolved) void boardsQuery.refetch();
if (isAuthenticated) void integrationsQuery.refetch();
if (isSessionResolved) void searchEnginesQuery.refetch();
};
window.addEventListener(spotlightOpenEvent, refreshCatalogs);
return () => window.removeEventListener(spotlightOpenEvent, refreshCatalogs);
}, [
appsQuery,
boardsQuery,
canBrowseApps,
integrationsQuery,
isAuthenticated,
isSessionResolved,
searchEnginesQuery,
]);
};
export const filterCatalog = <TOption>(
options: TOption[],
query: string,
getSearchableValues: (option: TOption) => string[],
limit = 8,
) => {
const normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.length === 0) return options.slice(0, limit);
return options
.map((option, index) => {
const values = getSearchableValues(option).map((value) => value.toLowerCase());
const score = values.reduce((currentScore, value) => {
if (value === normalizedQuery) return Math.min(currentScore, 0);
if (value.startsWith(normalizedQuery)) return Math.min(currentScore, 1);
if (value.includes(normalizedQuery)) return Math.min(currentScore, 2);
return currentScore;
}, Number.POSITIVE_INFINITY);
return { option, index, score };
})
.filter(({ score }) => Number.isFinite(score))
.toSorted((first, second) => first.score - second.score || first.index - second.index)
.slice(0, limit)
.map(({ option }) => option);
};
+20 -12
View File
@@ -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<TOption extends Record<string, unknown>, TOptionProps extends Record<string, unknown>> = {
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<TOption extends object, TOptionProps extends object> = {
// 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<SearchInteraction>;
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<TOption extends Record<string, unknown> = any> =
export type SearchGroup<TOption extends object = any> =
| CommonSearchGroup<TOption, { filter: (query: string, option: TOption) => boolean; options: TOption[] }>
| CommonSearchGroup<
TOption,
@@ -36,4 +44,4 @@ export type SearchGroup<TOption extends Record<string, unknown> = any> =
{ useQueryOptions: (query: string) => { data: TOption[] | undefined; isLoading: boolean; isError: boolean } }
>;
export const createGroup = <TOption extends Record<string, unknown>>(group: SearchGroup<TOption>) => group;
export const createGroup = <TOption extends object>(group: SearchGroup<TOption>) => group;
+2 -2
View File
@@ -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 = <TType extends string>(type: TType) => ({
optionsType: <TOption extends Record<string, unknown> | 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<any>["useActions"];
+5 -2
View File
@@ -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[];
@@ -0,0 +1,46 @@
"use client";
import { useDebouncedValue } from "@mantine/hooks";
import type { RemoteSearchSource } from "./group";
const remoteSearchPolicy: Record<RemoteSearchSource, { delay: number; minimumLength: number }> = {
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,
};
};
@@ -1,12 +1,13 @@
import { Avatar, Group, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconEye } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children";
import { filterCatalog, useAppsCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import { useSpotlightContextResults } from "../home/context";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -58,6 +59,7 @@ const appChildrenOptions = createChildrenOptions<App>({
<Avatar
size="sm"
src={options.iconUrl}
alt=""
radius={0}
styles={{
image: {
@@ -75,11 +77,13 @@ const appChildrenOptions = createChildrenOptions<App>({
export const appsSearchGroup = createGroup<App>({
keyPath: "id",
title: (t) => t("common.entity.apps"),
source: { kind: "remote", source: "apps" },
Component: (app) => (
<Group px="md" py="sm">
<Avatar
size="sm"
src={app.iconUrl}
alt=""
radius={0}
styles={{
image: {
@@ -92,6 +96,16 @@ export const appsSearchGroup = createGroup<App>({
),
useInteraction: interaction.children(appChildrenOptions),
useQueryOptions(query) {
return clientApi.app.search.useQuery({ query, limit: 5 });
const catalogQuery = useAppsCatalogQuery();
const localAppKeys = new Set(
useSpotlightContextResults().flatMap((item) => (item.dedupeKey ? [item.dedupeKey] : [])),
);
const apps = (catalogQuery.data ?? []).filter((app) => !localAppKeys.has(`app:${app.id}`));
return {
data: filterCatalog(apps, query, (app) => [app.name]),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
};
},
});
@@ -1,13 +1,14 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { ChildrenAction } from "../../lib/children";
import { filterCatalog, useBoardsCatalogQuery } from "../../lib/catalog";
import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import { clientApi } from "@homarr/api/client";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -126,6 +127,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
export const boardsSearchGroup = createGroup<Board>({
keyPath: "id",
title: (t) => t("common.entity.boards"),
source: { kind: "remote", source: "boards" },
Component: (board) => (
<Group px="md" py="sm">
{board.logoImageUrl ? (
@@ -139,6 +141,11 @@ export const boardsSearchGroup = createGroup<Board>({
),
useInteraction: interaction.children(boardChildrenOptions),
useQueryOptions(query) {
return clientApi.board.search.useQuery({ query, limit: 5 });
const catalogQuery = useBoardsCatalogQuery();
return {
data: filterCatalog(catalogQuery.data ?? [], query, (board) => [board.name]),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
};
},
});
@@ -2,19 +2,22 @@ import { useSession } from "@homarr/auth/client";
import type { SearchGroup } from "../../lib/group";
import type { SearchMode } from "../../lib/mode";
import { contextSpecificAppsSearchGroup } 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[] = [contextSpecificAppsSearchGroup];
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;
@@ -1,15 +1,16 @@
import { Group, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { IntegrationAvatar } from "@homarr/ui";
import { filterCatalog, useIntegrationsCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
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) => (
<Group px="md" py="sm">
<IntegrationAvatar size="sm" kind={integration.kind} />
@@ -19,6 +20,11 @@ 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 catalogQuery = useIntegrationsCatalogQuery();
return {
data: filterCatalog(catalogQuery.data ?? [], query, (integration) => [integration.name, integration.kind]),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
};
},
});
@@ -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;
@@ -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 = ({
</Group>
);
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel: string }) => (
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel?: string }) => (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
<Text size="sm" c="dimmed">
{valueLabel}
</Text>
{valueLabel ? (
<Text size="sm" c="dimmed">
{valueLabel}
</Text>
) : (
<IconChevronRight aria-hidden size={16} />
)}
</Group>
);
@@ -68,7 +72,7 @@ const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
export const useSettingsActions = (_: Record<string, unknown>, 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<string, unknown>, 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, () => 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<string, unknown>, query: string): Settings
if (!children) return null;
return {
key: definition.key,
Component: () => (
<SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.() ?? ""} />
),
Component: () => <SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.()} />,
useInteraction: interaction.children(children),
};
}
@@ -7,6 +7,7 @@ import { useSpotlightContextActions } from "../home/context";
export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.command.group.localCommand.title"),
keyPath: "id",
source: { kind: "local" },
Component(option) {
const icon =
typeof option.icon !== "string" ? (
@@ -36,7 +36,8 @@ type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> =
export const globalCommandGroup = createGroup<Command>({
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 }) => (
<Group px="md" py="sm">
@@ -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;
+3 -1
View File
@@ -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;
@@ -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";
@@ -13,11 +11,13 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children";
import { filterCatalog, useSearchEnginesCatalogQuery } from "../../lib/catalog";
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 SearchEngine = RouterOutputs["searchEngine"]["catalog"][number];
type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number];
@@ -187,7 +187,7 @@ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestCh
return (
<Group mx="md" my="sm" wrap="nowrap">
{options.result.image ? (
<Image src={options.result.image} w={35} h={50} fit="cover" radius={"md"} />
<Image src={options.result.image} alt="" w={35} h={50} fit="cover" radius={"md"} />
) : (
<IconSearch stroke={1.5} size={35} />
)}
@@ -206,11 +206,12 @@ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestCh
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
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 +245,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
return (
<Group mx="md" my="sm" wrap="nowrap">
{searchResult.image ? (
<Image src={searchResult.image} w={35} h={50} fit="cover" radius={"md"} />
<Image src={searchResult.image} alt="" w={35} h={50} fit="cover" radius={"md"} />
) : (
<IconSearch stroke={1.5} size={35} />
)}
@@ -270,7 +271,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
<Stack mx="md" my="sm">
<Text>{options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")}</Text>
<Group>
<img height={24} width={24} src={options.iconUrl} alt={options.name} />
<img height={24} width={24} src={options.iconUrl} alt="" />
<Text>{options.name}</Text>
</Group>
</Stack>
@@ -287,7 +288,7 @@ const parseBangQuery = (query: string) => {
return { bangToken, searchText, locked };
};
const buildSearchUrl = (template: string, query: string) => {
export const buildSearchUrl = (template: string, query: string) => {
const encoded = encodeURIComponent(query);
if (template.includes("{{{s}}}")) {
return template.replaceAll("{{{s}}}", encoded);
@@ -296,36 +297,23 @@ const buildSearchUrl = (template: string, query: string) => {
return template.replaceAll("%s", encoded);
};
export const searchEnginesSearchGroups = createGroup<ExternalOption>({
keyPath: "key",
title: (t) => t("common.entity.searchEngines"),
Component: (option) => {
if (option.kind === "hint") {
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
<IconSearch stroke={1.5} />
<Stack gap={0}>
<Text size="sm">{option.label}</Text>
{option.description ? (
<Text size="xs" c="gray.6">
{option.description}
</Text>
) : null}
</Stack>
</Group>
);
}
interface SearchEngineGroupOptions {
minimumLength: number;
showEmptyHint: boolean;
source: "focused" | "fallback";
}
if (option.kind === "search") {
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
{option.iconUrl ? (
<img height={24} width={24} src={option.iconUrl} alt={option.label} />
) : (
<IconSearch stroke={1.5} />
)}
<Stack gap={0} justify="center">
const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }: SearchEngineGroupOptions) =>
createGroup<ExternalOption>({
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 (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
<IconSearch stroke={1.5} />
<Stack gap={0}>
<Text size="sm">{option.label}</Text>
{option.description ? (
<Text size="xs" c="gray.6">
@@ -334,22 +322,61 @@ export const searchEnginesSearchGroups = createGroup<ExternalOption>({
) : null}
</Stack>
</Group>
);
}
<Kbd size="sm">!{option.bang}</Kbd>
</Group>
);
}
if (option.kind === "search") {
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
{option.iconUrl ? (
<img height={24} width={24} src={option.iconUrl} alt="" />
) : (
<IconSearch stroke={1.5} />
)}
<Stack gap={0} justify="center">
<Text size="sm">{option.label}</Text>
{option.description ? (
<Text size="xs" c="gray.6">
{option.description}
</Text>
) : null}
</Stack>
</Group>
if (option.kind === "engine") {
const { iconUrl, name, short, description } = option.engine;
<Kbd size="sm">!{option.bang}</Kbd>
</Group>
);
}
if (option.kind === "engine") {
const { iconUrl, name, short, description } = option.engine;
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
<img height={24} width={24} src={iconUrl} alt="" />
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{description}
</Text>
</Stack>
</Group>
<Kbd size="sm">!{short}</Kbd>
</Group>
);
}
const { s: name, t: short, d: domain } = option.bang;
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
<img height={24} width={24} src={iconUrl} alt={name} />
<IconSearch stroke={1.5} />
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{description}
{domain ?? "DuckDuckGo bang"}
</Text>
</Stack>
</Group>
@@ -357,138 +384,134 @@ export const searchEnginesSearchGroups = createGroup<ExternalOption>({
<Kbd size="sm">!{short}</Kbd>
</Group>
);
}
},
useInteraction(option, query) {
const { openSearchInNewTab } = useSettings();
const { bangToken, searchText } = parseBangQuery(query);
const { s: name, t: short, d: domain } = option.bang;
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
<IconSearch stroke={1.5} />
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{domain ?? "DuckDuckGo bang"}
</Text>
</Stack>
</Group>
if (option.kind === "search") {
return {
type: "link",
href: buildSearchUrl(option.urlTemplate, option.searchText),
newTab: openSearchInNewTab,
};
}
<Kbd size="sm">!{short}</Kbd>
</Group>
);
},
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 });
const ddgQueryEnabled = remoteQuery.enabled && ddgBangs && remoteQuery.query.length >= 1;
const enginesQuery = useSearchEnginesCatalogQuery();
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 ddgQuery = clientApi.bangs.search.useQuery(
{ query: remoteQuery.query, limit: 10 },
{
enabled: ddgQueryEnabled,
placeholderData: (previousData) => previousData,
},
);
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 engineCatalog = enginesQuery.data ?? [];
const engineData = filterCatalog(engineCatalog, bangToken, (engine) => [engine.short, engine.name], 10);
const ddgData = ddgQueryEnabled && bangToken.length > 0
? (ddgQuery.data ?? []).filter((bang) => bang.t.toLowerCase().startsWith(bangToken.toLowerCase()))
: [];
const isWaitingForDdgResults =
ddgBangs &&
bangToken.length > 0 &&
engineData.length === 0 &&
(remoteQuery.query !== bangToken || (ddgQueryEnabled && ddgQuery.isFetching && ddgData.length === 0));
const isLoading = enginesQuery.isLoading || isWaitingForDdgResults;
const isError = enginesQuery.isError || (ddgQuery.isError && ddgQueryEnabled && bangToken.length > 0);
const enginesQuery = clientApi.searchEngine.search.useQuery(
{ query: debouncedBangToken, limit: 10 },
{ placeholderData: keepPreviousData },
);
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 = engineData.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 = ddgData
.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 = engineCatalog.find((engine) => engine.short === bangToken);
const matchedDdg = ddgData.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 genericEngine = matchedEngine?.type === "generic" ? matchedEngine : undefined;
const label = genericEngine?.name ?? matchedDdg?.s;
const iconUrl = genericEngine?.iconUrl;
const urlTemplate = genericEngine?.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 && bangToken.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",
});
@@ -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 }) => (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<Icon stroke={1.5} />
<Text>{name}</Text>
</Group>
),
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 (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
<Text>{t("help")}</Text>
{character && <Kbd size="sm">{character}</Kbd>}
</Group>
);
},
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) => (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<props.icon stroke={1.5} />
<Text>{props.label}</Text>
</Group>
),
filter: () => true,
useInteraction: interaction.link(({ href }) => ({ href, newTab: true })),
}),
] as const;
};
export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()];
@@ -7,39 +7,89 @@ 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" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt="" />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.unavailable ? 0.55 : 1}>
{icon}
<Stack gap={0}>
<Text>{option.name}</Text>
{option.description ? (
<Text size="xs" c="dimmed">
{option.description}
</Text>
) : null}
</Stack>
</Group>
);
};
export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.home.group.local.title"),
keyPath: "id",
Component(option) {
const icon =
typeof option.icon !== "string" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt={option.name} />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.unavailable ? 0.55 : 1}>
{icon}
<Stack gap={0}>
<Text>{option.name}</Text>
{option.description && (
<Text size="xs" c="dimmed">
{option.description}
</Text>
)}
</Stack>
</Group>
);
},
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 contextSpecificAppsSearchGroup = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.home.group.local.title"),
keyPath: "id",
source: { kind: "local" },
Component: renderContextSpecificItem,
useInteraction(option, query) {
return option.interaction(query);
},
filter(query, option) {
return query.trim().length === 0 || shouldShowContextSpecificResult(query, option);
},
sort: localResultSort,
useOptions() {
return useSpotlightContextResults().filter((item) => item.placement !== "fallback");
},
});
export const contextSpecificFallbackSearchGroup = createGroup<ContextSpecificItem>({
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");
},
});
+19 -1
View File
@@ -11,10 +11,13 @@ export type ContextSpecificItem = {
name: string;
icon: TablerIcon | string;
interaction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
aliases?: string[];
placement?: "primary" | "fallback";
disabled?: boolean;
description?: string;
unavailable?: boolean;
alwaysVisible?: boolean;
dedupeKey?: string;
};
interface SpotlightContextProps {
@@ -60,7 +63,22 @@ const createSpotlightContext = (displayName: string) => {
});
}, []);
const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]);
const items = useMemo(() => {
const uniqueItems: ContextSpecificItem[] = [];
const itemKeys = new Set<string>();
for (const registration of itemsMap.values()) {
for (const item of registration.items) {
const itemKey = item.dedupeKey ?? item.id;
if (itemKeys.has(itemKey)) continue;
itemKeys.add(itemKey);
uniqueItems.push(item);
}
}
return uniqueItems;
}, [itemsMap]);
const itemsContext = useMemo(() => ({ items }), [items]);
const registration = useMemo(() => ({ registerItems, unregisterItems }), [registerItems, unregisterItems]);
@@ -1,19 +1,21 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { IconSearch, IconSearchOff } from "@tabler/icons-react";
import { IconSearchOff } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { Session } from "@homarr/auth";
import { useSession } from "@homarr/auth/client";
import { useSettings } from "@homarr/settings";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { useSearchEnginesCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { useFromIntegrationSearchInteraction } from "../external/search-engines-search-group";
import { buildSearchUrl } from "../external/search-engines-search-group";
type SearchEngine = RouterOutputs["searchEngine"]["catalog"][number];
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type GroupItem = {
id: string;
@@ -21,19 +23,20 @@ type GroupItem = {
description?: string;
icon: TablerIcon | string;
disabled?: boolean;
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
interaction: inferSearchInteractionDefinition<SearchInteraction>;
};
export const homeSearchEngineGroup = createGroup<GroupItem>({
title: (t) => t("search.mode.home.group.search.title"),
keyPath: "id",
source: { kind: "fallback" },
Component(item) {
const icon =
typeof item.icon !== "string" ? (
<item.icon size={24} />
) : (
<Box w={24} h={24}>
<img src={item.icon} alt={item.name} style={{ maxWidth: 24 }} />
<img src={item.icon} alt="" style={{ maxWidth: 24 }} />
</Box>
);
@@ -42,121 +45,95 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
{icon}
<Stack gap={0}>
<Text>{item.name}</Text>
{item.description && (
{item.description ? (
<Text c="gray.6" size="sm">
{item.description}
</Text>
)}
) : null}
</Stack>
</Group>
);
},
useInteraction(item, query) {
return item.useInteraction(query);
},
filter() {
return true;
useInteraction(item) {
return item.interaction;
},
useQueryOptions(query) {
const t = useI18n();
const { data: session, status } = useSession();
const { data: defaultSearchEngine, ...defaultSearchEngineQuery } =
clientApi.searchEngine.getDefaultSearchEngine.useQuery(undefined, {
enabled: status !== "loading",
});
const fromIntegrationEnabled = defaultSearchEngine?.type === "fromIntegration" && query.length > 0;
const { data: results, ...resultQuery } = clientApi.integration.searchInIntegration.useQuery(
{
query,
integrationId: defaultSearchEngine?.integrationId ?? "",
},
{
enabled: fromIntegrationEnabled,
select: (data) => data.slice(0, 5),
},
);
const { data: session } = useSession();
const { openSearchInNewTab } = useSettings();
const catalogQuery = useSearchEnginesCatalogQuery();
return {
isLoading:
defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading",
isError: defaultSearchEngineQuery.isError || (resultQuery.isError && fromIntegrationEnabled),
data: createDefaultSearchEntries(defaultSearchEngine, results, session, query, t),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
data: createSearchEntries(
catalogQuery.data ?? [],
session?.user.permissions.includes("search-engine-create") === true,
query,
openSearchInNewTab,
t,
),
};
},
});
const createDefaultSearchEntries = (
defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null,
results: RouterOutputs["integration"]["searchInIntegration"] | undefined,
session: Session | null,
const createSearchEntries = (
searchEngines: SearchEngine[],
canCreateSearchEngine: boolean,
query: string,
openInNewTab: boolean,
t: TranslationFunction,
): GroupItem[] => {
if (!session?.user && !defaultSearchEngine) return [];
if (!defaultSearchEngine) {
if (searchEngines.length === 0) {
return [
{
id: "no-default",
name: t("search.mode.home.group.search.option.no-default.label"),
description: t("search.mode.home.group.search.option.no-default.description"),
id: "no-search-engines",
name: t("search.mode.home.group.search.option.no-engines.label"),
description: canCreateSearchEngine
? t("search.mode.home.group.search.option.no-engines.descriptionCreate")
: t("search.mode.home.group.search.option.no-engines.descriptionUnavailable"),
icon: IconSearchOff,
useInteraction() {
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: `/manage/users/${session!.user.id}/general`,
};
},
disabled: !canCreateSearchEngine,
interaction: canCreateSearchEngine ? { type: "link", href: "/manage/search-engines/new" } : { type: "none" },
},
];
}
if (defaultSearchEngine.type === "generic") {
return [
{
id: "search",
name: t("search.mode.home.group.search.option.search.label", {
query,
name: defaultSearchEngine.name,
}),
icon: defaultSearchEngine.iconUrl,
useInteraction(query) {
const { openSearchInNewTab } = useSettings();
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: defaultSearchEngine.urlTemplate!.replace("%s", query),
newTab: openSearchInNewTab,
};
},
},
];
}
const normalizedQuery = query.trim();
return searchEngines.map((searchEngine) => {
const name =
normalizedQuery.length === 0
? searchEngine.name
: t("search.mode.home.group.search.option.search.label", {
query: normalizedQuery,
name: searchEngine.name,
});
if (!results) {
return [
{
id: "from-integration",
name: defaultSearchEngine.name,
icon: defaultSearchEngine.iconUrl,
description: t("search.mode.home.group.search.option.from-integration.description"),
useInteraction() {
return {
type: "none",
};
if (searchEngine.type === "generic") {
return {
id: `search-${searchEngine.id}`,
name,
description: searchEngine.description ?? undefined,
icon: searchEngine.iconUrl,
interaction: {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: buildSearchUrl(searchEngine.urlTemplate!, normalizedQuery),
newTab: openInNewTab,
},
},
];
}
};
}
return results.map((result) => ({
id: `search-${result.id}`,
name: result.name,
description: result.text,
icon: result.image ?? IconSearch,
useInteraction() {
return useFromIntegrationSearchInteraction(defaultSearchEngine, result);
},
}));
return {
id: `search-${searchEngine.id}`,
name,
description: searchEngine.description ?? undefined,
icon: searchEngine.iconUrl,
interaction: {
type: "mode",
mode: "external",
query: `${searchEngine.short} ${normalizedQuery}`.trimEnd(),
},
};
});
};
+33 -5
View File
@@ -1,10 +1,38 @@
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 { homeSearchEngineGroup } from "./home-search-engine-group";
import {
contextSpecificAppsSearchGroup,
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 !== contextSpecificAppsSearchGroup);
const directoryGroups = userGroupMode.useGroups();
return [
contextSpecificSearchGroups,
pagesSearchGroup,
contextSpecificActionsSearchGroups,
globalCommandGroup,
preferencesGroup,
...appGroups,
...directoryGroups,
homeSearchEngineGroup,
contextSpecificFallbackSearchGroup,
mediaFallbackGroup,
];
},
} satisfies SearchMode;
+9 -7
View File
@@ -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;
+3 -1
View File
@@ -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;
@@ -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<MediaFallbackOption>({
keyPath: "key",
title: (t) => t("search.modePicker.continueWith"),
source: { kind: "fallback" },
Component(option) {
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.unavailable ? 0.5 : 1}>
<IconMovie stroke={1.5} />
<Stack gap={0}>
<Text>{option.label}</Text>
<Text size="xs" c="dimmed">
{option.description}
</Text>
</Stack>
</Group>
);
},
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.signInRequired"),
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,
},
],
};
},
});
@@ -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<MediaRequestSearchOption>({
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<MediaRequestSearchOption>({
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{result.image ? (
<Image src={result.image} w={35} h={50} fit="cover" radius="md" />
<Image src={result.image} alt="" w={35} h={50} fit="cover" radius="md" />
) : (
<IconSearch stroke={1.5} size={35} />
)}
@@ -98,21 +106,24 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
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<MediaRequestSearchOption>({
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.signInRequired"),
unavailable: true,
},
],
isLoading: false,
isError: false,
};
}
if (!targetsQuery.data) {
return {
isLoading,
@@ -140,12 +171,13 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
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,
@@ -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;
@@ -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 }) => (
<Group px="md" py="sm">
<Icon stroke={1.5} />
@@ -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<Record<string, unknown>>;
const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null };
export const preferencesGroup = createGroup<PreferencesGroupOption>({
/** Preferences are normal command-menu actions, not a gateway into another hidden list. */
export const preferencesGroup = createGroup<PreferenceAction>({
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 (
<Group px="md" py="sm" wrap="nowrap" gap="sm">
<Icon stroke={1.5} />
<Text>{label}</Text>
</Group>
);
Component(option) {
return <option.Component />;
},
useInteraction(option, query) {
return option.useInteraction({}, query);
},
filter: () => true,
useInteraction: (option) => ({
type: "children",
...(option.preferenceKey
? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ??
settingsChildrenOptions({}))
: settingsChildrenOptions({})),
}),
});
@@ -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;
@@ -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<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -71,6 +72,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
export const groupsSearchGroup = createGroup<Group>({
keyPath: "id",
title: (t) => t("common.entity.groups"),
source: { kind: "remote", source: "groups" },
Component: ({ name }) => (
<Group px="md" py="sm">
<Text>{name}</Text>
@@ -78,6 +80,7 @@ export const groupsSearchGroup = createGroup<Group>({
),
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 });
},
});
@@ -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;
@@ -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<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -49,6 +50,7 @@ const userChildrenOptions = createChildrenOptions<User>({
export const usersSearchGroup = createGroup<User>({
keyPath: "id",
title: (t) => t("common.entity.users"),
source: { kind: "remote", source: "users" },
Component: (user) => (
<Group px="md" py="sm">
<UserAvatar user={user} size="sm" />
@@ -57,6 +59,7 @@ export const usersSearchGroup = createGroup<User>({
),
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 });
},
});
+12 -6
View File
@@ -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);
+37 -9
View File
@@ -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<SpotlightOpenIntent>(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<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
+53 -3
View File
@@ -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"
},
@@ -7000,6 +7048,7 @@
"description": "Start typing to search your media request integrations",
"disabled": {
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
"signInRequired": "Sign in to search media request integrations.",
"scoped": "This widget does not have an accessible media request integration."
}
}
@@ -7077,9 +7126,10 @@
"other": {
"label": "Search with another search engine"
},
"no-default": {
"label": "No default search engine",
"description": "Set a default search engine in preferences"
"no-engines": {
"label": "No search engines configured",
"descriptionCreate": "Add a search engine to search from the command menu",
"descriptionUnavailable": "Ask an administrator to configure a search engine"
},
"search": {
"label": "Search for \"{query}\" with {name}"
@@ -49,7 +49,7 @@ import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar";
import { SelectableCard } from "../components/selectable-card";
import { selectGridCols } from "../select-grid-layout";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
import { buildSortedIntegrations, filterIntegrations } from "./integration-grid-shared";
import type { IntegrationGridItem } from "./integration-grid-shared";
export interface IntegrationSelectGridProps {
@@ -134,8 +134,11 @@ const IntegrationCard = ({
onClick={() => onSelect(integration.kind)}
aria-label={integration.name}
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
title={integration.name}
topRight={<IntegrationCategoryBadges categories={integration.categories} limit={1} />}
title={
<Text fw={700} size="md" style={{ whiteSpace: "nowrap" }}>
{integration.name}
</Text>
}
footerLeft={
connectedCount > 0 ? (
<Badge variant="light" color="teal" size="xs" radius="xs">
@@ -232,24 +235,3 @@ const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; lim
</Group>
);
};
const IntegrationCategoryBadges = ({
categories,
limit,
}: {
categories: IntegrationGridItem["categories"];
limit?: number;
}) => {
const t = useI18n();
const items = limit ? categories.slice(0, limit) : categories;
return (
<Group gap={4} wrap="wrap">
{items.map((category) => (
<Badge key={category} variant="light" size="xs" color="gray" radius="xs">
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
</Badge>
))}
</Group>
);
};
-21
View File
@@ -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 <WidgetEmptyState />;
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
@@ -73,6 +73,7 @@ export default function BookmarksWidget({
return [
{
id: bookmark.id,
dedupeKey: appsById.has(bookmark.id) ? `app:${bookmark.id}` : undefined,
name: bookmark.name,
icon: bookmark.iconUrl ?? IconLink,
interaction() {
@@ -148,10 +149,7 @@ export default function BookmarksWidget({
</Stack>
</Center>
) : (
<ScrollArea
scrollbars={plan.horizontalScroll ? "x" : "y"}
style={{ flex: 1, minHeight: 0 }}
>
<ScrollArea scrollbars={plan.horizontalScroll ? "x" : "y"} style={{ flex: 1, minHeight: 0 }}>
<Box miw="100%" mih="100%" pb={2}>
{plan.horizontalScroll ? (
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">