feat(spotlight): add default search engine (#1807)

This commit is contained in:
Meier Lukas
2025-01-06 19:59:40 +01:00
committed by GitHub
parent 6a68ccfee4
commit 65befa22ba
24 changed files with 3849 additions and 88 deletions

View File

@@ -45,7 +45,9 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
<Spotlight.Action
renderRoot={renderRoot}
onClick={handleClickAsync}
closeSpotlightOnTrigger={interaction.type !== "mode" && interaction.type !== "children"}
closeSpotlightOnTrigger={
interaction.type !== "mode" && interaction.type !== "children" && interaction.type !== "none"
}
className={classes.spotlightAction}
>
<group.Component {...option} />

View File

@@ -1,10 +1,10 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconSearch, IconX } from "@tabler/icons-react";
import { IconQuestionMark, IconSearch, IconX } from "@tabler/icons-react";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
@@ -12,53 +12,32 @@ import { useI18n } from "@homarr/translation/client";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes";
import { useSpotlightContextResults } from "../modes/home/context";
import { selectAction, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
type SearchModeKey = keyof TranslationObject["search"]["mode"];
const defaultMode = "home";
export const Spotlight = () => {
const items = useSpotlightContextResults();
// We fallback to help if no context results are available
const defaultMode = items.length >= 1 ? "home" : "help";
const searchModeState = useState<SearchModeKey>(defaultMode);
const mode = searchModeState[0];
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
/**
* The below logic is used to switch to home page if any context results are registered
* or to help page if context results are unregistered
*/
const previousLengthRef = useRef(items.length);
useEffect(() => {
if (items.length >= 1 && previousLengthRef.current === 0) {
searchModeState[1]("home");
} else if (items.length === 0 && previousLengthRef.current >= 1) {
searchModeState[1]("help");
}
previousLengthRef.current = items.length;
}, [items.length, searchModeState]);
if (!activeMode) {
return null;
}
// We use the "key" below to prevent the 'Different amounts of hooks' error
return (
<SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} defaultMode={defaultMode} />
);
return <SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} />;
};
interface SpotlightWithActiveModeProps {
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
activeMode: SearchMode;
defaultMode: SearchModeKey;
}
const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: SpotlightWithActiveModeProps) => {
const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = useState("");
const [mode, setMode] = modeState;
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
@@ -77,7 +56,7 @@ const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: Spotlig
}}
query={query}
onQueryChange={(query) => {
if ((mode !== "help" && mode !== "home") || query.length !== 1) {
if (mode !== "help" || query.length !== 1) {
setQuery(query);
}
@@ -110,7 +89,17 @@ const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: Spotlig
},
}}
rightSection={
mode === defaultMode ? undefined : (
mode === defaultMode ? (
<ActionIcon
onClick={() => {
setMode("help");
inputRef.current?.focus();
}}
variant="subtle"
>
<IconQuestionMark stroke={1.5} />
</ActionIcon>
) : (
<ActionIcon
onClick={() => {
setMode(defaultMode);

View File

@@ -1,5 +1,4 @@
import type { JSX } from "react";
import type { UseTRPCQueryResult } from "@trpc/react-query/shared";
import type { stringOrTranslation } from "@homarr/translation";
@@ -29,9 +28,12 @@ export type SearchGroup<TOption extends Record<string, unknown> = any> =
{
filter: (query: string, option: TOption) => boolean;
sort?: (query: string, options: [TOption, TOption]) => number;
useOptions: () => TOption[];
useOptions: (query: string) => TOption[];
}
>
| CommonSearchGroup<TOption, { useQueryOptions: (query: string) => UseTRPCQueryResult<TOption[], unknown> }>;
| CommonSearchGroup<
TOption,
{ useQueryOptions: (query: string) => { data: TOption[] | undefined; isLoading: boolean; isError: boolean } }
>;
export const createGroup = <TOption extends Record<string, unknown>>(group: SearchGroup<TOption>) => group;

View File

@@ -4,7 +4,7 @@ import type { TranslationObject } from "@homarr/translation";
import type { CreateChildrenOptionsProps } from "./children";
const createSearchInteraction = <TType extends string>(type: TType) => ({
optionsType: <TOption extends Record<string, unknown>>() => ({ type, _inferOptions: {} as TOption }),
optionsType: <TOption extends Record<string, unknown> | undefined>() => ({ type, _inferOptions: {} as TOption }),
});
// This is used to define search interactions with their options
@@ -20,20 +20,23 @@ const searchInteractions = [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
option: any;
}>(),
createSearchInteraction("none").optionsType<never>(),
] as const;
// Union of all search interactions types
export type SearchInteraction = (typeof searchInteractions)[number]["type"];
// Infer the options for the specified search interaction
export type inferSearchInteractionOptions<TInteraction extends SearchInteraction> = Extract<
(typeof searchInteractions)[number],
{ type: TInteraction }
>["_inferOptions"];
export type inferSearchInteractionOptions<TInteraction extends SearchInteraction> = Exclude<
Extract<(typeof searchInteractions)[number], { type: TInteraction }>["_inferOptions"],
undefined
>;
// Infer the search interaction definition (type + options) for the specified search interaction
export type inferSearchInteractionDefinition<TInteraction extends SearchInteraction> = {
[interactionKey in TInteraction]: { type: interactionKey } & inferSearchInteractionOptions<interactionKey>;
[interactionKey in TInteraction]: inferSearchInteractionOptions<interactionKey> extends never
? { type: interactionKey }
: { type: interactionKey } & inferSearchInteractionOptions<interactionKey>;
}[TInteraction];
// Type used for helper functions to define basic search interactions

View File

@@ -11,9 +11,11 @@ import { useScopedI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition } from "../../lib/interaction";
import { interaction } from "../../lib/interaction";
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type MediaRequestChildrenProps = {
@@ -33,6 +35,52 @@ type MediaRequestChildrenProps = {
};
};
export const useFromIntegrationSearchInteraction = (
searchEngine: SearchEngine,
searchResult: FromIntegrationSearchResult,
): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => {
if (searchEngine.type !== "fromIntegration") {
throw new Error("Invalid search engine type");
}
if (!searchEngine.integration) {
throw new Error("Invalid search engine integration");
}
if (
getIntegrationKindsByCategory("mediaRequest").some(
(categoryKind) => categoryKind === searchEngine.integration?.kind,
) &&
"type" in searchResult
) {
const type = searchResult.type;
if (type === "person") {
return {
type: "link",
href: searchResult.link,
newTab: true,
};
}
return {
type: "children",
...mediaRequestsChildrenOptions({
result: {
...searchResult,
type,
},
integration: searchEngine.integration,
}),
};
}
return {
type: "link",
href: searchResult.link,
newTab: true,
};
};
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
useActions() {
const { openModal } = useModalAction(RequestMediaModal);
@@ -162,47 +210,8 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
</Group>
);
},
useInteraction(searchEngine) {
if (searchEngine.type !== "fromIntegration") {
throw new Error("Invalid search engine type");
}
if (!searchEngine.integration) {
throw new Error("Invalid search engine integration");
}
if (
getIntegrationKindsByCategory("mediaRequest").some(
(categoryKind) => categoryKind === searchEngine.integration?.kind,
) &&
"type" in searchResult
) {
const type = searchResult.type;
if (type === "person") {
return {
type: "link",
href: searchResult.link,
newTab: true,
};
}
return {
type: "children",
...mediaRequestsChildrenOptions({
result: {
...searchResult,
type,
},
integration: searchEngine.integration,
}),
};
}
return {
type: "link",
href: searchResult.link,
newTab: true,
};
useInteraction() {
return useFromIntegrationSearchInteraction(searchEngine, searchResult);
},
}));
},

View File

@@ -0,0 +1,173 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
import { IconCaretUpDown, IconSearch, 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 type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { useFromIntegrationSearchInteraction } from "../external/search-engines-search-group";
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type GroupItem = {
id: string;
name: string;
description?: string;
icon: TablerIcon | string;
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
};
export const homeSearchEngineGroup = createGroup<GroupItem>({
title: (t) => t("search.mode.home.group.search.title"),
keyPath: "id",
Component(item) {
const icon =
typeof item.icon !== "string" ? (
<item.icon size={24} />
) : (
<Box w={24} h={24}>
<img src={item.icon} alt={item.name} style={{ maxWidth: 24 }} />
</Box>
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{icon}
<Stack gap={0}>
<Text>{item.name}</Text>
{item.description && (
<Text c="gray.6" size="sm">
{item.description}
</Text>
)}
</Stack>
</Group>
);
},
useInteraction(item, query) {
return item.useInteraction(query);
},
filter() {
return true;
},
useQueryOptions(query) {
const t = useI18n();
const { data: session, status } = useSession();
const { data: defaultSearchEngine, ...defaultSearchEngineQuery } =
clientApi.searchEngine.getDefaultSearchEngine.useQuery(undefined, {
enabled: status !== "loading",
});
const fromIntegrationEnabled = defaultSearchEngine?.type === "fromIntegration" && query.length > 0;
const { data: results, ...resultQuery } = clientApi.integration.searchInIntegration.useQuery(
{
query,
integrationId: defaultSearchEngine?.integrationId ?? "",
},
{
enabled: fromIntegrationEnabled,
select: (data) => data.slice(0, 5),
},
);
return {
isLoading:
defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading",
isError: defaultSearchEngineQuery.isError || (resultQuery.isError && fromIntegrationEnabled),
data: [
...createDefaultSearchEntries(defaultSearchEngine, results, session, query, t),
{
id: "other",
name: t("search.mode.home.group.search.option.other.label"),
icon: IconCaretUpDown,
useInteraction() {
return {
type: "mode",
mode: "external",
};
},
},
],
};
},
});
const createDefaultSearchEntries = (
defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null,
results: RouterOutputs["integration"]["searchInIntegration"] | undefined,
session: Session | null,
query: string,
t: TranslationFunction,
): GroupItem[] => {
if (!session?.user && !defaultSearchEngine) {
return [];
}
if (!defaultSearchEngine) {
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"),
icon: IconSearchOff,
useInteraction() {
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: `/manage/users/${session!.user.id}/general`,
};
},
},
];
}
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) {
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: defaultSearchEngine.urlTemplate!.replace("%s", query),
};
},
},
];
}
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);
},
}));
};

View File

@@ -1,8 +1,9 @@
import type { SearchMode } from "../../lib/mode";
import { contextSpecificSearchGroups } from "./context-specific-group";
import { homeSearchEngineGroup } from "./home-search-engine-group";
export const homeMode = {
character: undefined,
modeKey: "home",
groups: [contextSpecificSearchGroups],
groups: [homeSearchEngineGroup, contextSpecificSearchGroups],
} satisfies SearchMode;