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,
|
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
|
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
|
before configured search-engine actions and Assistant fallbacks. Commands include opening the board switcher without
|
||||||
keyboard.
|
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
|
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.
|
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
|
# 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 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
|
||||||
@@ -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.
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const BoardAppsSpotlightRegistrar = () => {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: app.id,
|
id: app.id,
|
||||||
|
dedupeKey: `app:${app.id}`,
|
||||||
name: app.name,
|
name: app.name,
|
||||||
icon: app.iconUrl,
|
icon: app.iconUrl,
|
||||||
interaction() {
|
interaction() {
|
||||||
|
|||||||
@@ -57,12 +57,8 @@ 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 && (
|
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
|
||||||
<>
|
<Spotlight />
|
||||||
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
|
|
||||||
<Spotlight />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ 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";
|
||||||
@@ -123,6 +124,84 @@ 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:
|
||||||
|
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({
|
export const boardRouter = createTRPCRouter({
|
||||||
exists: permissionRequiredProcedure
|
exists: permissionRequiredProcedure
|
||||||
.requiresPermission("board-create")
|
.requiresPermission("board-create")
|
||||||
@@ -482,80 +561,10 @@ export const boardRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
catalog: publicProcedure.query(async ({ ctx }) => searchAccessibleBoardsAsync(ctx, "")),
|
||||||
search: publicProcedure
|
search: publicProcedure
|
||||||
.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 }) => {
|
.query(async ({ ctx, input }) => searchAccessibleBoardsAsync(ctx, input.query, input.limit)),
|
||||||
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,6 +130,21 @@ export const searchEngineRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return null;
|
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 }) => {
|
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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Center, Loader } from "@mantine/core";
|
import { Center } from "@mantine/core";
|
||||||
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -61,11 +61,7 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.isLoading) {
|
if (options.isLoading) {
|
||||||
return (
|
return <span hidden data-search-pending />;
|
||||||
<Center w="100%" py="sm" data-search-loading>
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.isError) {
|
if (options.isError) {
|
||||||
|
|||||||
@@ -70,12 +70,8 @@
|
|||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionsList [data-search-loading] ~ [data-search-loading] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionsList:has([data-action]) .emptyState,
|
.actionsList:has([data-action]) .emptyState,
|
||||||
.actionsList:has([data-search-loading]) .emptyState,
|
.actionsList:has([data-search-pending]) .emptyState,
|
||||||
.actionsList:has([data-search-status]) .emptyState {
|
.actionsList:has([data-search-status]) .emptyState {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import type { OpenMediaRequestSearchOptions, SpotlightMode, SpotlightOpenIntent } from "../open";
|
import type { OpenMediaRequestSearchOptions, SpotlightMode, SpotlightOpenIntent } 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 { 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";
|
||||||
@@ -36,6 +37,8 @@ const resetSelection = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Spotlight = () => {
|
export const Spotlight = () => {
|
||||||
|
useSpotlightCatalogs();
|
||||||
|
|
||||||
const [mode, setMode] = useState<SpotlightMode>(defaultMode);
|
const [mode, setMode] = useState<SpotlightMode>(defaultMode);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
|
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 { 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 { useRemoteQuery } from "../../lib/remote-query";
|
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
|
||||||
@@ -96,7 +96,16 @@ export const appsSearchGroup = createGroup<App>({
|
|||||||
),
|
),
|
||||||
useInteraction: interaction.children(appChildrenOptions),
|
useInteraction: interaction.children(appChildrenOptions),
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const remoteQuery = useRemoteQuery(query, "apps");
|
const catalogQuery = useAppsCatalogQuery();
|
||||||
return clientApi.app.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
|
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 { 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 { 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>.
|
// 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
|
||||||
@@ -141,7 +141,11 @@ export const boardsSearchGroup = createGroup<Board>({
|
|||||||
),
|
),
|
||||||
useInteraction: interaction.children(boardChildrenOptions),
|
useInteraction: interaction.children(boardChildrenOptions),
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const remoteQuery = useRemoteQuery(query, "boards");
|
const catalogQuery = useBoardsCatalogQuery();
|
||||||
return clientApi.board.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
|
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 { SearchGroup } from "../../lib/group";
|
||||||
import type { SearchMode } from "../../lib/mode";
|
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 { 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";
|
||||||
@@ -14,7 +14,7 @@ export const appIntegrationBoardMode = {
|
|||||||
placeholder: (t) => t("search.modePicker.apps.placeholder"),
|
placeholder: (t) => t("search.modePicker.apps.placeholder"),
|
||||||
useGroups() {
|
useGroups() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const groups: SearchGroup[] = [contextSpecificSearchGroups];
|
const groups: SearchGroup[] = [contextSpecificAppsSearchGroup];
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return groups.concat(boardsSearchGroup);
|
return groups.concat(boardsSearchGroup);
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
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";
|
||||||
import { useRemoteQuery } from "../../lib/remote-query";
|
|
||||||
|
|
||||||
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
|
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
@@ -21,10 +20,11 @@ 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 remoteQuery = useRemoteQuery(query, "integrations");
|
const catalogQuery = useIntegrationsCatalogQuery();
|
||||||
return clientApi.integration.search.useQuery(
|
return {
|
||||||
{ query: remoteQuery.query, limit: 8 },
|
data: filterCatalog(catalogQuery.data ?? [], query, (integration) => [integration.name, integration.kind]),
|
||||||
{ enabled: remoteQuery.enabled },
|
isLoading: catalogQuery.isLoading,
|
||||||
);
|
isError: catalogQuery.isError,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -11,12 +11,13 @@ 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";
|
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 FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
|
||||||
type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number];
|
type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number];
|
||||||
|
|
||||||
@@ -287,7 +288,7 @@ const parseBangQuery = (query: string) => {
|
|||||||
return { bangToken, searchText, locked };
|
return { bangToken, searchText, locked };
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildSearchUrl = (template: string, query: string) => {
|
export 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);
|
||||||
@@ -416,23 +417,28 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
|
|||||||
const { bangToken, searchText, locked } = parseBangQuery(query);
|
const { bangToken, searchText, locked } = parseBangQuery(query);
|
||||||
const remoteQuery = useRemoteQuery(bangToken, "search-engines", { minimumLength });
|
const remoteQuery = useRemoteQuery(bangToken, "search-engines", { minimumLength });
|
||||||
const ddgQueryEnabled = remoteQuery.enabled && ddgBangs && remoteQuery.query.length >= 1;
|
const ddgQueryEnabled = remoteQuery.enabled && ddgBangs && remoteQuery.query.length >= 1;
|
||||||
|
const enginesQuery = useSearchEnginesCatalogQuery();
|
||||||
const enginesQuery = clientApi.searchEngine.search.useQuery(
|
|
||||||
{ query: remoteQuery.query, limit: 10 },
|
|
||||||
{ enabled: remoteQuery.enabled },
|
|
||||||
);
|
|
||||||
|
|
||||||
const ddgQuery = clientApi.bangs.search.useQuery(
|
const ddgQuery = clientApi.bangs.search.useQuery(
|
||||||
{ query: remoteQuery.query, limit: 10 },
|
{ query: remoteQuery.query, limit: 10 },
|
||||||
{
|
{
|
||||||
enabled: ddgQueryEnabled,
|
enabled: ddgQueryEnabled,
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLoading = (enginesQuery.isLoading && remoteQuery.enabled) || (ddgQuery.isLoading && ddgQueryEnabled);
|
const engineCatalog = enginesQuery.data ?? [];
|
||||||
const isError = (enginesQuery.isError && remoteQuery.enabled) || (ddgQuery.isError && ddgQueryEnabled);
|
const engineData = filterCatalog(engineCatalog, bangToken, (engine) => [engine.short, engine.name], 10);
|
||||||
const engineData = remoteQuery.enabled ? (enginesQuery.data ?? []) : [];
|
const ddgData = ddgQueryEnabled && bangToken.length > 0
|
||||||
const ddgData = ddgQueryEnabled ? (ddgQuery.data ?? []) : [];
|
? (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(
|
const engineOptions = engineData.map(
|
||||||
(engine): ExternalOption => ({
|
(engine): ExternalOption => ({
|
||||||
@@ -454,7 +460,7 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
|
|||||||
|
|
||||||
const searchActions: ExternalOption[] = [];
|
const searchActions: ExternalOption[] = [];
|
||||||
if (locked && bangToken.length > 0) {
|
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 matchedDdg = ddgData.find((bang) => bang.t === bangToken);
|
||||||
|
|
||||||
const genericEngine = matchedEngine?.type === "generic" ? matchedEngine : undefined;
|
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({
|
searchActions.push({
|
||||||
key: "hint",
|
key: "hint",
|
||||||
kind: "hint",
|
kind: "hint",
|
||||||
@@ -509,9 +515,3 @@ export const searchEnginesSearchGroups = createSearchEnginesSearchGroup({
|
|||||||
showEmptyHint: true,
|
showEmptyHint: true,
|
||||||
source: "focused",
|
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>({
|
export const contextSpecificFallbackSearchGroup = createGroup<ContextSpecificItem>({
|
||||||
title: (t) => t("search.modePicker.continueWith"),
|
title: (t) => t("search.modePicker.continueWith"),
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type ContextSpecificItem = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
unavailable?: boolean;
|
unavailable?: boolean;
|
||||||
alwaysVisible?: boolean;
|
alwaysVisible?: boolean;
|
||||||
|
dedupeKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SpotlightContextProps {
|
interface SpotlightContextProps {
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
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 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 { useRemoteQuery } from "../../lib/remote-query";
|
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;
|
||||||
@@ -22,7 +23,7 @@ type GroupItem = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
icon: TablerIcon | string;
|
icon: TablerIcon | string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
interaction: inferSearchInteractionDefinition<SearchInteraction>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const homeSearchEngineGroup = createGroup<GroupItem>({
|
export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||||
@@ -44,127 +45,95 @@ 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, query) {
|
useInteraction(item) {
|
||||||
return item.useInteraction(query);
|
return item.interaction;
|
||||||
},
|
|
||||||
filter() {
|
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { data: session, status } = useSession();
|
const { data: session } = useSession();
|
||||||
const remoteQuery = useRemoteQuery(query, "integration-search");
|
const { openSearchInNewTab } = useSettings();
|
||||||
const { data: defaultSearchEngine, ...defaultSearchEngineQuery } =
|
const catalogQuery = useSearchEnginesCatalogQuery();
|
||||||
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),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading:
|
isLoading: catalogQuery.isLoading,
|
||||||
defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading",
|
isError: catalogQuery.isError,
|
||||||
isError:
|
data: createSearchEntries(
|
||||||
(defaultSearchEngineQuery.isError && status !== "loading" && remoteQuery.enabled) ||
|
catalogQuery.data ?? [],
|
||||||
(resultQuery.isError && fromIntegrationEnabled),
|
session?.user.permissions.includes("search-engine-create") === true,
|
||||||
data: remoteQuery.enabled ? createDefaultSearchEntries(defaultSearchEngine, results, session, query, t) : [],
|
query,
|
||||||
|
openSearchInNewTab,
|
||||||
|
t,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createDefaultSearchEntries = (
|
const createSearchEntries = (
|
||||||
defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null,
|
searchEngines: SearchEngine[],
|
||||||
results: RouterOutputs["integration"]["searchInIntegration"] | undefined,
|
canCreateSearchEngine: boolean,
|
||||||
session: Session | null,
|
|
||||||
query: string,
|
query: string,
|
||||||
|
openInNewTab: boolean,
|
||||||
t: TranslationFunction,
|
t: TranslationFunction,
|
||||||
): GroupItem[] => {
|
): GroupItem[] => {
|
||||||
if (!session?.user && !defaultSearchEngine) return [];
|
if (searchEngines.length === 0) {
|
||||||
|
|
||||||
if (!defaultSearchEngine) {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "no-default",
|
id: "no-search-engines",
|
||||||
name: t("search.mode.home.group.search.option.no-default.label"),
|
name: t("search.mode.home.group.search.option.no-engines.label"),
|
||||||
description: t("search.mode.home.group.search.option.no-default.description"),
|
description: canCreateSearchEngine
|
||||||
|
? t("search.mode.home.group.search.option.no-engines.descriptionCreate")
|
||||||
|
: t("search.mode.home.group.search.option.no-engines.descriptionUnavailable"),
|
||||||
icon: IconSearchOff,
|
icon: IconSearchOff,
|
||||||
useInteraction() {
|
disabled: !canCreateSearchEngine,
|
||||||
return {
|
interaction: canCreateSearchEngine ? { type: "link", href: "/manage/search-engines/new" } : { type: "none" },
|
||||||
type: "link",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
href: `/manage/users/${session!.user.id}/general`,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultSearchEngine.type === "generic") {
|
const normalizedQuery = query.trim();
|
||||||
return [
|
return searchEngines.map((searchEngine) => {
|
||||||
{
|
const name =
|
||||||
id: "search",
|
normalizedQuery.length === 0
|
||||||
name: t("search.mode.home.group.search.option.search.label", {
|
? searchEngine.name
|
||||||
query,
|
: t("search.mode.home.group.search.option.search.label", {
|
||||||
name: defaultSearchEngine.name,
|
query: normalizedQuery,
|
||||||
}),
|
name: searchEngine.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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!results) {
|
if (searchEngine.type === "generic") {
|
||||||
return [
|
return {
|
||||||
{
|
id: `search-${searchEngine.id}`,
|
||||||
id: "from-integration",
|
name,
|
||||||
name: defaultSearchEngine.name,
|
description: searchEngine.description ?? undefined,
|
||||||
icon: defaultSearchEngine.iconUrl,
|
icon: searchEngine.iconUrl,
|
||||||
description: t("search.mode.home.group.search.option.from-integration.description"),
|
interaction: {
|
||||||
useInteraction() {
|
type: "link",
|
||||||
return {
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
type: "none",
|
href: buildSearchUrl(searchEngine.urlTemplate!, normalizedQuery),
|
||||||
};
|
newTab: openInNewTab,
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
];
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return results.map((result) => ({
|
return {
|
||||||
id: `search-${result.id}`,
|
id: `search-${searchEngine.id}`,
|
||||||
name: result.name,
|
name,
|
||||||
description: result.text,
|
description: searchEngine.description ?? undefined,
|
||||||
icon: result.image ?? IconSearch,
|
icon: searchEngine.iconUrl,
|
||||||
useInteraction() {
|
interaction: {
|
||||||
return useFromIntegrationSearchInteraction(defaultSearchEngine, result);
|
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 { appIntegrationBoardMode } from "../app-integration-board";
|
||||||
import { contextSpecificActionsSearchGroups } from "../command/context-specific-group";
|
import { contextSpecificActionsSearchGroups } from "../command/context-specific-group";
|
||||||
import { globalCommandGroup } from "../command/global-group";
|
import { globalCommandGroup } from "../command/global-group";
|
||||||
import { globalSearchEnginesSearchGroup } from "../external/search-engines-search-group";
|
|
||||||
import { homeSearchEngineGroup } from "./home-search-engine-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 { mediaFallbackGroup } from "../media/media-fallback-group";
|
||||||
import { pagesSearchGroup } from "../page/pages-search-group";
|
import { pagesSearchGroup } from "../page/pages-search-group";
|
||||||
import { preferencesGroup } from "../preferences/groups";
|
import { preferencesGroup } from "../preferences/groups";
|
||||||
@@ -16,7 +19,7 @@ export const homeMode = {
|
|||||||
label: (t) => t("search.modePicker.search.label"),
|
label: (t) => t("search.modePicker.search.label"),
|
||||||
placeholder: (t) => t("search.modePicker.search.placeholder"),
|
placeholder: (t) => t("search.modePicker.search.placeholder"),
|
||||||
useGroups() {
|
useGroups() {
|
||||||
const appGroups = appIntegrationBoardMode.useGroups().filter((group) => group !== contextSpecificSearchGroups);
|
const appGroups = appIntegrationBoardMode.useGroups().filter((group) => group !== contextSpecificAppsSearchGroup);
|
||||||
const directoryGroups = userGroupMode.useGroups();
|
const directoryGroups = userGroupMode.useGroups();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -27,7 +30,6 @@ export const homeMode = {
|
|||||||
preferencesGroup,
|
preferencesGroup,
|
||||||
...appGroups,
|
...appGroups,
|
||||||
...directoryGroups,
|
...directoryGroups,
|
||||||
globalSearchEnginesSearchGroup,
|
|
||||||
mediaFallbackGroup,
|
mediaFallbackGroup,
|
||||||
homeSearchEngineGroup,
|
homeSearchEngineGroup,
|
||||||
contextSpecificFallbackSearchGroup,
|
contextSpecificFallbackSearchGroup,
|
||||||
|
|||||||
@@ -7126,9 +7126,10 @@
|
|||||||
"other": {
|
"other": {
|
||||||
"label": "Search with another search engine"
|
"label": "Search with another search engine"
|
||||||
},
|
},
|
||||||
"no-default": {
|
"no-engines": {
|
||||||
"label": "No default search engine",
|
"label": "No search engines configured",
|
||||||
"description": "Set a default search engine in preferences"
|
"descriptionCreate": "Add a search engine to search from the command menu",
|
||||||
|
"descriptionUnavailable": "Ask an administrator to configure a search engine"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"label": "Search for \"{query}\" with {name}"
|
"label": "Search for \"{query}\" with {name}"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ 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() {
|
||||||
@@ -148,10 +149,7 @@ export default function BookmarksWidget({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea
|
<ScrollArea scrollbars={plan.horizontalScroll ? "x" : "y"} style={{ flex: 1, minHeight: 0 }}>
|
||||||
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