fix(spotlight): stabilize catalog search
This commit is contained in:
1 parent
389a99a203
commit
7acff56abb
21 files changed
+359
-239
No files matched your search
@@ -23,8 +23,9 @@ Homarr has the following keyboard shortcuts:
|
||||
|
||||
Search is designed as the fastest route to Homarr. Start typing after `MOD` + `K` to find local apps, boards,
|
||||
integrations, pages, commands, and preferences without entering a separate mode first. Local Homarr results appear
|
||||
before external search and Assistant fallbacks. Commands include opening the board switcher without leaving the
|
||||
keyboard.
|
||||
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.
|
||||
|
||||
@@ -7,9 +7,13 @@ tags:
|
||||
|
||||
# Search Engines
|
||||
|
||||
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`.
|
||||
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `MOD + K`.
|
||||
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
|
||||
|
||||
The default **Search** mode lists every configured search engine as a fallback action for the current query. To discover
|
||||
engines by shortcut, switch to **Web** with `!`. DuckDuckGo bang discovery is intentionally limited to Web mode so an
|
||||
ordinary search term never expands into similarly named bang providers.
|
||||
|
||||
You can add any search engine that has a search URL that accepts a search term as a parameter.
|
||||
|
||||
## Create search engine
|
||||
@@ -26,6 +30,8 @@ The name can be chosen freely. It will be displayed in the search box popover.
|
||||
|
||||
Prefix that is used to choose the search engine in the popover using the keyboard.
|
||||
It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia).
|
||||
Type `!` in Spotlight to browse these shortcuts and, when DuckDuckGo bangs are enabled in preferences, discover
|
||||
additional bang providers.
|
||||
|
||||
### Configuration
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export const BoardAppsSpotlightRegistrar = () => {
|
||||
return [
|
||||
{
|
||||
id: app.id,
|
||||
dedupeKey: `app:${app.id}`,
|
||||
name: app.name,
|
||||
icon: app.iconUrl,
|
||||
interaction() {
|
||||
|
||||
@@ -57,12 +57,8 @@ export const LazySpotlight = () => {
|
||||
return isMounted || isPreloaded ? (
|
||||
<>
|
||||
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
||||
{isMounted && (
|
||||
<>
|
||||
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
|
||||
<Spotlight />
|
||||
</>
|
||||
)}
|
||||
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
|
||||
<Spotlight />
|
||||
</>
|
||||
) : null;
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { createId, generateResponsiveGridFor } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
@@ -123,6 +124,84 @@ const findFirstAvailableBoardItemPosition = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const searchAccessibleBoardsAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
query: string,
|
||||
limit?: number,
|
||||
) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
});
|
||||
|
||||
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const boardIds = permissionsOfCurrentUserWhenPresent
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
permissionsOfCurrentUserGroupsWhenPresent
|
||||
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||
.flat(),
|
||||
);
|
||||
|
||||
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
});
|
||||
|
||||
const foundBoards = await ctx.db.query.boards.findMany({
|
||||
where: and(
|
||||
like(boards.name, `%${query}%`),
|
||||
ctx.session?.user.permissions.includes("board-view-all")
|
||||
? undefined
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||
inArray(boards.id, boardIds),
|
||||
),
|
||||
),
|
||||
limit,
|
||||
orderBy: asc(boards.name),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
creatorId: true,
|
||||
isPublic: true,
|
||||
logoImageUrl: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where:
|
||||
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,
|
||||
}));
|
||||
};
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
exists: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
@@ -482,80 +561,10 @@ export const boardRouter = createTRPCRouter({
|
||||
};
|
||||
});
|
||||
}),
|
||||
catalog: publicProcedure.query(async ({ ctx }) => searchAccessibleBoardsAsync(ctx, "")),
|
||||
search: publicProcedure
|
||||
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
});
|
||||
|
||||
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const boardIds = permissionsOfCurrentUserWhenPresent
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
permissionsOfCurrentUserGroupsWhenPresent
|
||||
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||
.flat(),
|
||||
);
|
||||
|
||||
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
});
|
||||
|
||||
const foundBoards = await ctx.db.query.boards.findMany({
|
||||
where: and(
|
||||
like(boards.name, `%${input.query}%`),
|
||||
ctx.session?.user.permissions.includes("board-view-all")
|
||||
? undefined
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||
inArray(boards.id, boardIds),
|
||||
),
|
||||
),
|
||||
limit: input.limit,
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
creatorId: true,
|
||||
isPublic: true,
|
||||
logoImageUrl: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where:
|
||||
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return foundBoards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
permissions: constructBoardPermissions(board, ctx.session),
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
.query(async ({ ctx, input }) => searchAccessibleBoardsAsync(ctx, input.query, input.limit)),
|
||||
createBoard: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.meta({
|
||||
|
||||
@@ -130,6 +130,21 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
|
||||
return null;
|
||||
}),
|
||||
catalog: publicProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.searchEngines.findMany({
|
||||
where: ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
|
||||
orderBy: asc(searchEngines.name),
|
||||
with: {
|
||||
integration: {
|
||||
columns: {
|
||||
kind: true,
|
||||
url: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.searchEngines.findMany({
|
||||
// Public dashboards have no session: restrict anonymous users to generic
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { Center } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -61,11 +61,7 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
||||
}
|
||||
|
||||
if (options.isLoading) {
|
||||
return (
|
||||
<Center w="100%" py="sm" data-search-loading>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
return <span hidden data-search-pending />;
|
||||
}
|
||||
|
||||
if (options.isError) {
|
||||
|
||||
@@ -70,12 +70,8 @@
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.actionsList [data-search-loading] ~ [data-search-loading] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.actionsList:has([data-action]) .emptyState,
|
||||
.actionsList:has([data-search-loading]) .emptyState,
|
||||
.actionsList:has([data-search-pending]) .emptyState,
|
||||
.actionsList:has([data-search-status]) .emptyState {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import type { OpenMediaRequestSearchOptions, SpotlightMode, SpotlightOpenIntent } from "../open";
|
||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||
import type { SearchMode } from "../lib/mode";
|
||||
import { useSpotlightCatalogs } from "../lib/catalog";
|
||||
import { searchModes } from "../modes";
|
||||
import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
|
||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||
@@ -36,6 +37,8 @@ const resetSelection = () => {
|
||||
};
|
||||
|
||||
export const Spotlight = () => {
|
||||
useSpotlightCatalogs();
|
||||
|
||||
const [mode, setMode] = useState<SpotlightMode>(defaultMode);
|
||||
const [query, setQuery] = useState("");
|
||||
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
|
||||
import { spotlightOpenEvent } from "../open";
|
||||
|
||||
const catalogQueryOptions = (enabled = true) => ({
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
});
|
||||
|
||||
export const useAppsCatalogQuery = (enabled = true) => {
|
||||
const { status } = useSession();
|
||||
return clientApi.app.selectable.useQuery(undefined, catalogQueryOptions(enabled && status === "authenticated"));
|
||||
};
|
||||
|
||||
export const useBoardsCatalogQuery = () => {
|
||||
const { status } = useSession();
|
||||
return clientApi.board.catalog.useQuery(undefined, catalogQueryOptions(status !== "loading"));
|
||||
};
|
||||
|
||||
export const useIntegrationsCatalogQuery = (enabled = true) => {
|
||||
const { status } = useSession();
|
||||
return clientApi.integration.all.useQuery(undefined, catalogQueryOptions(enabled && status === "authenticated"));
|
||||
};
|
||||
|
||||
export const useSearchEnginesCatalogQuery = () => {
|
||||
const { status } = useSession();
|
||||
return clientApi.searchEngine.catalog.useQuery(undefined, catalogQueryOptions(status !== "loading"));
|
||||
};
|
||||
|
||||
export const useSpotlightCatalogs = () => {
|
||||
const { data: session, status } = useSession();
|
||||
const isAuthenticated = status === "authenticated";
|
||||
const isSessionResolved = status !== "loading";
|
||||
const canBrowseApps =
|
||||
session?.user.permissions.includes("board-modify-all") === true ||
|
||||
session?.user.permissions.includes("app-modify-all") === true;
|
||||
|
||||
const appsQuery = useAppsCatalogQuery(isAuthenticated && canBrowseApps);
|
||||
const boardsQuery = useBoardsCatalogQuery();
|
||||
const integrationsQuery = useIntegrationsCatalogQuery(isAuthenticated);
|
||||
const searchEnginesQuery = useSearchEnginesCatalogQuery();
|
||||
|
||||
useEffect(() => {
|
||||
const refreshCatalogs = () => {
|
||||
if (canBrowseApps) void appsQuery.refetch();
|
||||
if (isSessionResolved) void boardsQuery.refetch();
|
||||
if (isAuthenticated) void integrationsQuery.refetch();
|
||||
if (isSessionResolved) void searchEnginesQuery.refetch();
|
||||
};
|
||||
|
||||
window.addEventListener(spotlightOpenEvent, refreshCatalogs);
|
||||
return () => window.removeEventListener(spotlightOpenEvent, refreshCatalogs);
|
||||
}, [
|
||||
appsQuery,
|
||||
boardsQuery,
|
||||
canBrowseApps,
|
||||
integrationsQuery,
|
||||
isAuthenticated,
|
||||
isSessionResolved,
|
||||
searchEnginesQuery,
|
||||
]);
|
||||
};
|
||||
|
||||
export const filterCatalog = <TOption>(
|
||||
options: TOption[],
|
||||
query: string,
|
||||
getSearchableValues: (option: TOption) => string[],
|
||||
limit = 8,
|
||||
) => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (normalizedQuery.length === 0) return options.slice(0, limit);
|
||||
|
||||
return options
|
||||
.map((option, index) => {
|
||||
const values = getSearchableValues(option).map((value) => value.toLowerCase());
|
||||
const score = values.reduce((currentScore, value) => {
|
||||
if (value === normalizedQuery) return Math.min(currentScore, 0);
|
||||
if (value.startsWith(normalizedQuery)) return Math.min(currentScore, 1);
|
||||
if (value.includes(normalizedQuery)) return Math.min(currentScore, 2);
|
||||
return currentScore;
|
||||
}, Number.POSITIVE_INFINITY);
|
||||
|
||||
return { option, index, score };
|
||||
})
|
||||
.filter(({ score }) => Number.isFinite(score))
|
||||
.toSorted((first, second) => first.score - second.score || first.index - second.index)
|
||||
.slice(0, limit)
|
||||
.map(({ option }) => option);
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Avatar, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconExternalLink, IconEye } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createChildrenOptions } from "../../lib/children";
|
||||
import { filterCatalog, useAppsCatalogQuery } from "../../lib/catalog";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
import { useRemoteQuery } from "../../lib/remote-query";
|
||||
import { useSpotlightContextResults } from "../home/context";
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
@@ -96,7 +96,16 @@ export const appsSearchGroup = createGroup<App>({
|
||||
),
|
||||
useInteraction: interaction.children(appChildrenOptions),
|
||||
useQueryOptions(query) {
|
||||
const remoteQuery = useRemoteQuery(query, "apps");
|
||||
return clientApi.app.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
|
||||
const catalogQuery = useAppsCatalogQuery();
|
||||
const localAppKeys = new Set(
|
||||
useSpotlightContextResults().flatMap((item) => (item.dedupeKey ? [item.dedupeKey] : [])),
|
||||
);
|
||||
const apps = (catalogQuery.data ?? []).filter((app) => !localAppKeys.has(`app:${app.id}`));
|
||||
|
||||
return {
|
||||
data: filterCatalog(apps, query, (app) => [app.name]),
|
||||
isLoading: catalogQuery.isLoading,
|
||||
isError: catalogQuery.isError,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ChildrenAction } from "../../lib/children";
|
||||
import { filterCatalog, useBoardsCatalogQuery } from "../../lib/catalog";
|
||||
import { createChildrenOptions } from "../../lib/children";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
import { useRemoteQuery } from "../../lib/remote-query";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
@@ -141,7 +141,11 @@ export const boardsSearchGroup = createGroup<Board>({
|
||||
),
|
||||
useInteraction: interaction.children(boardChildrenOptions),
|
||||
useQueryOptions(query) {
|
||||
const remoteQuery = useRemoteQuery(query, "boards");
|
||||
return clientApi.board.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
|
||||
const catalogQuery = useBoardsCatalogQuery();
|
||||
return {
|
||||
data: filterCatalog(catalogQuery.data ?? [], query, (board) => [board.name]),
|
||||
isLoading: catalogQuery.isLoading,
|
||||
isError: catalogQuery.isError,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useSession } from "@homarr/auth/client";
|
||||
|
||||
import type { SearchGroup } from "../../lib/group";
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { contextSpecificSearchGroups } from "../home/context-specific-group";
|
||||
import { contextSpecificAppsSearchGroup } from "../home/context-specific-group";
|
||||
import { appsSearchGroup } from "./apps-search-group";
|
||||
import { boardsSearchGroup } from "./boards-search-group";
|
||||
import { integrationsSearchGroup } from "./integrations-search-group";
|
||||
@@ -14,7 +14,7 @@ export const appIntegrationBoardMode = {
|
||||
placeholder: (t) => t("search.modePicker.apps.placeholder"),
|
||||
useGroups() {
|
||||
const { data: session } = useSession();
|
||||
const groups: SearchGroup[] = [contextSpecificSearchGroups];
|
||||
const groups: SearchGroup[] = [contextSpecificAppsSearchGroup];
|
||||
|
||||
if (!session?.user) {
|
||||
return groups.concat(boardsSearchGroup);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { filterCatalog, useIntegrationsCatalogQuery } from "../../lib/catalog";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
import { useRemoteQuery } from "../../lib/remote-query";
|
||||
|
||||
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
|
||||
keyPath: "id",
|
||||
@@ -21,10 +20,11 @@ export const integrationsSearchGroup = createGroup<{ id: string; kind: Integrati
|
||||
),
|
||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })),
|
||||
useQueryOptions(query) {
|
||||
const remoteQuery = useRemoteQuery(query, "integrations");
|
||||
return clientApi.integration.search.useQuery(
|
||||
{ query: remoteQuery.query, limit: 8 },
|
||||
{ enabled: remoteQuery.enabled },
|
||||
);
|
||||
const catalogQuery = useIntegrationsCatalogQuery();
|
||||
return {
|
||||
data: filterCatalog(catalogQuery.data ?? [], query, (integration) => [integration.name, integration.kind]),
|
||||
isLoading: catalogQuery.isLoading,
|
||||
isError: catalogQuery.isError,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -11,12 +11,13 @@ import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createChildrenOptions } from "../../lib/children";
|
||||
import { filterCatalog, useSearchEnginesCatalogQuery } from "../../lib/catalog";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import type { inferSearchInteractionDefinition } from "../../lib/interaction";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
import { useRemoteQuery } from "../../lib/remote-query";
|
||||
|
||||
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
|
||||
type SearchEngine = RouterOutputs["searchEngine"]["catalog"][number];
|
||||
type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
|
||||
type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number];
|
||||
|
||||
@@ -287,7 +288,7 @@ const parseBangQuery = (query: string) => {
|
||||
return { bangToken, searchText, locked };
|
||||
};
|
||||
|
||||
const buildSearchUrl = (template: string, query: string) => {
|
||||
export const buildSearchUrl = (template: string, query: string) => {
|
||||
const encoded = encodeURIComponent(query);
|
||||
if (template.includes("{{{s}}}")) {
|
||||
return template.replaceAll("{{{s}}}", encoded);
|
||||
@@ -416,23 +417,28 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
|
||||
const { bangToken, searchText, locked } = parseBangQuery(query);
|
||||
const remoteQuery = useRemoteQuery(bangToken, "search-engines", { minimumLength });
|
||||
const ddgQueryEnabled = remoteQuery.enabled && ddgBangs && remoteQuery.query.length >= 1;
|
||||
|
||||
const enginesQuery = clientApi.searchEngine.search.useQuery(
|
||||
{ query: remoteQuery.query, limit: 10 },
|
||||
{ enabled: remoteQuery.enabled },
|
||||
);
|
||||
const enginesQuery = useSearchEnginesCatalogQuery();
|
||||
|
||||
const ddgQuery = clientApi.bangs.search.useQuery(
|
||||
{ query: remoteQuery.query, limit: 10 },
|
||||
{
|
||||
enabled: ddgQueryEnabled,
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading = (enginesQuery.isLoading && remoteQuery.enabled) || (ddgQuery.isLoading && ddgQueryEnabled);
|
||||
const isError = (enginesQuery.isError && remoteQuery.enabled) || (ddgQuery.isError && ddgQueryEnabled);
|
||||
const engineData = remoteQuery.enabled ? (enginesQuery.data ?? []) : [];
|
||||
const ddgData = ddgQueryEnabled ? (ddgQuery.data ?? []) : [];
|
||||
const engineCatalog = enginesQuery.data ?? [];
|
||||
const engineData = filterCatalog(engineCatalog, bangToken, (engine) => [engine.short, engine.name], 10);
|
||||
const ddgData = ddgQueryEnabled && bangToken.length > 0
|
||||
? (ddgQuery.data ?? []).filter((bang) => bang.t.toLowerCase().startsWith(bangToken.toLowerCase()))
|
||||
: [];
|
||||
const isWaitingForDdgResults =
|
||||
ddgBangs &&
|
||||
bangToken.length > 0 &&
|
||||
engineData.length === 0 &&
|
||||
(remoteQuery.query !== bangToken || (ddgQueryEnabled && ddgQuery.isFetching && ddgData.length === 0));
|
||||
const isLoading = enginesQuery.isLoading || isWaitingForDdgResults;
|
||||
const isError = enginesQuery.isError || (ddgQuery.isError && ddgQueryEnabled && bangToken.length > 0);
|
||||
|
||||
const engineOptions = engineData.map(
|
||||
(engine): ExternalOption => ({
|
||||
@@ -454,7 +460,7 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
|
||||
|
||||
const searchActions: ExternalOption[] = [];
|
||||
if (locked && bangToken.length > 0) {
|
||||
const matchedEngine = engineData.find((engine) => engine.short === bangToken);
|
||||
const matchedEngine = engineCatalog.find((engine) => engine.short === bangToken);
|
||||
const matchedDdg = ddgData.find((bang) => bang.t === bangToken);
|
||||
|
||||
const genericEngine = matchedEngine?.type === "generic" ? matchedEngine : undefined;
|
||||
@@ -487,7 +493,7 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showEmptyHint && remoteQuery.query.length === 0) {
|
||||
if (showEmptyHint && bangToken.length === 0) {
|
||||
searchActions.push({
|
||||
key: "hint",
|
||||
kind: "hint",
|
||||
@@ -509,9 +515,3 @@ export const searchEnginesSearchGroups = createSearchEnginesSearchGroup({
|
||||
showEmptyHint: true,
|
||||
source: "focused",
|
||||
});
|
||||
|
||||
export const globalSearchEnginesSearchGroup = createSearchEnginesSearchGroup({
|
||||
minimumLength: 1,
|
||||
showEmptyHint: false,
|
||||
source: "fallback",
|
||||
});
|
||||
@@ -62,6 +62,23 @@ export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
|
||||
},
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -17,6 +17,7 @@ export type ContextSpecificItem = {
|
||||
description?: string;
|
||||
unavailable?: boolean;
|
||||
alwaysVisible?: boolean;
|
||||
dedupeKey?: string;
|
||||
};
|
||||
|
||||
interface SpotlightContextProps {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconSearch, IconSearchOff } from "@tabler/icons-react";
|
||||
import { IconSearchOff } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { useSearchEnginesCatalogQuery } from "../../lib/catalog";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
|
||||
import { useRemoteQuery } from "../../lib/remote-query";
|
||||
import { useFromIntegrationSearchInteraction } from "../external/search-engines-search-group";
|
||||
import { buildSearchUrl } from "../external/search-engines-search-group";
|
||||
|
||||
type SearchEngine = RouterOutputs["searchEngine"]["catalog"][number];
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type GroupItem = {
|
||||
id: string;
|
||||
@@ -22,7 +23,7 @@ type GroupItem = {
|
||||
description?: string;
|
||||
icon: TablerIcon | string;
|
||||
disabled?: boolean;
|
||||
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||
interaction: inferSearchInteractionDefinition<SearchInteraction>;
|
||||
};
|
||||
|
||||
export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||
@@ -44,127 +45,95 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||
{icon}
|
||||
<Stack gap={0}>
|
||||
<Text>{item.name}</Text>
|
||||
{item.description && (
|
||||
{item.description ? (
|
||||
<Text c="gray.6" size="sm">
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction(item, query) {
|
||||
return item.useInteraction(query);
|
||||
},
|
||||
filter() {
|
||||
return true;
|
||||
useInteraction(item) {
|
||||
return item.interaction;
|
||||
},
|
||||
useQueryOptions(query) {
|
||||
const t = useI18n();
|
||||
const { data: session, status } = useSession();
|
||||
const remoteQuery = useRemoteQuery(query, "integration-search");
|
||||
const { data: defaultSearchEngine, ...defaultSearchEngineQuery } =
|
||||
clientApi.searchEngine.getDefaultSearchEngine.useQuery(undefined, {
|
||||
enabled: status !== "loading" && remoteQuery.enabled,
|
||||
});
|
||||
const fromIntegrationEnabled =
|
||||
defaultSearchEngine?.type === "fromIntegration" &&
|
||||
defaultSearchEngine.integrationId !== null &&
|
||||
remoteQuery.enabled;
|
||||
const { data: results, ...resultQuery } = clientApi.integration.searchInIntegration.useQuery(
|
||||
{
|
||||
query: remoteQuery.query,
|
||||
integrationId: defaultSearchEngine?.integrationId ?? "",
|
||||
},
|
||||
{
|
||||
enabled: fromIntegrationEnabled,
|
||||
select: (data) => data.slice(0, 5),
|
||||
},
|
||||
);
|
||||
const { data: session } = useSession();
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
const catalogQuery = useSearchEnginesCatalogQuery();
|
||||
|
||||
return {
|
||||
isLoading:
|
||||
defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading",
|
||||
isError:
|
||||
(defaultSearchEngineQuery.isError && status !== "loading" && remoteQuery.enabled) ||
|
||||
(resultQuery.isError && fromIntegrationEnabled),
|
||||
data: remoteQuery.enabled ? createDefaultSearchEntries(defaultSearchEngine, results, session, query, t) : [],
|
||||
isLoading: catalogQuery.isLoading,
|
||||
isError: catalogQuery.isError,
|
||||
data: createSearchEntries(
|
||||
catalogQuery.data ?? [],
|
||||
session?.user.permissions.includes("search-engine-create") === true,
|
||||
query,
|
||||
openSearchInNewTab,
|
||||
t,
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const createDefaultSearchEntries = (
|
||||
defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null,
|
||||
results: RouterOutputs["integration"]["searchInIntegration"] | undefined,
|
||||
session: Session | null,
|
||||
const createSearchEntries = (
|
||||
searchEngines: SearchEngine[],
|
||||
canCreateSearchEngine: boolean,
|
||||
query: string,
|
||||
openInNewTab: boolean,
|
||||
t: TranslationFunction,
|
||||
): GroupItem[] => {
|
||||
if (!session?.user && !defaultSearchEngine) return [];
|
||||
|
||||
if (!defaultSearchEngine) {
|
||||
if (searchEngines.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: "no-default",
|
||||
name: t("search.mode.home.group.search.option.no-default.label"),
|
||||
description: t("search.mode.home.group.search.option.no-default.description"),
|
||||
id: "no-search-engines",
|
||||
name: t("search.mode.home.group.search.option.no-engines.label"),
|
||||
description: canCreateSearchEngine
|
||||
? t("search.mode.home.group.search.option.no-engines.descriptionCreate")
|
||||
: t("search.mode.home.group.search.option.no-engines.descriptionUnavailable"),
|
||||
icon: IconSearchOff,
|
||||
useInteraction() {
|
||||
return {
|
||||
type: "link",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: `/manage/users/${session!.user.id}/general`,
|
||||
};
|
||||
},
|
||||
disabled: !canCreateSearchEngine,
|
||||
interaction: canCreateSearchEngine ? { type: "link", href: "/manage/search-engines/new" } : { type: "none" },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (defaultSearchEngine.type === "generic") {
|
||||
return [
|
||||
{
|
||||
id: "search",
|
||||
name: t("search.mode.home.group.search.option.search.label", {
|
||||
query,
|
||||
name: defaultSearchEngine.name,
|
||||
}),
|
||||
icon: defaultSearchEngine.iconUrl,
|
||||
useInteraction(query) {
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
return {
|
||||
type: "link",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: defaultSearchEngine.urlTemplate!.replace("%s", query),
|
||||
newTab: openSearchInNewTab,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
const normalizedQuery = query.trim();
|
||||
return searchEngines.map((searchEngine) => {
|
||||
const name =
|
||||
normalizedQuery.length === 0
|
||||
? searchEngine.name
|
||||
: t("search.mode.home.group.search.option.search.label", {
|
||||
query: normalizedQuery,
|
||||
name: searchEngine.name,
|
||||
});
|
||||
|
||||
if (!results) {
|
||||
return [
|
||||
{
|
||||
id: "from-integration",
|
||||
name: defaultSearchEngine.name,
|
||||
icon: defaultSearchEngine.iconUrl,
|
||||
description: t("search.mode.home.group.search.option.from-integration.description"),
|
||||
useInteraction() {
|
||||
return {
|
||||
type: "none",
|
||||
};
|
||||
if (searchEngine.type === "generic") {
|
||||
return {
|
||||
id: `search-${searchEngine.id}`,
|
||||
name,
|
||||
description: searchEngine.description ?? undefined,
|
||||
icon: searchEngine.iconUrl,
|
||||
interaction: {
|
||||
type: "link",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: buildSearchUrl(searchEngine.urlTemplate!, normalizedQuery),
|
||||
newTab: openInNewTab,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return results.map((result) => ({
|
||||
id: `search-${result.id}`,
|
||||
name: result.name,
|
||||
description: result.text,
|
||||
icon: result.image ?? IconSearch,
|
||||
useInteraction() {
|
||||
return useFromIntegrationSearchInteraction(defaultSearchEngine, result);
|
||||
},
|
||||
}));
|
||||
return {
|
||||
id: `search-${searchEngine.id}`,
|
||||
name,
|
||||
description: searchEngine.description ?? undefined,
|
||||
icon: searchEngine.iconUrl,
|
||||
interaction: {
|
||||
type: "mode",
|
||||
mode: "external",
|
||||
query: `${searchEngine.short} ${normalizedQuery}`.trimEnd(),
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -2,9 +2,12 @@ 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 { globalSearchEnginesSearchGroup } from "../external/search-engines-search-group";
|
||||
import { homeSearchEngineGroup } from "./home-search-engine-group";
|
||||
import { contextSpecificFallbackSearchGroup, contextSpecificSearchGroups } from "./context-specific-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";
|
||||
@@ -16,7 +19,7 @@ export const homeMode = {
|
||||
label: (t) => t("search.modePicker.search.label"),
|
||||
placeholder: (t) => t("search.modePicker.search.placeholder"),
|
||||
useGroups() {
|
||||
const appGroups = appIntegrationBoardMode.useGroups().filter((group) => group !== contextSpecificSearchGroups);
|
||||
const appGroups = appIntegrationBoardMode.useGroups().filter((group) => group !== contextSpecificAppsSearchGroup);
|
||||
const directoryGroups = userGroupMode.useGroups();
|
||||
|
||||
return [
|
||||
@@ -27,7 +30,6 @@ export const homeMode = {
|
||||
preferencesGroup,
|
||||
...appGroups,
|
||||
...directoryGroups,
|
||||
globalSearchEnginesSearchGroup,
|
||||
mediaFallbackGroup,
|
||||
homeSearchEngineGroup,
|
||||
contextSpecificFallbackSearchGroup,
|
||||
|
||||
@@ -7126,9 +7126,10 @@
|
||||
"other": {
|
||||
"label": "Search with another search engine"
|
||||
},
|
||||
"no-default": {
|
||||
"label": "No default search engine",
|
||||
"description": "Set a default search engine in preferences"
|
||||
"no-engines": {
|
||||
"label": "No search engines configured",
|
||||
"descriptionCreate": "Add a search engine to search from the command menu",
|
||||
"descriptionUnavailable": "Ask an administrator to configure a search engine"
|
||||
},
|
||||
"search": {
|
||||
"label": "Search for \"{query}\" with {name}"
|
||||
|
||||
@@ -73,6 +73,7 @@ export default function BookmarksWidget({
|
||||
return [
|
||||
{
|
||||
id: bookmark.id,
|
||||
dedupeKey: appsById.has(bookmark.id) ? `app:${bookmark.id}` : undefined,
|
||||
name: bookmark.name,
|
||||
icon: bookmark.iconUrl ?? IconLink,
|
||||
interaction() {
|
||||
@@ -148,10 +149,7 @@ export default function BookmarksWidget({
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<ScrollArea
|
||||
scrollbars={plan.horizontalScroll ? "x" : "y"}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
<ScrollArea scrollbars={plan.horizontalScroll ? "x" : "y"} style={{ flex: 1, minHeight: 0 }}>
|
||||
<Box miw="100%" mih="100%" pb={2}>
|
||||
{plan.horizontalScroll ? (
|
||||
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">
|
||||
|
||||
Reference in new issue
Block a user