Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb238ed4f |
No files matched your search
@@ -8,28 +8,13 @@ Homarr has the following keyboard shortcuts:
|
|||||||
|
|
||||||
| Shortcut | Description |
|
| Shortcut | Description |
|
||||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `MOD` + `K` | Opens search, including local apps, boards, integrations, pages, commands, and preferences |
|
| `MOD` + `K` | Opens the search bar |
|
||||||
| `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` + `J` | Toggles Dark Mode |
|
||||||
| `MOD` + `E` | Toggles Edit Mode |
|
| `MOD` + `E` | Toggles Edit Mode |
|
||||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
| 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 |
|
| 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 |
|
| `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
|
## Widget advanced view
|
||||||
|
|
||||||
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
|
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ host gateway address.
|
|||||||
## Use it
|
## Use it
|
||||||
|
|
||||||
- Select **Assistant** in the user menu or press `Shift + A`.
|
- Select **Assistant** in the user menu or press `Shift + A`.
|
||||||
- Press `Cmd/Ctrl + /` to ask from search, or use `Cmd/Ctrl + K` and choose the Assistant fallback.
|
- Ask from `Cmd/Ctrl + K`.
|
||||||
- Add the [Assistant widget](../widgets/assistant) to a board.
|
- 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
|
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
|
||||||
|
|||||||
@@ -7,13 +7,9 @@ tags:
|
|||||||
|
|
||||||
# Search Engines
|
# 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 `MOD + K`.
|
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`.
|
||||||
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
|
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.
|
You can add any search engine that has a search URL that accepts a search term as a parameter.
|
||||||
|
|
||||||
## Create search engine
|
## Create search engine
|
||||||
@@ -30,8 +26,6 @@ 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.
|
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).
|
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
|
### Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { FloatingTip } from "@homarr/ui";
|
import { FloatingTip } from "@homarr/ui";
|
||||||
|
|
||||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
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 { BoardEmptyState } from "~/components/board/board-empty-state";
|
||||||
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
||||||
import {
|
import {
|
||||||
@@ -80,7 +79,6 @@ export const ClientBoard = () => {
|
|||||||
<BoardSelectionGridProvider>
|
<BoardSelectionGridProvider>
|
||||||
<BoardAdvancedFocusProvider>
|
<BoardAdvancedFocusProvider>
|
||||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||||
<BoardAppsSpotlightRegistrar />
|
|
||||||
<BoardBackgroundVideo />
|
<BoardBackgroundVideo />
|
||||||
<BoardEmptyState />
|
<BoardEmptyState />
|
||||||
<BoardSetupChecklist />
|
<BoardSetupChecklist />
|
||||||
|
|||||||
@@ -53,13 +53,11 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
|||||||
|
|
||||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||||
|
|
||||||
{!demoReadOnly && (
|
<TourTarget id="board-settings">
|
||||||
<TourTarget id="board-settings">
|
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
<IconSettings stroke={1.5} />
|
||||||
<IconSettings stroke={1.5} />
|
</HeaderButton>
|
||||||
</HeaderButton>
|
</TourTarget>
|
||||||
</TourTarget>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -856,7 +856,6 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
: t("busy.description"),
|
: t("busy.description"),
|
||||||
unavailable: !canSend,
|
unavailable: !canSend,
|
||||||
alwaysVisible: true,
|
alwaysVisible: true,
|
||||||
placement: "fallback" as const,
|
|
||||||
interaction: (query: string) =>
|
interaction: (query: string) =>
|
||||||
createAssistantPromptInteraction({
|
createAssistantPromptInteraction({
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export const useRegisterAssistantSpotlightPlaceholder = (description: string) =>
|
|||||||
description,
|
description,
|
||||||
unavailable: true,
|
unavailable: true,
|
||||||
alwaysVisible: true,
|
alwaysVisible: true,
|
||||||
placement: "fallback" as const,
|
|
||||||
interaction: (_query: string) => ({ type: "none" as const }),
|
interaction: (_query: string) => ({ type: "none" as const }),
|
||||||
}),
|
}),
|
||||||
[description, t],
|
[description, t],
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
"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,7 +26,6 @@ import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useOptionalBoard } from "@homarr/boards/context";
|
import { useOptionalBoard } from "@homarr/boards/context";
|
||||||
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { Link, UserAvatar } from "@homarr/ui";
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
@@ -42,7 +41,6 @@ interface BoardSwitcherProps {
|
|||||||
|
|
||||||
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||||
const t = useI18n("board.action.switcher");
|
const t = useI18n("board.action.switcher");
|
||||||
const tBoard = useI18n("board");
|
|
||||||
const manageBoardsT = useI18n("management.page.board");
|
const manageBoardsT = useI18n("management.page.board");
|
||||||
const currentBoard = useOptionalBoard();
|
const currentBoard = useOptionalBoard();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -87,20 +85,6 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
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 }]]);
|
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import type { HotkeyItem } from "@mantine/hooks";
|
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
|
|
||||||
import { hotkeys } from "@homarr/definitions";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
@@ -14,18 +13,23 @@ const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component })
|
|||||||
loading: () => null,
|
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 = () => {
|
export const LazySpotlight = () => {
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const [isPreloaded, setIsPreloaded] = useState(false);
|
const [isPreloaded, setIsPreloaded] = useState(false);
|
||||||
|
|
||||||
useHotkeys(spotlightHotkeys, [], true);
|
useHotkeys(
|
||||||
|
isMounted
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
[
|
||||||
|
hotkeys.openSpotlight,
|
||||||
|
() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
openSpotlight();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
@@ -57,8 +61,12 @@ export const LazySpotlight = () => {
|
|||||||
return isMounted || isPreloaded ? (
|
return isMounted || isPreloaded ? (
|
||||||
<>
|
<>
|
||||||
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
||||||
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
|
{isMounted && (
|
||||||
<Spotlight />
|
<>
|
||||||
|
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
|
||||||
|
<Spotlight />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
@@ -20,7 +20,7 @@ export const DesktopSearchInput = () => {
|
|||||||
w={400}
|
w={400}
|
||||||
size="sm"
|
size="sm"
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
onClick={() => openSpotlight()}
|
onClick={openSpotlight}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
>
|
>
|
||||||
{`${t("search.placeholder")}...`}
|
{`${t("search.placeholder")}...`}
|
||||||
@@ -32,7 +32,7 @@ export const MobileSearchButton = () => {
|
|||||||
return (
|
return (
|
||||||
<HeaderButton
|
<HeaderButton
|
||||||
data-homarr-dev-benchmark-interaction="search"
|
data-homarr-dev-benchmark-interaction="search"
|
||||||
onClick={() => openSpotlight()}
|
onClick={openSpotlight}
|
||||||
className={classes.mobileSearch}
|
className={classes.mobileSearch}
|
||||||
>
|
>
|
||||||
<IconSearch size={20} stroke={1.5} />
|
<IconSearch size={20} stroke={1.5} />
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
|
||||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||||
import { createId, generateResponsiveGridFor } from "@homarr/common";
|
import { createId, generateResponsiveGridFor } from "@homarr/common";
|
||||||
import type { GridAlgorithmItem } from "@homarr/common";
|
import type { GridAlgorithmItem } from "@homarr/common";
|
||||||
@@ -124,78 +123,6 @@ const findFirstAvailableBoardItemPosition = (
|
|||||||
return null;
|
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({
|
export const boardRouter = createTRPCRouter({
|
||||||
exists: permissionRequiredProcedure
|
exists: permissionRequiredProcedure
|
||||||
.requiresPermission("board-create")
|
.requiresPermission("board-create")
|
||||||
@@ -555,18 +482,80 @@ 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
|
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) }))
|
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||||
.query(async ({ ctx, input }) => searchAccessibleBoardsAsync(ctx, input.query, input.limit)),
|
.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,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
createBoard: permissionRequiredProcedure
|
createBoard: permissionRequiredProcedure
|
||||||
.requiresPermission("board-create")
|
.requiresPermission("board-create")
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -130,28 +130,6 @@ export const searchEngineRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return null;
|
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 }) => {
|
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||||
return await ctx.db.query.searchEngines.findMany({
|
return await ctx.db.query.searchEngines.findMany({
|
||||||
// Public dashboards have no session: restrict anonymous users to generic
|
// Public dashboards have no session: restrict anonymous users to generic
|
||||||
|
|||||||
@@ -19,10 +19,8 @@ const MCP_TOOL_ALLOWLIST = {
|
|||||||
"beszel_getAlerts",
|
"beszel_getAlerts",
|
||||||
"beszel_getSystems",
|
"beszel_getSystems",
|
||||||
"beszel_getSystemStats",
|
"beszel_getSystemStats",
|
||||||
"board_catalog",
|
|
||||||
"board_getAllBoards",
|
"board_getAllBoards",
|
||||||
"board_getBoardSettings",
|
"board_getBoardSettings",
|
||||||
"board_search",
|
|
||||||
"calendar_findAllEvents",
|
"calendar_findAllEvents",
|
||||||
"customWidget_get",
|
"customWidget_get",
|
||||||
"customWidget_getAuthoringPrompt",
|
"customWidget_getAuthoringPrompt",
|
||||||
|
|||||||
@@ -3,8 +3,5 @@ export const hotkeys = {
|
|||||||
toggleColorScheme: "mod+j",
|
toggleColorScheme: "mod+j",
|
||||||
saveNotebook: "mod+s",
|
saveNotebook: "mod+s",
|
||||||
openSpotlight: "mod+k",
|
openSpotlight: "mod+k",
|
||||||
openSpotlightApps: "mod+p",
|
|
||||||
openSpotlightAssistant: "mod+/",
|
|
||||||
openSpotlightPreferences: "mod+shift+?",
|
|
||||||
openAssistant: "shift+a",
|
openAssistant: "shift+a",
|
||||||
};
|
};
|
||||||
@@ -1,32 +1,22 @@
|
|||||||
import { Center } from "@mantine/core";
|
import { Center, Loader } from "@mantine/core";
|
||||||
|
import { useWindowEvent } from "@mantine/hooks";
|
||||||
|
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { SearchGroup } from "../../lib/group";
|
import type { SearchGroup } from "../../lib/group";
|
||||||
import type { inferSearchInteractionOptions } from "../../lib/interaction";
|
import type { inferSearchInteractionOptions } from "../../lib/interaction";
|
||||||
import type { SpotlightMode } from "../../open";
|
import { SpotlightNoResults } from "../no-results";
|
||||||
import { SpotlightGroupActionItem } from "./items/group-action-item";
|
import { SpotlightGroupActionItem } from "./items/group-action-item";
|
||||||
|
|
||||||
interface GroupActionsProps<TOption extends Record<string, unknown>> {
|
interface GroupActionsProps<TOption extends Record<string, unknown>> {
|
||||||
group: SearchGroup<TOption>;
|
group: SearchGroup<TOption>;
|
||||||
query: string;
|
query: string;
|
||||||
setQuery: (query: string) => void;
|
setQuery: (query: string) => void;
|
||||||
setMode: (mode: SpotlightMode) => void;
|
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => 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>>({
|
export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
||||||
group,
|
group,
|
||||||
query,
|
query,
|
||||||
@@ -40,26 +30,28 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
|||||||
const options = useOptions(query);
|
const options = useOptions(query);
|
||||||
const t = useI18n();
|
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 (Array.isArray(options)) {
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredOptions = getUniqueOptions(
|
const filteredOptions = options
|
||||||
options
|
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
|
||||||
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
|
.sort((optionA, optionB) => {
|
||||||
.sort((optionA, optionB) => {
|
if ("sort" in group) {
|
||||||
if ("sort" in group) {
|
return group.sort?.(query, [optionA, optionB]) ?? 0;
|
||||||
return group.sort?.(query, [optionA, optionB]) ?? 0;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}),
|
});
|
||||||
group.keyPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (filteredOptions.length === 0) {
|
if (filteredOptions.length === 0) {
|
||||||
return null;
|
return <SpotlightNoResults />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredOptions.map((option) => (
|
return filteredOptions.map((option) => (
|
||||||
@@ -76,15 +68,15 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.isLoading) {
|
if (options.isLoading) {
|
||||||
return <span hidden data-search-pending />;
|
return (
|
||||||
|
<Center w="100%" py="sm">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.isError) {
|
if (options.isError) {
|
||||||
return (
|
return <Center py="sm">{t("search.error.fetch")}</Center>;
|
||||||
<Center py="sm" data-search-status>
|
|
||||||
{t("search.error.fetch")}
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.data) {
|
if (!options.data) {
|
||||||
@@ -92,10 +84,10 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.data.length === 0) {
|
if (options.data.length === 0) {
|
||||||
return null;
|
return <SpotlightNoResults />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getUniqueOptions(options.data, group.keyPath).map((option) => (
|
return options.data.map((option) => (
|
||||||
<SpotlightGroupActionItem
|
<SpotlightGroupActionItem
|
||||||
key={option[group.keyPath] as never}
|
key={option[group.keyPath] as never}
|
||||||
option={option}
|
option={option}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
|
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { translateIfNecessary } from "@homarr/translation";
|
import { translateIfNecessary } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { SearchGroup } from "../../../lib/group";
|
import type { SearchGroup } from "../../../lib/group";
|
||||||
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||||
import type { SpotlightMode } from "../../../open";
|
|
||||||
import { SpotlightGroupActions } from "../group-actions";
|
import { SpotlightGroupActions } from "../group-actions";
|
||||||
|
|
||||||
interface SpotlightActionGroupsProps {
|
interface SpotlightActionGroupsProps {
|
||||||
groups: SearchGroup[];
|
groups: SearchGroup[];
|
||||||
query: string;
|
query: string;
|
||||||
setQuery: (query: string) => void;
|
setQuery: (query: string) => void;
|
||||||
setMode: (mode: SpotlightMode) => void;
|
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +23,6 @@ export const SpotlightActionGroups = ({ groups, ...others }: SpotlightActionGrou
|
|||||||
<Spotlight.ActionsGroup
|
<Spotlight.ActionsGroup
|
||||||
key={`${translateIfNecessary(t, group.title)}::${String(group.keyPath)}`}
|
key={`${translateIfNecessary(t, group.title)}::${String(group.keyPath)}`}
|
||||||
label={translateIfNecessary(t, group.title)}
|
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 */}
|
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<SpotlightGroupActions<any> group={group} {...others} />
|
<SpotlightGroupActions<any> group={group} {...others} />
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
|
|
||||||
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common";
|
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common";
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import type { SearchGroup } from "../../../lib/group";
|
import type { SearchGroup } from "../../../lib/group";
|
||||||
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||||
import type { SpotlightMode } from "../../../open";
|
|
||||||
import { selectAction, spotlightStore } from "../../../spotlight-store";
|
import { selectAction, spotlightStore } from "../../../spotlight-store";
|
||||||
import classes from "./action-item.module.css";
|
import classes from "./action-item.module.css";
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ interface SpotlightGroupActionItemProps<TOption extends Record<string, unknown>>
|
|||||||
option: TOption;
|
option: TOption;
|
||||||
query: string;
|
query: string;
|
||||||
setQuery: (query: string) => void;
|
setQuery: (query: string) => void;
|
||||||
setMode: (mode: SpotlightMode) => void;
|
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||||
group: SearchGroup<TOption>;
|
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 safeHref = interaction.type === "link" ? getSafeAppHref(interaction.href) : undefined;
|
||||||
const renderRoot =
|
const renderRoot =
|
||||||
interaction.type === "link" && safeHref && !unavailable
|
interaction.type === "link" && safeHref
|
||||||
? (props: Record<string, unknown>) => {
|
? (props: Record<string, unknown>) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -48,8 +48,6 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleClickAsync = async () => {
|
const handleClickAsync = async () => {
|
||||||
if (unavailable) return;
|
|
||||||
|
|
||||||
if (interaction.type === "javaScript") {
|
if (interaction.type === "javaScript") {
|
||||||
await interaction.onSelect();
|
await interaction.onSelect();
|
||||||
} else if (interaction.type === "setQuery") {
|
} else if (interaction.type === "setQuery") {
|
||||||
@@ -78,7 +76,6 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
|||||||
}
|
}
|
||||||
className={classes.spotlightAction}
|
className={classes.spotlightAction}
|
||||||
aria-disabled={unavailable || undefined}
|
aria-disabled={unavailable || undefined}
|
||||||
disabled={unavailable}
|
|
||||||
>
|
>
|
||||||
<group.Component {...(optionProps as TOption)} />
|
<group.Component {...(optionProps as TOption)} />
|
||||||
</Spotlight.Action>
|
</Spotlight.Action>
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
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";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
export const SpotlightNoResults = ({ className }: { className?: string }) => {
|
export const SpotlightNoResults = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return <Spotlight.Empty className={className}>{t("search.nothingFound")}</Spotlight.Empty>;
|
return <Spotlight.Empty>{t("search.nothingFound")}</Spotlight.Empty>;
|
||||||
};
|
};
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ActionIcon, Center, Group, Kbd, Text } from "@mantine/core";
|
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||||
import { IconArrowDown, IconArrowLeft, IconArrowUp, IconCornerDownLeft, IconSearch, IconX } from "@tabler/icons-react";
|
import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
|
|
||||||
import { translateIfNecessary } from "@homarr/translation";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -16,142 +17,74 @@ import {
|
|||||||
mediaRequestSearchEvent,
|
mediaRequestSearchEvent,
|
||||||
spotlightOpenEvent,
|
spotlightOpenEvent,
|
||||||
} from "../open";
|
} from "../open";
|
||||||
import type { OpenMediaRequestSearchOptions, SpotlightMode, SpotlightOpenIntent } from "../open";
|
import type { OpenMediaRequestSearchOptions } from "../open";
|
||||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||||
import type { SearchMode } from "../lib/mode";
|
import type { SearchMode } from "../lib/mode";
|
||||||
import { useSpotlightCatalogs } from "../lib/catalog";
|
|
||||||
import { searchModes } from "../modes";
|
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 { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
|
||||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
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">;
|
type ChildrenOptions = inferSearchInteractionOptions<"children">;
|
||||||
|
|
||||||
const defaultMode: SpotlightMode = "search";
|
const defaultMode = "home";
|
||||||
|
|
||||||
const resetSelection = () => {
|
|
||||||
requestAnimationFrame(() => selectAction(0, spotlightStore));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Spotlight = () => {
|
export const Spotlight = () => {
|
||||||
useSpotlightCatalogs();
|
const searchModeState = useState<SearchModeKey>(defaultMode);
|
||||||
|
const queryState = useState("");
|
||||||
|
const mode = searchModeState[0];
|
||||||
|
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
|
||||||
|
|
||||||
const [mode, setMode] = useState<SpotlightMode>(defaultMode);
|
if (!activeMode) {
|
||||||
const [query, setQuery] = useState("");
|
return null;
|
||||||
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 (
|
return (
|
||||||
<SpotlightWithActiveMode
|
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
|
||||||
key={mode}
|
|
||||||
mode={mode}
|
|
||||||
setMode={setMode}
|
|
||||||
query={query}
|
|
||||||
setQuery={setQuery}
|
|
||||||
activeMode={activeMode}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SpotlightWithActiveModeProps {
|
interface SpotlightWithActiveModeProps {
|
||||||
mode: SpotlightMode;
|
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
|
||||||
setMode: Dispatch<SetStateAction<SpotlightMode>>;
|
queryState: [string, Dispatch<SetStateAction<string>>];
|
||||||
query: string;
|
|
||||||
setQuery: Dispatch<SetStateAction<string>>;
|
|
||||||
activeMode: SearchMode;
|
activeMode: SearchMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }: SpotlightWithActiveModeProps) => {
|
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
|
||||||
|
const [query, setQuery] = queryState;
|
||||||
|
const [mode, setMode] = modeState;
|
||||||
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
|
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
|
||||||
const childrenOptions = childrenStack.at(-1) ?? null;
|
const childrenOptions = childrenStack.at(-1) ?? null;
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
|
||||||
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
||||||
const groups = useGroups();
|
const groups = useGroups();
|
||||||
const hasModeCharacter = activeMode.character !== undefined;
|
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
|
||||||
const activeModeLabel = translateIfNecessary(t, activeMode.label) ?? activeMode.mode;
|
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
const handleOpen = () => {
|
||||||
|
consumePendingSpotlightOpen();
|
||||||
|
spotlightActions.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(spotlightOpenEvent, handleOpen);
|
||||||
|
if (consumePendingSpotlightOpen()) spotlightActions.open();
|
||||||
|
return () => window.removeEventListener(spotlightOpenEvent, handleOpen);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearChildrenStack = useCallback(() => {
|
const clearChildrenStack = useCallback(() => {
|
||||||
setChildrenStack([]);
|
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(
|
const pushChildrenOptions = useCallback(
|
||||||
(options: ChildrenOptions) => {
|
(options: ChildrenOptions) => {
|
||||||
setChildrenStack((currentStack) => [...currentStack, options]);
|
setChildrenStack((currentStack) => [...currentStack, options]);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
resetSelection();
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
},
|
},
|
||||||
[setQuery],
|
[setQuery],
|
||||||
);
|
);
|
||||||
@@ -159,19 +92,44 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
const popChildrenOptions = useCallback(() => {
|
const popChildrenOptions = useCallback(() => {
|
||||||
setChildrenStack((currentStack) => currentStack.slice(0, -1));
|
setChildrenStack((currentStack) => currentStack.slice(0, -1));
|
||||||
setQuery("");
|
setQuery("");
|
||||||
resetSelection();
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
}, [setQuery]);
|
}, [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 (
|
return (
|
||||||
<MantineSpotlight.Root
|
<MantineSpotlight.Root
|
||||||
shortcut={null}
|
shortcut={hotkeys.openSpotlight}
|
||||||
size={760}
|
yOffset={8}
|
||||||
yOffset={64}
|
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
|
||||||
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={() => {
|
onSpotlightOpen={() => {
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
}}
|
}}
|
||||||
@@ -185,24 +143,22 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
|
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
|
||||||
setQuery(sanitizedQuery);
|
setQuery(sanitizedQuery);
|
||||||
|
|
||||||
if (sanitizedQuery.length === 1) {
|
if (sanitizedQuery.length !== 1) return;
|
||||||
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
|
|
||||||
if (modeToActivate) {
|
|
||||||
changeMode(modeToActivate.mode, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetSelection();
|
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
|
||||||
|
if (!modeToActivate) return;
|
||||||
|
|
||||||
|
setMode(modeToActivate.modeKey);
|
||||||
|
clearChildrenStack();
|
||||||
|
setQuery("");
|
||||||
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
}}
|
}}
|
||||||
store={spotlightStore}
|
store={spotlightStore}
|
||||||
>
|
>
|
||||||
<MantineSpotlight.Search
|
<MantineSpotlight.Search
|
||||||
data-homarr-dev-benchmark-spotlight
|
data-homarr-dev-benchmark-spotlight
|
||||||
aria-label={placeholder}
|
placeholder={`${t("search.placeholder")}...`}
|
||||||
placeholder={placeholder}
|
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className={classes.search}
|
|
||||||
leftSectionWidth={hasModeCharacter ? 80 : 48}
|
leftSectionWidth={hasModeCharacter ? 80 : 48}
|
||||||
leftSection={
|
leftSection={
|
||||||
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
|
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
|
||||||
@@ -212,7 +168,11 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
styles={{ section: { pointerEvents: "all" } }}
|
styles={{
|
||||||
|
section: {
|
||||||
|
pointerEvents: "all",
|
||||||
|
},
|
||||||
|
}}
|
||||||
rightSection={
|
rightSection={
|
||||||
mode === defaultMode && childrenStack.length === 0 ? null : (
|
mode === defaultMode && childrenStack.length === 0 ? null : (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -228,7 +188,9 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
changeMode(defaultMode, true);
|
setMode(defaultMode);
|
||||||
|
clearChildrenStack();
|
||||||
|
inputRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -250,28 +212,20 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode !== defaultMode) changeMode(defaultMode, true);
|
if (mode !== defaultMode) {
|
||||||
|
setMode(defaultMode);
|
||||||
|
clearChildrenStack();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SpotlightModeRail
|
|
||||||
activeMode={mode}
|
|
||||||
entries={modeRailEntries}
|
|
||||||
navigationLabel={t("search.modePicker.navigationLabel")}
|
|
||||||
onModeChange={changeMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{childrenOptions ? (
|
{childrenOptions ? (
|
||||||
<Group key={childrenStack.length}>
|
<Group key={childrenStack.length}>
|
||||||
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
|
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
|
||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MantineSpotlight.ActionsList
|
<MantineSpotlight.ActionsList>
|
||||||
component="section"
|
|
||||||
className={classes.actionsList}
|
|
||||||
aria-label={t("search.modePicker.resultsAriaLabel", { mode: activeModeLabel })}
|
|
||||||
>
|
|
||||||
{childrenOptions ? (
|
{childrenOptions ? (
|
||||||
<SpotlightChildrenActions
|
<SpotlightChildrenActions
|
||||||
key={childrenStack.length}
|
key={childrenStack.length}
|
||||||
@@ -282,46 +236,21 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
) : (
|
) : (
|
||||||
<SpotlightActionGroups
|
<SpotlightActionGroups
|
||||||
setQuery={setQuery}
|
setQuery={setQuery}
|
||||||
setMode={(nextMode) => changeMode(nextMode, true)}
|
setMode={(nextMode) => {
|
||||||
setChildrenOptions={pushChildrenOptions}
|
setMode(nextMode);
|
||||||
|
clearChildrenStack();
|
||||||
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
|
}}
|
||||||
|
setChildrenOptions={(options) => {
|
||||||
|
pushChildrenOptions(options);
|
||||||
|
}}
|
||||||
query={query}
|
query={query}
|
||||||
groups={groups}
|
groups={
|
||||||
|
mode === defaultMode && query.length === 0 ? [...homeEmptyGroups, contextSpecificSearchGroups] : groups
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SpotlightNoResults className={classes.emptyState} />
|
|
||||||
</MantineSpotlight.ActionsList>
|
</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>
|
</MantineSpotlight.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -6,4 +6,3 @@ export {
|
|||||||
useRegisterSpotlightContextResults,
|
useRegisterSpotlightContextResults,
|
||||||
useRegisterSpotlightContextActions,
|
useRegisterSpotlightContextActions,
|
||||||
} from "./modes/home/context";
|
} from "./modes/home/context";
|
||||||
export type { ContextSpecificItem } from "./modes/home/context";
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
"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);
|
|
||||||
};
|
|
||||||
@@ -2,34 +2,26 @@ import type { JSX } from "react";
|
|||||||
|
|
||||||
import type { stringOrTranslation } from "@homarr/translation";
|
import type { stringOrTranslation } from "@homarr/translation";
|
||||||
|
|
||||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "./interaction";
|
import type { inferSearchInteractionDefinition, inferSearchInteractionOptions, SearchInteraction } from "./interaction";
|
||||||
|
|
||||||
export type RemoteSearchSource =
|
type CommonSearchGroup<TOption extends Record<string, unknown>, TOptionProps extends Record<string, unknown>> = {
|
||||||
| "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
|
// key path is used to define the path to a unique key in the option object
|
||||||
keyPath: keyof TOption;
|
keyPath: keyof TOption;
|
||||||
title: stringOrTranslation;
|
title: stringOrTranslation;
|
||||||
source?: SearchGroupSource;
|
|
||||||
Component: (option: TOption) => JSX.Element;
|
Component: (option: TOption) => JSX.Element;
|
||||||
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||||
|
onKeyDown?: (
|
||||||
|
event: KeyboardEvent,
|
||||||
|
options: TOption[],
|
||||||
|
query: string,
|
||||||
|
actions: {
|
||||||
|
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
} & TOptionProps;
|
} & TOptionProps;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type SearchGroup<TOption extends object = any> =
|
export type SearchGroup<TOption extends Record<string, unknown> = any> =
|
||||||
| CommonSearchGroup<TOption, { filter: (query: string, option: TOption) => boolean; options: TOption[] }>
|
| CommonSearchGroup<TOption, { filter: (query: string, option: TOption) => boolean; options: TOption[] }>
|
||||||
| CommonSearchGroup<
|
| CommonSearchGroup<
|
||||||
TOption,
|
TOption,
|
||||||
@@ -44,4 +36,4 @@ export type SearchGroup<TOption extends object = any> =
|
|||||||
{ useQueryOptions: (query: string) => { data: TOption[] | undefined; isLoading: boolean; isError: boolean } }
|
{ useQueryOptions: (query: string) => { data: TOption[] | undefined; isLoading: boolean; isError: boolean } }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const createGroup = <TOption extends object>(group: SearchGroup<TOption>) => group;
|
export const createGroup = <TOption extends Record<string, unknown>>(group: SearchGroup<TOption>) => group;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { MaybePromise } from "@homarr/common/types";
|
import type { MaybePromise } from "@homarr/common/types";
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
|
|
||||||
import type { CreateChildrenOptionsProps } from "./children";
|
import type { CreateChildrenOptionsProps } from "./children";
|
||||||
import type { SpotlightMode } from "../open";
|
|
||||||
|
|
||||||
const createSearchInteraction = <TType extends string>(type: TType) => ({
|
const createSearchInteraction = <TType extends string>(type: TType) => ({
|
||||||
optionsType: <TOption extends Record<string, unknown> | undefined>() => ({ type, _inferOptions: {} as TOption }),
|
optionsType: <TOption extends Record<string, unknown> | undefined>() => ({ type, _inferOptions: {} as TOption }),
|
||||||
@@ -15,7 +15,7 @@ const searchInteractions = [
|
|||||||
closeSpotlightOnTrigger?: boolean;
|
closeSpotlightOnTrigger?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
|
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
|
||||||
createSearchInteraction("mode").optionsType<{ mode: SpotlightMode; query?: string }>(),
|
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
|
||||||
createSearchInteraction("children").optionsType<{
|
createSearchInteraction("children").optionsType<{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
useActions: CreateChildrenOptionsProps<any>["useActions"];
|
useActions: CreateChildrenOptionsProps<any>["useActions"];
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import type { stringOrTranslation } from "@homarr/translation";
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
|
|
||||||
import type { SearchGroup } from "./group";
|
import type { SearchGroup } from "./group";
|
||||||
import type { SpotlightMode } from "../open";
|
|
||||||
|
|
||||||
export type SearchMode = {
|
export type SearchMode = {
|
||||||
mode: SpotlightMode;
|
modeKey: keyof TranslationObject["search"]["mode"];
|
||||||
character: string | undefined;
|
character: string | undefined;
|
||||||
label: stringOrTranslation;
|
|
||||||
placeholder: stringOrTranslation;
|
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
groups: SearchGroup[];
|
groups: SearchGroup[];
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
"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,13 +1,12 @@
|
|||||||
import { Avatar, Group, Stack, Text } from "@mantine/core";
|
import { Avatar, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconExternalLink, IconEye } from "@tabler/icons-react";
|
import { IconExternalLink, IconEye } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { createChildrenOptions } from "../../lib/children";
|
import { createChildrenOptions } from "../../lib/children";
|
||||||
import { filterCatalog, useAppsCatalogQuery } from "../../lib/catalog";
|
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import { interaction } from "../../lib/interaction";
|
import { interaction } from "../../lib/interaction";
|
||||||
import { useSpotlightContextResults } from "../home/context";
|
|
||||||
|
|
||||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
@@ -59,7 +58,6 @@ const appChildrenOptions = createChildrenOptions<App>({
|
|||||||
<Avatar
|
<Avatar
|
||||||
size="sm"
|
size="sm"
|
||||||
src={options.iconUrl}
|
src={options.iconUrl}
|
||||||
alt=""
|
|
||||||
radius={0}
|
radius={0}
|
||||||
styles={{
|
styles={{
|
||||||
image: {
|
image: {
|
||||||
@@ -77,13 +75,11 @@ const appChildrenOptions = createChildrenOptions<App>({
|
|||||||
export const appsSearchGroup = createGroup<App>({
|
export const appsSearchGroup = createGroup<App>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: (t) => t("common.entity.apps"),
|
title: (t) => t("common.entity.apps"),
|
||||||
source: { kind: "remote", source: "apps" },
|
|
||||||
Component: (app) => (
|
Component: (app) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<Avatar
|
<Avatar
|
||||||
size="sm"
|
size="sm"
|
||||||
src={app.iconUrl}
|
src={app.iconUrl}
|
||||||
alt=""
|
|
||||||
radius={0}
|
radius={0}
|
||||||
styles={{
|
styles={{
|
||||||
image: {
|
image: {
|
||||||
@@ -96,16 +92,6 @@ export const appsSearchGroup = createGroup<App>({
|
|||||||
),
|
),
|
||||||
useInteraction: interaction.children(appChildrenOptions),
|
useInteraction: interaction.children(appChildrenOptions),
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const catalogQuery = useAppsCatalogQuery();
|
return clientApi.app.search.useQuery({ query, limit: 5 });
|
||||||
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,14 +1,13 @@
|
|||||||
import { Group, Stack, Text } from "@mantine/core";
|
import { Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
|
import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { ChildrenAction } from "../../lib/children";
|
import type { ChildrenAction } from "../../lib/children";
|
||||||
import { filterCatalog, useBoardsCatalogQuery } from "../../lib/catalog";
|
|
||||||
import { createChildrenOptions } from "../../lib/children";
|
import { createChildrenOptions } from "../../lib/children";
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import { interaction } from "../../lib/interaction";
|
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>.
|
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
@@ -127,7 +126,6 @@ const boardChildrenOptions = createChildrenOptions<Board>({
|
|||||||
export const boardsSearchGroup = createGroup<Board>({
|
export const boardsSearchGroup = createGroup<Board>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: (t) => t("common.entity.boards"),
|
title: (t) => t("common.entity.boards"),
|
||||||
source: { kind: "remote", source: "boards" },
|
|
||||||
Component: (board) => (
|
Component: (board) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
{board.logoImageUrl ? (
|
{board.logoImageUrl ? (
|
||||||
@@ -141,11 +139,6 @@ export const boardsSearchGroup = createGroup<Board>({
|
|||||||
),
|
),
|
||||||
useInteraction: interaction.children(boardChildrenOptions),
|
useInteraction: interaction.children(boardChildrenOptions),
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const catalogQuery = useBoardsCatalogQuery();
|
return clientApi.board.search.useQuery({ query, limit: 5 });
|
||||||
return {
|
|
||||||
data: filterCatalog(catalogQuery.data ?? [], query, (board) => [board.name]),
|
|
||||||
isLoading: catalogQuery.isLoading,
|
|
||||||
isError: catalogQuery.isError,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2,22 +2,19 @@ import { useSession } from "@homarr/auth/client";
|
|||||||
|
|
||||||
import type { SearchGroup } from "../../lib/group";
|
import type { SearchGroup } from "../../lib/group";
|
||||||
import type { SearchMode } from "../../lib/mode";
|
import type { SearchMode } from "../../lib/mode";
|
||||||
import { contextSpecificAppsSearchGroup } from "../home/context-specific-group";
|
|
||||||
import { appsSearchGroup } from "./apps-search-group";
|
import { appsSearchGroup } from "./apps-search-group";
|
||||||
import { boardsSearchGroup } from "./boards-search-group";
|
import { boardsSearchGroup } from "./boards-search-group";
|
||||||
import { integrationsSearchGroup } from "./integrations-search-group";
|
import { integrationsSearchGroup } from "./integrations-search-group";
|
||||||
|
|
||||||
export const appIntegrationBoardMode = {
|
export const appIntegrationBoardMode = {
|
||||||
mode: "apps",
|
modeKey: "appIntegrationBoard",
|
||||||
character: "#",
|
character: "#",
|
||||||
label: (t) => t("search.modePicker.apps.label"),
|
|
||||||
placeholder: (t) => t("search.modePicker.apps.placeholder"),
|
|
||||||
useGroups() {
|
useGroups() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const groups: SearchGroup[] = [contextSpecificAppsSearchGroup];
|
const groups: SearchGroup[] = [boardsSearchGroup];
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return groups.concat(boardsSearchGroup);
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors the gate on app.search: either permission may browse the app catalogue, so an app
|
// Mirrors the gate on app.search: either permission may browse the app catalogue, so an app
|
||||||
@@ -25,10 +22,6 @@ export const appIntegrationBoardMode = {
|
|||||||
const canSearchApps =
|
const canSearchApps =
|
||||||
session.user.permissions.includes("board-modify-all") || session.user.permissions.includes("app-modify-all");
|
session.user.permissions.includes("board-modify-all") || session.user.permissions.includes("app-modify-all");
|
||||||
|
|
||||||
if (canSearchApps) {
|
return groups.concat(canSearchApps ? [appsSearchGroup, integrationsSearchGroup] : [integrationsSearchGroup]);
|
||||||
return groups.concat(appsSearchGroup, boardsSearchGroup, integrationsSearchGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups.concat(boardsSearchGroup, integrationsSearchGroup);
|
|
||||||
},
|
},
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import { Group, Text } from "@mantine/core";
|
import { Group, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { filterCatalog, useIntegrationsCatalogQuery } from "../../lib/catalog";
|
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import { interaction } from "../../lib/interaction";
|
import { interaction } from "../../lib/interaction";
|
||||||
|
|
||||||
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
|
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: (t) => t("common.entity.integrations"),
|
title: (t) => t("common.entity.integrations"),
|
||||||
source: { kind: "remote", source: "integrations" },
|
|
||||||
Component: (integration) => (
|
Component: (integration) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<IntegrationAvatar size="sm" kind={integration.kind} />
|
<IntegrationAvatar size="sm" kind={integration.kind} />
|
||||||
@@ -20,11 +19,6 @@ export const integrationsSearchGroup = createGroup<{ id: string; kind: Integrati
|
|||||||
),
|
),
|
||||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })),
|
useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })),
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const catalogQuery = useIntegrationsCatalogQuery();
|
return clientApi.integration.search.useQuery({ query, limit: 5 });
|
||||||
return {
|
|
||||||
data: filterCatalog(catalogQuery.data ?? [], query, (integration) => [integration.name, integration.kind]),
|
|
||||||
isLoading: catalogQuery.isLoading,
|
|
||||||
isError: catalogQuery.isError,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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 { Group, Stack, Switch, Text } from "@mantine/core";
|
||||||
import type { DayOfWeek } from "@mantine/dates";
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import { IconChevronRight } from "@tabler/icons-react";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import localeData from "dayjs/plugin/localeData";
|
import localeData from "dayjs/plugin/localeData";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
import { visiblePreferenceDefinitions } from "@homarr/settings";
|
import { visiblePreferenceDefinitions } from "@homarr/settings";
|
||||||
@@ -47,19 +47,15 @@ const BooleanRow = ({
|
|||||||
</Group>
|
</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 mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||||
<Group wrap="nowrap" gap="sm">
|
<Group wrap="nowrap" gap="sm">
|
||||||
<Icon stroke={1.5} size={20} />
|
<Icon stroke={1.5} size={20} />
|
||||||
<Text>{label}</Text>
|
<Text>{label}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{valueLabel ? (
|
<Text size="sm" c="dimmed">
|
||||||
<Text size="sm" c="dimmed">
|
{valueLabel}
|
||||||
{valueLabel}
|
</Text>
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<IconChevronRight aria-hidden size={16} />
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,7 +68,7 @@ const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
|
|||||||
|
|
||||||
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
|
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
|
||||||
|
|
||||||
export const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
|
const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
|
||||||
const t = useI18n("search.mode.command.group.preferences.option");
|
const t = useI18n("search.mode.command.group.preferences.option");
|
||||||
const tUserField = useI18n("user.field");
|
const tUserField = useI18n("user.field");
|
||||||
const tColorScheme = useI18n("common.colorScheme.options");
|
const tColorScheme = useI18n("common.colorScheme.options");
|
||||||
@@ -81,13 +77,25 @@ export const useSettingsActions = (_: Record<string, unknown>, query: string): S
|
|||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const preferences = useUserPreferences();
|
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> = {
|
const valueLabelResolvers: Record<string, () => string> = {
|
||||||
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
|
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
|
||||||
locale: () => {
|
locale: () => {
|
||||||
const val = preferences.getPreference("locale").value;
|
const val = preferences.getPreference("locale").value;
|
||||||
return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val);
|
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] ?? "",
|
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)
|
return visiblePreferenceDefinitions(isAuthenticated)
|
||||||
@@ -126,7 +134,9 @@ export const useSettingsActions = (_: Record<string, unknown>, query: string): S
|
|||||||
if (!children) return null;
|
if (!children) return null;
|
||||||
return {
|
return {
|
||||||
key: definition.key,
|
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),
|
useInteraction: interaction.children(children),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useSpotlightContextActions } from "../home/context";
|
|||||||
export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificItem>({
|
export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificItem>({
|
||||||
title: (t) => t("search.mode.command.group.localCommand.title"),
|
title: (t) => t("search.mode.command.group.localCommand.title"),
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
source: { kind: "local" },
|
|
||||||
Component(option) {
|
Component(option) {
|
||||||
const icon =
|
const icon =
|
||||||
typeof option.icon !== "string" ? (
|
typeof option.icon !== "string" ? (
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> =
|
|||||||
|
|
||||||
export const globalCommandGroup = createGroup<Command>({
|
export const globalCommandGroup = createGroup<Command>({
|
||||||
keyPath: "commandKey",
|
keyPath: "commandKey",
|
||||||
title: (t) => t("search.mode.command.group.globalCommand.title"),
|
title: "Global commands",
|
||||||
source: { kind: "local" },
|
|
||||||
useInteraction: (option, query) => option.useInteraction(option, query),
|
useInteraction: (option, query) => option.useInteraction(option, query),
|
||||||
Component: ({ icon: Icon, name }) => (
|
Component: ({ icon: Icon, name }) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import { globalCommandGroup } from "./global-group";
|
|||||||
import { preferencesGroup } from "../preferences/groups";
|
import { preferencesGroup } from "../preferences/groups";
|
||||||
|
|
||||||
export const commandMode = {
|
export const commandMode = {
|
||||||
mode: "command",
|
modeKey: "command",
|
||||||
character: ">",
|
character: ">",
|
||||||
label: (t) => t("search.modePicker.command.label"),
|
groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup],
|
||||||
placeholder: (t) => t("search.modePicker.command.placeholder"),
|
|
||||||
groups: [contextSpecificActionsSearchGroups, globalCommandGroup, preferencesGroup],
|
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
+1
-3
@@ -2,9 +2,7 @@ import type { SearchMode } from "../../lib/mode";
|
|||||||
import { searchEnginesSearchGroups } from "./search-engines-search-group";
|
import { searchEnginesSearchGroups } from "./search-engines-search-group";
|
||||||
|
|
||||||
export const externalMode = {
|
export const externalMode = {
|
||||||
mode: "external",
|
modeKey: "external",
|
||||||
character: "!",
|
character: "!",
|
||||||
label: (t) => t("search.modePicker.external.label"),
|
|
||||||
placeholder: (t) => t("search.modePicker.external.placeholder"),
|
|
||||||
groups: [searchEnginesSearchGroups],
|
groups: [searchEnginesSearchGroups],
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
+165
-188
@@ -1,5 +1,7 @@
|
|||||||
import { Group, Image, Kbd, Stack, Text } from "@mantine/core";
|
import { Group, Image, Kbd, Stack, Text } from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { IconDownload, IconSearch } from "@tabler/icons-react";
|
import { IconDownload, IconSearch } from "@tabler/icons-react";
|
||||||
|
import { keepPreviousData } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -11,13 +13,11 @@ import { useSettings } from "@homarr/settings";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { createChildrenOptions } from "../../lib/children";
|
import { createChildrenOptions } from "../../lib/children";
|
||||||
import { filterCatalog, useSearchEnginesCatalogQuery } from "../../lib/catalog";
|
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import type { inferSearchInteractionDefinition } from "../../lib/interaction";
|
import type { inferSearchInteractionDefinition } from "../../lib/interaction";
|
||||||
import { interaction } from "../../lib/interaction";
|
import { interaction } from "../../lib/interaction";
|
||||||
import { useRemoteQuery } from "../../lib/remote-query";
|
|
||||||
|
|
||||||
type SearchEngine = RouterOutputs["searchEngine"]["catalog"][number];
|
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
|
||||||
type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
|
type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
|
||||||
type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number];
|
type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number];
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestCh
|
|||||||
return (
|
return (
|
||||||
<Group mx="md" my="sm" wrap="nowrap">
|
<Group mx="md" my="sm" wrap="nowrap">
|
||||||
{options.result.image ? (
|
{options.result.image ? (
|
||||||
<Image src={options.result.image} alt="" w={35} h={50} fit="cover" radius={"md"} />
|
<Image src={options.result.image} w={35} h={50} fit="cover" radius={"md"} />
|
||||||
) : (
|
) : (
|
||||||
<IconSearch stroke={1.5} size={35} />
|
<IconSearch stroke={1.5} size={35} />
|
||||||
)}
|
)}
|
||||||
@@ -206,12 +206,11 @@ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestCh
|
|||||||
|
|
||||||
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
|
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
|
||||||
useActions: (searchEngine, query) => {
|
useActions: (searchEngine, query) => {
|
||||||
const remoteQuery = useRemoteQuery(query, "integration-search");
|
|
||||||
const { data } = clientApi.integration.searchInIntegration.useQuery(
|
const { data } = clientApi.integration.searchInIntegration.useQuery(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
{ integrationId: searchEngine.integrationId!, query: remoteQuery.query },
|
{ integrationId: searchEngine.integrationId!, query },
|
||||||
{
|
{
|
||||||
enabled: remoteQuery.enabled && searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null,
|
enabled: searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && query.length > 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { openSearchInNewTab } = useSettings();
|
const { openSearchInNewTab } = useSettings();
|
||||||
@@ -245,7 +244,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
|
|||||||
return (
|
return (
|
||||||
<Group mx="md" my="sm" wrap="nowrap">
|
<Group mx="md" my="sm" wrap="nowrap">
|
||||||
{searchResult.image ? (
|
{searchResult.image ? (
|
||||||
<Image src={searchResult.image} alt="" w={35} h={50} fit="cover" radius={"md"} />
|
<Image src={searchResult.image} w={35} h={50} fit="cover" radius={"md"} />
|
||||||
) : (
|
) : (
|
||||||
<IconSearch stroke={1.5} size={35} />
|
<IconSearch stroke={1.5} size={35} />
|
||||||
)}
|
)}
|
||||||
@@ -271,7 +270,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
|
|||||||
<Stack mx="md" my="sm">
|
<Stack mx="md" my="sm">
|
||||||
<Text>{options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")}</Text>
|
<Text>{options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")}</Text>
|
||||||
<Group>
|
<Group>
|
||||||
<img height={24} width={24} src={options.iconUrl} alt="" />
|
<img height={24} width={24} src={options.iconUrl} alt={options.name} />
|
||||||
<Text>{options.name}</Text>
|
<Text>{options.name}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -288,7 +287,7 @@ const parseBangQuery = (query: string) => {
|
|||||||
return { bangToken, searchText, locked };
|
return { bangToken, searchText, locked };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildSearchUrl = (template: string, query: string) => {
|
const buildSearchUrl = (template: string, query: string) => {
|
||||||
const encoded = encodeURIComponent(query);
|
const encoded = encodeURIComponent(query);
|
||||||
if (template.includes("{{{s}}}")) {
|
if (template.includes("{{{s}}}")) {
|
||||||
return template.replaceAll("{{{s}}}", encoded);
|
return template.replaceAll("{{{s}}}", encoded);
|
||||||
@@ -297,23 +296,36 @@ export const buildSearchUrl = (template: string, query: string) => {
|
|||||||
return template.replaceAll("%s", encoded);
|
return template.replaceAll("%s", encoded);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SearchEngineGroupOptions {
|
export const searchEnginesSearchGroups = createGroup<ExternalOption>({
|
||||||
minimumLength: number;
|
keyPath: "key",
|
||||||
showEmptyHint: boolean;
|
title: (t) => t("common.entity.searchEngines"),
|
||||||
source: "focused" | "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">
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }: SearchEngineGroupOptions) =>
|
if (option.kind === "search") {
|
||||||
createGroup<ExternalOption>({
|
return (
|
||||||
keyPath: "key",
|
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
|
||||||
title: (t) => t("common.entity.searchEngines"),
|
<Group wrap="nowrap">
|
||||||
source: source === "focused" ? { kind: "remote", source: "search-engines" } : { kind: "fallback" },
|
{option.iconUrl ? (
|
||||||
Component: (option) => {
|
<img height={24} width={24} src={option.iconUrl} alt={option.label} />
|
||||||
if (option.kind === "hint") {
|
) : (
|
||||||
return (
|
<IconSearch stroke={1.5} />
|
||||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
)}
|
||||||
<IconSearch stroke={1.5} />
|
<Stack gap={0} justify="center">
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="sm">{option.label}</Text>
|
<Text size="sm">{option.label}</Text>
|
||||||
{option.description ? (
|
{option.description ? (
|
||||||
<Text size="xs" c="gray.6">
|
<Text size="xs" c="gray.6">
|
||||||
@@ -322,61 +334,22 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
|
|||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.kind === "search") {
|
<Kbd size="sm">!{option.bang}</Kbd>
|
||||||
return (
|
</Group>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Kbd size="sm">!{option.bang}</Kbd>
|
if (option.kind === "engine") {
|
||||||
</Group>
|
const { iconUrl, name, short, description } = option.engine;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
|
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<IconSearch stroke={1.5} />
|
<img height={24} width={24} src={iconUrl} alt={name} />
|
||||||
<Stack gap={0} justify="center">
|
<Stack gap={0} justify="center">
|
||||||
<Text size="sm">{name}</Text>
|
<Text size="sm">{name}</Text>
|
||||||
<Text size="xs" c="gray.6">
|
<Text size="xs" c="gray.6">
|
||||||
{domain ?? "DuckDuckGo bang"}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -384,134 +357,138 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
|
|||||||
<Kbd size="sm">!{short}</Kbd>
|
<Kbd size="sm">!{short}</Kbd>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
useInteraction(option, query) {
|
|
||||||
const { openSearchInNewTab } = useSettings();
|
|
||||||
const { bangToken, searchText } = parseBangQuery(query);
|
|
||||||
|
|
||||||
if (option.kind === "search") {
|
const { s: name, t: short, d: domain } = option.bang;
|
||||||
return {
|
return (
|
||||||
type: "link",
|
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
|
||||||
href: buildSearchUrl(option.urlTemplate, option.searchText),
|
<Group wrap="nowrap">
|
||||||
newTab: openSearchInNewTab,
|
<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 === "engine") {
|
<Kbd size="sm">!{short}</Kbd>
|
||||||
const nextBang = option.engine.short;
|
</Group>
|
||||||
const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `;
|
);
|
||||||
return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery };
|
},
|
||||||
}
|
useInteraction(option, query) {
|
||||||
|
const { openSearchInNewTab } = useSettings();
|
||||||
|
const { bangToken, searchText } = parseBangQuery(query);
|
||||||
|
|
||||||
if (option.kind === "ddg") {
|
if (option.kind === "search") {
|
||||||
const nextBang = option.bang.t;
|
return {
|
||||||
const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `;
|
type: "link",
|
||||||
return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery };
|
href: buildSearchUrl(option.urlTemplate, option.searchText),
|
||||||
}
|
newTab: openSearchInNewTab,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { type: "none" };
|
if (option.kind === "engine") {
|
||||||
},
|
const nextBang = option.engine.short;
|
||||||
useQueryOptions(query) {
|
const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `;
|
||||||
const tExternal = useI18n("search.mode.external.group.searchEngine");
|
return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery };
|
||||||
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();
|
|
||||||
|
|
||||||
const ddgQuery = clientApi.bangs.search.useQuery(
|
if (option.kind === "ddg") {
|
||||||
{ query: remoteQuery.query, limit: 10 },
|
const nextBang = option.bang.t;
|
||||||
{
|
const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `;
|
||||||
enabled: ddgQueryEnabled,
|
return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery };
|
||||||
placeholderData: (previousData) => previousData,
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const engineCatalog = enginesQuery.data ?? [];
|
return { type: "none" };
|
||||||
const engineData = filterCatalog(engineCatalog, bangToken, (engine) => [engine.short, engine.name], 10);
|
},
|
||||||
const ddgData = ddgQueryEnabled && bangToken.length > 0
|
useQueryOptions(query) {
|
||||||
? (ddgQuery.data ?? []).filter((bang) => bang.t.toLowerCase().startsWith(bangToken.toLowerCase()))
|
const tExternal = useI18n("search.mode.external.group.searchEngine");
|
||||||
: [];
|
const { ddgBangs } = useSettings();
|
||||||
const isWaitingForDdgResults =
|
const { bangToken, searchText, locked } = parseBangQuery(query);
|
||||||
ddgBangs &&
|
const [debouncedBangToken] = useDebouncedValue(bangToken, 150);
|
||||||
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 engineOptions = engineData.map(
|
const enginesQuery = clientApi.searchEngine.search.useQuery(
|
||||||
(engine): ExternalOption => ({
|
{ query: debouncedBangToken, limit: 10 },
|
||||||
key: `engine-${engine.short}`,
|
{ placeholderData: keepPreviousData },
|
||||||
kind: "engine",
|
);
|
||||||
engine,
|
|
||||||
|
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 ddgOptions = ddgData
|
const searchActions: ExternalOption[] = [];
|
||||||
.filter((bang) => !engineOptions.some((option) => option.kind === "engine" && option.engine.short === bang.t))
|
if (locked && bangToken.length > 0) {
|
||||||
.map(
|
const matchedEngine = (enginesQuery.data ?? []).find((engine) => engine.short === bangToken);
|
||||||
(bang): ExternalOption => ({
|
const matchedDdg = (ddgQuery.data ?? []).find((bang) => bang.t === bangToken);
|
||||||
key: `ddg-${bang.t}`,
|
|
||||||
kind: "ddg",
|
|
||||||
bang,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const searchActions: ExternalOption[] = [];
|
const label = matchedEngine?.name ?? matchedDdg?.s;
|
||||||
if (locked && bangToken.length > 0) {
|
const iconUrl = matchedEngine?.iconUrl;
|
||||||
const matchedEngine = engineCatalog.find((engine) => engine.short === bangToken);
|
const urlTemplate = matchedEngine?.type === "generic" ? matchedEngine.urlTemplate : matchedDdg?.u;
|
||||||
const matchedDdg = ddgData.find((bang) => bang.t === bangToken);
|
|
||||||
|
|
||||||
const genericEngine = matchedEngine?.type === "generic" ? matchedEngine : undefined;
|
if (label && urlTemplate) {
|
||||||
const label = genericEngine?.name ?? matchedDdg?.s;
|
if (searchText.trim().length > 0) {
|
||||||
const iconUrl = genericEngine?.iconUrl;
|
searchActions.push({
|
||||||
const urlTemplate = genericEngine?.urlTemplate ?? matchedDdg?.u;
|
key: "search-action",
|
||||||
|
kind: "search",
|
||||||
if (label && urlTemplate) {
|
label: tExternal("bang.searchWithQuery", {
|
||||||
if (searchText.trim().length > 0) {
|
query: searchText.trim(),
|
||||||
searchActions.push({
|
label,
|
||||||
key: "search-action",
|
}),
|
||||||
kind: "search",
|
description: tExternal("bang.pressEnterToOpen"),
|
||||||
label: tExternal("bang.searchWithQuery", {
|
bang: bangToken,
|
||||||
query: searchText.trim(),
|
iconUrl,
|
||||||
label,
|
urlTemplate,
|
||||||
}),
|
searchText: searchText.trim(),
|
||||||
description: tExternal("bang.pressEnterToOpen"),
|
});
|
||||||
bang: bangToken,
|
} else {
|
||||||
iconUrl,
|
searchActions.push({
|
||||||
urlTemplate,
|
key: "search-hint",
|
||||||
searchText: searchText.trim(),
|
kind: "hint",
|
||||||
});
|
label: tExternal("bang.engineSelected", { label, bang: bangToken }),
|
||||||
} else {
|
description: tExternal("bang.typeQueryToContinue"),
|
||||||
searchActions.push({
|
});
|
||||||
key: "search-hint",
|
|
||||||
kind: "hint",
|
|
||||||
label: tExternal("bang.engineSelected", { label, bang: bangToken }),
|
|
||||||
description: tExternal("bang.typeQueryToContinue"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showEmptyHint && bangToken.length === 0) {
|
}
|
||||||
searchActions.push({
|
if (query.length === 0) {
|
||||||
key: "hint",
|
searchActions.push({
|
||||||
kind: "hint",
|
key: "hint",
|
||||||
label: tExternal("bang.emptyBangHint"),
|
kind: "hint",
|
||||||
description: ddgBangs ? tExternal("tipDdgBangs") : undefined,
|
label: tExternal("bang.emptyBangHint"),
|
||||||
});
|
description: ddgBangs ? tExternal("tipDdgBangs") : undefined,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
data: [...searchActions, ...engineOptions, ...ddgOptions].slice(0, 10),
|
data: [...searchActions, ...engineOptions, ...ddgOptions].slice(0, 10),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
|
||||||
|
|
||||||
export const searchEnginesSearchGroups = createSearchEnginesSearchGroup({
|
|
||||||
minimumLength: 0,
|
|
||||||
showEmptyHint: true,
|
|
||||||
source: "focused",
|
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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,89 +7,39 @@ import { useSpotlightContextResults } from "./context";
|
|||||||
export const shouldShowContextSpecificResult = (query: string, option: ContextSpecificItem) => {
|
export const shouldShowContextSpecificResult = (query: string, option: ContextSpecificItem) => {
|
||||||
if (option.alwaysVisible) return true;
|
if (option.alwaysVisible) return true;
|
||||||
if (query.length === 0) return false;
|
if (query.length === 0) return false;
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
return option.name.toLowerCase().includes(query.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>({
|
export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
|
||||||
title: (t) => t("search.mode.home.group.local.title"),
|
title: (t) => t("search.mode.home.group.local.title"),
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
source: { kind: "local" },
|
Component(option) {
|
||||||
Component: renderContextSpecificItem,
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
useInteraction(option, query) {
|
useInteraction(option, query) {
|
||||||
return option.interaction(query);
|
return option.interaction(query);
|
||||||
},
|
},
|
||||||
filter: shouldShowContextSpecificResult,
|
filter: shouldShowContextSpecificResult,
|
||||||
sort: localResultSort,
|
|
||||||
useOptions() {
|
useOptions() {
|
||||||
return useSpotlightContextResults().filter((item) => item.placement !== "fallback");
|
return useSpotlightContextResults();
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -11,13 +11,10 @@ export type ContextSpecificItem = {
|
|||||||
name: string;
|
name: string;
|
||||||
icon: TablerIcon | string;
|
icon: TablerIcon | string;
|
||||||
interaction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
interaction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||||
aliases?: string[];
|
|
||||||
placement?: "primary" | "fallback";
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
unavailable?: boolean;
|
unavailable?: boolean;
|
||||||
alwaysVisible?: boolean;
|
alwaysVisible?: boolean;
|
||||||
dedupeKey?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SpotlightContextProps {
|
interface SpotlightContextProps {
|
||||||
@@ -63,22 +60,7 @@ const createSpotlightContext = (displayName: string) => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]);
|
||||||
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 itemsContext = useMemo(() => ({ items }), [items]);
|
||||||
const registration = useMemo(() => ({ registerItems, unregisterItems }), [registerItems, unregisterItems]);
|
const registration = useMemo(() => ({ registerItems, unregisterItems }), [registerItems, unregisterItems]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconSearchOff } from "@tabler/icons-react";
|
import { IconSearch, IconSearchOff } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
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 { useSession } from "@homarr/auth/client";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
import { useSearchEnginesCatalogQuery } from "../../lib/catalog";
|
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
|
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
|
||||||
import { buildSearchUrl } from "../external/search-engines-search-group";
|
import { useFromIntegrationSearchInteraction } 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
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
type GroupItem = {
|
type GroupItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,20 +21,19 @@ type GroupItem = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
icon: TablerIcon | string;
|
icon: TablerIcon | string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
interaction: inferSearchInteractionDefinition<SearchInteraction>;
|
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const homeSearchEngineGroup = createGroup<GroupItem>({
|
export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||||
title: (t) => t("search.mode.home.group.search.title"),
|
title: (t) => t("search.mode.home.group.search.title"),
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
source: { kind: "fallback" },
|
|
||||||
Component(item) {
|
Component(item) {
|
||||||
const icon =
|
const icon =
|
||||||
typeof item.icon !== "string" ? (
|
typeof item.icon !== "string" ? (
|
||||||
<item.icon size={24} />
|
<item.icon size={24} />
|
||||||
) : (
|
) : (
|
||||||
<Box w={24} h={24}>
|
<Box w={24} h={24}>
|
||||||
<img src={item.icon} alt="" style={{ maxWidth: 24 }} />
|
<img src={item.icon} alt={item.name} style={{ maxWidth: 24 }} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -45,95 +42,121 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
|
|||||||
{icon}
|
{icon}
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text>{item.name}</Text>
|
<Text>{item.name}</Text>
|
||||||
{item.description ? (
|
{item.description && (
|
||||||
<Text c="gray.6" size="sm">
|
<Text c="gray.6" size="sm">
|
||||||
{item.description}
|
{item.description}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
useInteraction(item) {
|
useInteraction(item, query) {
|
||||||
return item.interaction;
|
return item.useInteraction(query);
|
||||||
|
},
|
||||||
|
filter() {
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { data: session } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const { openSearchInNewTab } = useSettings();
|
const { data: defaultSearchEngine, ...defaultSearchEngineQuery } =
|
||||||
const catalogQuery = useSearchEnginesCatalogQuery();
|
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),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading: catalogQuery.isLoading,
|
isLoading:
|
||||||
isError: catalogQuery.isError,
|
defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading",
|
||||||
data: createSearchEntries(
|
isError: defaultSearchEngineQuery.isError || (resultQuery.isError && fromIntegrationEnabled),
|
||||||
catalogQuery.data ?? [],
|
data: createDefaultSearchEntries(defaultSearchEngine, results, session, query, t),
|
||||||
session?.user.permissions.includes("search-engine-create") === true,
|
|
||||||
query,
|
|
||||||
openSearchInNewTab,
|
|
||||||
t,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createSearchEntries = (
|
const createDefaultSearchEntries = (
|
||||||
searchEngines: SearchEngine[],
|
defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null,
|
||||||
canCreateSearchEngine: boolean,
|
results: RouterOutputs["integration"]["searchInIntegration"] | undefined,
|
||||||
|
session: Session | null,
|
||||||
query: string,
|
query: string,
|
||||||
openInNewTab: boolean,
|
|
||||||
t: TranslationFunction,
|
t: TranslationFunction,
|
||||||
): GroupItem[] => {
|
): GroupItem[] => {
|
||||||
if (searchEngines.length === 0) {
|
if (!session?.user && !defaultSearchEngine) return [];
|
||||||
|
|
||||||
|
if (!defaultSearchEngine) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "no-search-engines",
|
id: "no-default",
|
||||||
name: t("search.mode.home.group.search.option.no-engines.label"),
|
name: t("search.mode.home.group.search.option.no-default.label"),
|
||||||
description: canCreateSearchEngine
|
description: t("search.mode.home.group.search.option.no-default.description"),
|
||||||
? t("search.mode.home.group.search.option.no-engines.descriptionCreate")
|
|
||||||
: t("search.mode.home.group.search.option.no-engines.descriptionUnavailable"),
|
|
||||||
icon: IconSearchOff,
|
icon: IconSearchOff,
|
||||||
disabled: !canCreateSearchEngine,
|
useInteraction() {
|
||||||
interaction: canCreateSearchEngine ? { type: "link", href: "/manage/search-engines/new" } : { type: "none" },
|
return {
|
||||||
|
type: "link",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
href: `/manage/users/${session!.user.id}/general`,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedQuery = query.trim();
|
if (defaultSearchEngine.type === "generic") {
|
||||||
return searchEngines.map((searchEngine) => {
|
return [
|
||||||
const name =
|
{
|
||||||
normalizedQuery.length === 0
|
id: "search",
|
||||||
? searchEngine.name
|
name: t("search.mode.home.group.search.option.search.label", {
|
||||||
: t("search.mode.home.group.search.option.search.label", {
|
query,
|
||||||
query: normalizedQuery,
|
name: defaultSearchEngine.name,
|
||||||
name: searchEngine.name,
|
}),
|
||||||
});
|
icon: defaultSearchEngine.iconUrl,
|
||||||
|
useInteraction(query) {
|
||||||
if (searchEngine.type === "generic") {
|
const { openSearchInNewTab } = useSettings();
|
||||||
return {
|
return {
|
||||||
id: `search-${searchEngine.id}`,
|
type: "link",
|
||||||
name,
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
description: searchEngine.description ?? undefined,
|
href: defaultSearchEngine.urlTemplate!.replace("%s", query),
|
||||||
icon: searchEngine.iconUrl,
|
newTab: openSearchInNewTab,
|
||||||
interaction: {
|
};
|
||||||
type: "link",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
href: buildSearchUrl(searchEngine.urlTemplate!, normalizedQuery),
|
|
||||||
newTab: openInNewTab,
|
|
||||||
},
|
},
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `search-${searchEngine.id}`,
|
|
||||||
name,
|
|
||||||
description: searchEngine.description ?? undefined,
|
|
||||||
icon: searchEngine.iconUrl,
|
|
||||||
interaction: {
|
|
||||||
type: "mode",
|
|
||||||
mode: "external",
|
|
||||||
query: `${searchEngine.short} ${normalizedQuery}`.trimEnd(),
|
|
||||||
},
|
},
|
||||||
};
|
];
|
||||||
});
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.map((result) => ({
|
||||||
|
id: `search-${result.id}`,
|
||||||
|
name: result.name,
|
||||||
|
description: result.text,
|
||||||
|
icon: result.image ?? IconSearch,
|
||||||
|
useInteraction() {
|
||||||
|
return useFromIntegrationSearchInteraction(defaultSearchEngine, result);
|
||||||
|
},
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
@@ -1,38 +1,10 @@
|
|||||||
import type { SearchMode } from "../../lib/mode";
|
import type { SearchMode } from "../../lib/mode";
|
||||||
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 { preferencesGroup } from "../preferences/groups";
|
||||||
import { userGroupMode } from "../user-group";
|
import { contextSpecificSearchGroups } from "./context-specific-group";
|
||||||
|
import { homeSearchEngineGroup } from "./home-search-engine-group";
|
||||||
|
|
||||||
export const homeMode = {
|
export const homeMode = {
|
||||||
mode: "search",
|
character: undefined,
|
||||||
character: "/",
|
modeKey: "home",
|
||||||
label: (t) => t("search.modePicker.search.label"),
|
groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups],
|
||||||
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;
|
} satisfies SearchMode;
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
import { appIntegrationBoardMode } from "./app-integration-board";
|
import { appIntegrationBoardMode } from "./app-integration-board";
|
||||||
import { assistantMode } from "./assistant";
|
|
||||||
import { commandMode } from "./command";
|
import { commandMode } from "./command";
|
||||||
import { externalMode } from "./external";
|
import { externalMode } from "./external";
|
||||||
import { homeMode } from "./home";
|
import { homeMode } from "./home";
|
||||||
import { mediaMode } from "./media";
|
import { mediaMode } from "./media";
|
||||||
import { preferencesMode } from "./preferences";
|
import { pageMode } from "./page";
|
||||||
import { userGroupMode } from "./user-group";
|
import { userGroupMode } from "./user-group";
|
||||||
|
|
||||||
export const searchModes = [
|
export const searchModes = [
|
||||||
homeMode,
|
|
||||||
appIntegrationBoardMode,
|
|
||||||
commandMode,
|
|
||||||
preferencesMode,
|
|
||||||
assistantMode,
|
|
||||||
externalMode,
|
|
||||||
mediaMode,
|
|
||||||
userGroupMode,
|
userGroupMode,
|
||||||
|
appIntegrationBoardMode,
|
||||||
|
externalMode,
|
||||||
|
commandMode,
|
||||||
|
pageMode,
|
||||||
|
mediaMode,
|
||||||
|
homeMode,
|
||||||
] as const;
|
] as const;
|
||||||
@@ -2,9 +2,7 @@ import type { SearchMode } from "../../lib/mode";
|
|||||||
import { mediaRequestSearchGroup } from "./media-request-search-group";
|
import { mediaRequestSearchGroup } from "./media-request-search-group";
|
||||||
|
|
||||||
export const mediaMode = {
|
export const mediaMode = {
|
||||||
mode: "media",
|
modeKey: "media",
|
||||||
character: undefined,
|
character: undefined,
|
||||||
label: (t) => t("search.modePicker.media.label"),
|
|
||||||
placeholder: (t) => t("search.modePicker.media.placeholder"),
|
|
||||||
groups: [mediaRequestSearchGroup],
|
groups: [mediaRequestSearchGroup],
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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 { Badge, Group, Image, Stack, Text } from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { IconMovie, IconSearch } from "@tabler/icons-react";
|
import { IconMovie, IconSearch } from "@tabler/icons-react";
|
||||||
|
import { keepPreviousData } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import type { MediaAvailability } from "@homarr/integrations/types";
|
import type { MediaAvailability } from "@homarr/integrations/types";
|
||||||
import { mediaAvailabilityConfiguration } from "@homarr/integrations/types";
|
import { mediaAvailabilityConfiguration } from "@homarr/integrations/types";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import { useRemoteQuery } from "../../lib/remote-query";
|
|
||||||
import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
|
import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
|
||||||
import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group";
|
import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group";
|
||||||
|
|
||||||
@@ -23,14 +23,7 @@ type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequest
|
|||||||
type MediaRequestSearchOption =
|
type MediaRequestSearchOption =
|
||||||
| {
|
| {
|
||||||
key: string;
|
key: string;
|
||||||
kind: "disabled";
|
kind: "disabled" | "hint";
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
unavailable: true;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
key: string;
|
|
||||||
kind: "hint";
|
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
@@ -43,7 +36,6 @@ type MediaRequestSearchOption =
|
|||||||
export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
||||||
keyPath: "key",
|
keyPath: "key",
|
||||||
title: (t) => t("search.mode.media.group.title"),
|
title: (t) => t("search.mode.media.group.title"),
|
||||||
source: { kind: "remote", source: "media" },
|
|
||||||
Component(option) {
|
Component(option) {
|
||||||
const tMedia = useI18n("search.mode.media");
|
const tMedia = useI18n("search.mode.media");
|
||||||
|
|
||||||
@@ -74,7 +66,7 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
|||||||
return (
|
return (
|
||||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||||
{result.image ? (
|
{result.image ? (
|
||||||
<Image src={result.image} alt="" w={35} h={50} fit="cover" radius="md" />
|
<Image src={result.image} w={35} h={50} fit="cover" radius="md" />
|
||||||
) : (
|
) : (
|
||||||
<IconSearch stroke={1.5} size={35} />
|
<IconSearch stroke={1.5} size={35} />
|
||||||
)}
|
)}
|
||||||
@@ -106,24 +98,21 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
|||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const tMedia = useI18n("search.mode.media");
|
const tMedia = useI18n("search.mode.media");
|
||||||
const scope = useAtomValue(mediaRequestSearchScopeAtom);
|
const scope = useAtomValue(mediaRequestSearchScopeAtom);
|
||||||
const remoteQuery = useRemoteQuery(query, "media");
|
const [debouncedQuery] = useDebouncedValue(query.trim(), 150);
|
||||||
const { data: session, status } = useSession();
|
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery();
|
||||||
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery(undefined, {
|
|
||||||
enabled: status !== "loading" && Boolean(session?.user),
|
|
||||||
staleTime: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scopedTargets = (targetsQuery.data ?? []).filter((target) =>
|
const scopedTargets = (targetsQuery.data ?? []).filter((target) =>
|
||||||
scope.integrationIds ? scope.integrationIds.includes(target.id) : true,
|
scope.integrationIds ? scope.integrationIds.includes(target.id) : true,
|
||||||
);
|
);
|
||||||
const hasScopedTargets = scopedTargets.length > 0;
|
const hasScopedTargets = scopedTargets.length > 0;
|
||||||
const shouldSearch = remoteQuery.enabled && hasScopedTargets;
|
const shouldSearch = debouncedQuery.length > 0 && hasScopedTargets;
|
||||||
const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined;
|
const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined;
|
||||||
|
|
||||||
const resultsQuery = clientApi.integration.searchMediaRequests.useQuery(
|
const resultsQuery = clientApi.integration.searchMediaRequests.useQuery(
|
||||||
{ query: remoteQuery.query, integrationIds },
|
{ query: debouncedQuery, integrationIds },
|
||||||
{
|
{
|
||||||
enabled: shouldSearch,
|
enabled: shouldSearch,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
select: (data) => data.slice(0, 10),
|
select: (data) => data.slice(0, 10),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -131,26 +120,6 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
|||||||
const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading);
|
const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading);
|
||||||
const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError);
|
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) {
|
if (!targetsQuery.data) {
|
||||||
return {
|
return {
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -171,13 +140,12 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
|||||||
description: scope.integrationIds
|
description: scope.integrationIds
|
||||||
? tMedia("action.search.disabled.scoped")
|
? tMedia("action.search.disabled.scoped")
|
||||||
: tMedia("action.search.disabled.noIntegration"),
|
: tMedia("action.search.disabled.noIntegration"),
|
||||||
unavailable: true,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!remoteQuery.enabled) {
|
if (debouncedQuery.length === 0) {
|
||||||
return {
|
return {
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { SearchMode } from "../../lib/mode";
|
||||||
|
import { pagesSearchGroup } from "./pages-search-group";
|
||||||
|
|
||||||
|
export const pageMode = {
|
||||||
|
modeKey: "page",
|
||||||
|
character: "/",
|
||||||
|
groups: [pagesSearchGroup],
|
||||||
|
} satisfies SearchMode;
|
||||||
@@ -30,7 +30,6 @@ export const pagesSearchGroup = createGroup<{
|
|||||||
}>({
|
}>({
|
||||||
keyPath: "path",
|
keyPath: "path",
|
||||||
title: (t) => t("search.mode.page.group.page.title"),
|
title: (t) => t("search.mode.page.group.page.title"),
|
||||||
source: { kind: "local" },
|
|
||||||
Component: ({ name, icon: Icon }) => (
|
Component: ({ name, icon: Icon }) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<Icon stroke={1.5} />
|
<Icon stroke={1.5} />
|
||||||
|
|||||||
@@ -1,22 +1,90 @@
|
|||||||
|
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 { createGroup } from "../../lib/group";
|
||||||
import type { ChildrenAction } from "../../lib/children";
|
import {
|
||||||
import { useSettingsActions } from "../command/children/preferences/settings-children";
|
preferenceChildrenOptionsByKey,
|
||||||
|
preferenceIcons,
|
||||||
|
userFieldPreferenceLabels,
|
||||||
|
} from "../../preferences/preference-registry";
|
||||||
|
import { settingsChildrenOptions } from "../command/children/preferences/settings-children";
|
||||||
|
|
||||||
type PreferenceAction = ChildrenAction<Record<string, unknown>>;
|
type PreferencesGroupOption =
|
||||||
|
| { key: "settings"; preferenceKey: null }
|
||||||
|
| { key: UserPreferenceKey; preferenceKey: UserPreferenceKey };
|
||||||
|
|
||||||
/** Preferences are normal command-menu actions, not a gateway into another hidden list. */
|
const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null };
|
||||||
export const preferencesGroup = createGroup<PreferenceAction>({
|
|
||||||
|
export const preferencesGroup = createGroup<PreferencesGroupOption>({
|
||||||
keyPath: "key",
|
keyPath: "key",
|
||||||
title: (t) => t("search.mode.command.group.preferences.title"),
|
title: (t) => t("search.mode.command.group.preferences.title"),
|
||||||
source: { kind: "local" },
|
|
||||||
useOptions(query) {
|
useOptions(query) {
|
||||||
return useSettingsActions({}, 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] : [];
|
||||||
},
|
},
|
||||||
Component(option) {
|
Component: (option) => {
|
||||||
return <option.Component />;
|
const t = useI18n("search.mode.command.group.preferences");
|
||||||
},
|
const tOption = useI18n("search.mode.command.group.preferences.option");
|
||||||
useInteraction(option, query) {
|
const tUserField = useI18n("user.field");
|
||||||
return option.useInteraction({}, query);
|
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>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
filter: () => true,
|
filter: () => true,
|
||||||
|
useInteraction: (option) => ({
|
||||||
|
type: "children",
|
||||||
|
...(option.preferenceKey
|
||||||
|
? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ??
|
||||||
|
settingsChildrenOptions({}))
|
||||||
|
: settingsChildrenOptions({})),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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,7 +7,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { createChildrenOptions } from "../../lib/children";
|
import { createChildrenOptions } from "../../lib/children";
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import { interaction } from "../../lib/interaction";
|
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>.
|
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
@@ -72,7 +71,6 @@ const groupChildrenOptions = createChildrenOptions<Group>({
|
|||||||
export const groupsSearchGroup = createGroup<Group>({
|
export const groupsSearchGroup = createGroup<Group>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: (t) => t("common.entity.groups"),
|
title: (t) => t("common.entity.groups"),
|
||||||
source: { kind: "remote", source: "groups" },
|
|
||||||
Component: ({ name }) => (
|
Component: ({ name }) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<Text>{name}</Text>
|
<Text>{name}</Text>
|
||||||
@@ -80,7 +78,6 @@ export const groupsSearchGroup = createGroup<Group>({
|
|||||||
),
|
),
|
||||||
useInteraction: interaction.children(groupChildrenOptions),
|
useInteraction: interaction.children(groupChildrenOptions),
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const remoteQuery = useRemoteQuery(query, "groups");
|
return clientApi.group.search.useQuery({ query, limit: 5 });
|
||||||
return clientApi.group.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
import { useSession } from "@homarr/auth/client";
|
|
||||||
|
|
||||||
import type { SearchMode } from "../../lib/mode";
|
import type { SearchMode } from "../../lib/mode";
|
||||||
import { groupsSearchGroup } from "./groups-search-group";
|
import { groupsSearchGroup } from "./groups-search-group";
|
||||||
import { usersSearchGroup } from "./users-search-group";
|
import { usersSearchGroup } from "./users-search-group";
|
||||||
|
|
||||||
export const userGroupMode = {
|
export const userGroupMode = {
|
||||||
mode: "userGroup",
|
modeKey: "userGroup",
|
||||||
character: "@",
|
character: "@",
|
||||||
label: (t) => t("search.modePicker.userGroup.label"),
|
groups: [usersSearchGroup, groupsSearchGroup],
|
||||||
placeholder: (t) => t("search.modePicker.userGroup.placeholder"),
|
|
||||||
useGroups() {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
if (!session?.user.permissions.includes("admin")) return [];
|
|
||||||
return [usersSearchGroup, groupsSearchGroup];
|
|
||||||
},
|
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
@@ -8,7 +8,6 @@ import { UserAvatar } from "@homarr/ui";
|
|||||||
import { createChildrenOptions } from "../../lib/children";
|
import { createChildrenOptions } from "../../lib/children";
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import { interaction } from "../../lib/interaction";
|
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>.
|
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
@@ -50,7 +49,6 @@ const userChildrenOptions = createChildrenOptions<User>({
|
|||||||
export const usersSearchGroup = createGroup<User>({
|
export const usersSearchGroup = createGroup<User>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: (t) => t("common.entity.users"),
|
title: (t) => t("common.entity.users"),
|
||||||
source: { kind: "remote", source: "users" },
|
|
||||||
Component: (user) => (
|
Component: (user) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<UserAvatar user={user} size="sm" />
|
<UserAvatar user={user} size="sm" />
|
||||||
@@ -59,7 +57,6 @@ export const usersSearchGroup = createGroup<User>({
|
|||||||
),
|
),
|
||||||
useInteraction: interaction.children(userChildrenOptions),
|
useInteraction: interaction.children(userChildrenOptions),
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const remoteQuery = useRemoteQuery(query, "users");
|
return clientApi.user.search.useQuery({ query, limit: 5 });
|
||||||
return clientApi.user.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -20,22 +20,19 @@ describe("lazy Spotlight open bridge", () => {
|
|||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
window.addEventListener(spotlightOpenEvent, listener);
|
window.addEventListener(spotlightOpenEvent, listener);
|
||||||
|
|
||||||
openSpotlight({ mode: "apps", query: "rada" });
|
openSpotlight();
|
||||||
|
|
||||||
expect(listener).toHaveBeenCalledOnce();
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
expect(listener.mock.calls[0]?.[0]).toMatchObject({
|
expect(consumePendingSpotlightOpen()).toBe(true);
|
||||||
detail: { mode: "apps", query: "rada" },
|
expect(consumePendingSpotlightOpen()).toBe(false);
|
||||||
});
|
|
||||||
expect(consumePendingSpotlightOpen()).toEqual({ mode: "apps", query: "rada" });
|
|
||||||
expect(consumePendingSpotlightOpen()).toBeNull();
|
|
||||||
window.removeEventListener(spotlightOpenEvent, listener);
|
window.removeEventListener(spotlightOpenEvent, listener);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to search and lets a listener installed after the request discover the pending intent", () => {
|
it("lets a listener installed after the request discover the pending open", () => {
|
||||||
openSpotlight();
|
openSpotlight();
|
||||||
|
|
||||||
expect(hasPendingSpotlightOpen()).toBe(true);
|
expect(hasPendingSpotlightOpen()).toBe(true);
|
||||||
expect(consumePendingSpotlightOpen()).toEqual({ mode: "search" });
|
expect(consumePendingSpotlightOpen()).toBe(true);
|
||||||
expect(hasPendingSpotlightOpen()).toBe(false);
|
expect(hasPendingSpotlightOpen()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,11 +45,8 @@ describe("lazy Spotlight open bridge", () => {
|
|||||||
openMediaRequestSearch({ integrationIds: ["integration-a"], query: "movie" });
|
openMediaRequestSearch({ integrationIds: ["integration-a"], query: "movie" });
|
||||||
|
|
||||||
expect(openListener).toHaveBeenCalledOnce();
|
expect(openListener).toHaveBeenCalledOnce();
|
||||||
expect(openListener.mock.calls[0]?.[0]).toMatchObject({
|
|
||||||
detail: { mode: "media", query: "movie" },
|
|
||||||
});
|
|
||||||
expect(mediaListener).toHaveBeenCalledOnce();
|
expect(mediaListener).toHaveBeenCalledOnce();
|
||||||
expect(consumePendingSpotlightOpen()).toEqual({ mode: "media", query: "movie" });
|
expect(consumePendingSpotlightOpen()).toBe(true);
|
||||||
expect(consumePendingMediaRequestSearch()).toEqual({ integrationIds: ["integration-a"], query: "movie" });
|
expect(consumePendingMediaRequestSearch()).toEqual({ integrationIds: ["integration-a"], query: "movie" });
|
||||||
window.removeEventListener(spotlightOpenEvent, openListener);
|
window.removeEventListener(spotlightOpenEvent, openListener);
|
||||||
window.removeEventListener(mediaRequestSearchEvent, mediaListener);
|
window.removeEventListener(mediaRequestSearchEvent, mediaListener);
|
||||||
|
|||||||
@@ -3,58 +3,30 @@
|
|||||||
export const spotlightOpenEvent = "homarr:spotlight:open";
|
export const spotlightOpenEvent = "homarr:spotlight:open";
|
||||||
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
|
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 {
|
export interface OpenMediaRequestSearchOptions {
|
||||||
integrationIds?: string[];
|
integrationIds?: string[];
|
||||||
query?: string;
|
query?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pendingMediaRequestSearch: OpenMediaRequestSearchOptions | null = null;
|
let pendingMediaRequestSearch: OpenMediaRequestSearchOptions | null = null;
|
||||||
let pendingSpotlightOpen: SpotlightOpenIntent | null = null;
|
let pendingSpotlightOpen = false;
|
||||||
|
|
||||||
const requestSpotlightMount = (intent: SpotlightOpenIntent) => {
|
const requestSpotlightMount = () => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") window.dispatchEvent(new Event(spotlightOpenEvent));
|
||||||
window.dispatchEvent(new CustomEvent<SpotlightOpenIntent>(spotlightOpenEvent, { detail: intent }));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const openSpotlight = (options: OpenSpotlightOptions = {}) => {
|
export const openSpotlight = () => {
|
||||||
const intent: SpotlightOpenIntent = {
|
pendingSpotlightOpen = true;
|
||||||
mode: options.mode ?? "search",
|
requestSpotlightMount();
|
||||||
};
|
|
||||||
if (options.query !== undefined) intent.query = options.query;
|
|
||||||
|
|
||||||
if (intent.mode !== "media") pendingMediaRequestSearch = null;
|
|
||||||
pendingSpotlightOpen = intent;
|
|
||||||
requestSpotlightMount(intent);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const consumePendingSpotlightOpen = () => {
|
export const consumePendingSpotlightOpen = () => {
|
||||||
const pending = pendingSpotlightOpen;
|
const pending = pendingSpotlightOpen;
|
||||||
pendingSpotlightOpen = null;
|
pendingSpotlightOpen = false;
|
||||||
return pending;
|
return pending;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hasPendingSpotlightOpen = () => pendingSpotlightOpen !== null;
|
export const hasPendingSpotlightOpen = () => pendingSpotlightOpen;
|
||||||
|
|
||||||
export const consumePendingMediaRequestSearch = () => {
|
export const consumePendingMediaRequestSearch = () => {
|
||||||
const pending = pendingMediaRequestSearch;
|
const pending = pendingMediaRequestSearch;
|
||||||
@@ -64,7 +36,7 @@ export const consumePendingMediaRequestSearch = () => {
|
|||||||
|
|
||||||
export const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
|
export const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
|
||||||
pendingMediaRequestSearch = options;
|
pendingMediaRequestSearch = options;
|
||||||
openSpotlight({ mode: "media", query: options.query });
|
openSpotlight();
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
|
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
|
||||||
|
|||||||
@@ -6314,9 +6314,6 @@
|
|||||||
"toggleColorScheme": "Toggle light/dark mode",
|
"toggleColorScheme": "Toggle light/dark mode",
|
||||||
"saveNotebook": "Save notebook (only inside notebook widget)",
|
"saveNotebook": "Save notebook (only inside notebook widget)",
|
||||||
"openSpotlight": "Open search",
|
"openSpotlight": "Open search",
|
||||||
"openSpotlightApps": "Open app search",
|
|
||||||
"openSpotlightAssistant": "Open Assistant mode",
|
|
||||||
"openSpotlightPreferences": "Open preferences mode",
|
|
||||||
"openAssistant": "Open Homarr Assistant"
|
"openAssistant": "Open Homarr Assistant"
|
||||||
},
|
},
|
||||||
"note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS"
|
"note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS"
|
||||||
@@ -6782,51 +6779,6 @@
|
|||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search for anything",
|
"placeholder": "Search for anything",
|
||||||
"nothingFound": "Nothing found",
|
"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": {
|
"error": {
|
||||||
"fetch": "An error occurred while fetching data"
|
"fetch": "An error occurred while fetching data"
|
||||||
},
|
},
|
||||||
@@ -7048,7 +7000,6 @@
|
|||||||
"description": "Start typing to search your media request integrations",
|
"description": "Start typing to search your media request integrations",
|
||||||
"disabled": {
|
"disabled": {
|
||||||
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
|
"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."
|
"scoped": "This widget does not have an accessible media request integration."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7126,10 +7077,9 @@
|
|||||||
"other": {
|
"other": {
|
||||||
"label": "Search with another search engine"
|
"label": "Search with another search engine"
|
||||||
},
|
},
|
||||||
"no-engines": {
|
"no-default": {
|
||||||
"label": "No search engines configured",
|
"label": "No default search engine",
|
||||||
"descriptionCreate": "Add a search engine to search from the command menu",
|
"description": "Set a default search engine in preferences"
|
||||||
"descriptionUnavailable": "Ask an administrator to configure a search engine"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"label": "Search for \"{query}\" with {name}"
|
"label": "Search for \"{query}\" with {name}"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { IntegrationAvatar } from "../components/integration-avatar";
|
import { IntegrationAvatar } from "../components/integration-avatar";
|
||||||
import { SelectableCard } from "../components/selectable-card";
|
import { SelectableCard } from "../components/selectable-card";
|
||||||
import { selectGridCols } from "../select-grid-layout";
|
import { selectGridCols } from "../select-grid-layout";
|
||||||
import { buildSortedIntegrations, filterIntegrations } from "./integration-grid-shared";
|
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||||
import type { IntegrationGridItem } from "./integration-grid-shared";
|
import type { IntegrationGridItem } from "./integration-grid-shared";
|
||||||
|
|
||||||
export interface IntegrationSelectGridProps {
|
export interface IntegrationSelectGridProps {
|
||||||
@@ -134,11 +134,8 @@ const IntegrationCard = ({
|
|||||||
onClick={() => onSelect(integration.kind)}
|
onClick={() => onSelect(integration.kind)}
|
||||||
aria-label={integration.name}
|
aria-label={integration.name}
|
||||||
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
|
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
|
||||||
title={
|
title={integration.name}
|
||||||
<Text fw={700} size="md" style={{ whiteSpace: "nowrap" }}>
|
topRight={<IntegrationCategoryBadges categories={integration.categories} limit={1} />}
|
||||||
{integration.name}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
footerLeft={
|
footerLeft={
|
||||||
connectedCount > 0 ? (
|
connectedCount > 0 ? (
|
||||||
<Badge variant="light" color="teal" size="xs" radius="xs">
|
<Badge variant="light" color="teal" size="xs" radius="xs">
|
||||||
@@ -235,3 +232,24 @@ const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; lim
|
|||||||
</Group>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import combineClasses from "clsx";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
|
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||||
|
|
||||||
@@ -28,6 +29,26 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
|||||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
||||||
const app = getUsableWidgetQueryData(appQuery);
|
const app = getUsableWidgetQueryData(appQuery);
|
||||||
const href = getSafeAppHref(app?.href);
|
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 (!options.appId) return <WidgetEmptyState />;
|
||||||
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
|
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export default function BookmarksWidget({
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: bookmark.id,
|
id: bookmark.id,
|
||||||
dedupeKey: appsById.has(bookmark.id) ? `app:${bookmark.id}` : undefined,
|
|
||||||
name: bookmark.name,
|
name: bookmark.name,
|
||||||
icon: bookmark.iconUrl ?? IconLink,
|
icon: bookmark.iconUrl ?? IconLink,
|
||||||
interaction() {
|
interaction() {
|
||||||
@@ -149,7 +148,10 @@ export default function BookmarksWidget({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</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}>
|
<Box miw="100%" mih="100%" pb={2}>
|
||||||
{plan.horizontalScroll ? (
|
{plan.horizontalScroll ? (
|
||||||
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">
|
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">
|
||||||
|
|||||||
Reference in new issue
Block a user