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

@@ -0,0 +1,29 @@
"use client";
import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return (
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
{(form) => (
<>
<Select
label={tSearch("defaultSearchEngine.label")}
description={tSearch("defaultSearchEngine.description")}
data={selectableSearchEngines}
{...form.getInputProps("defaultSearchEngineId")}
/>
</>
)}
</CommonSettingsForm>
);
};

View File

@@ -11,6 +11,7 @@ import { AnalyticsSettings } from "./_components/analytics.settings";
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form";
export async function generateMetadata() {
const t = await getScopedI18n("management");
@@ -41,6 +42,10 @@ export default async function SettingsPage() {
<Title order={2}>{tSettings("section.board.title")}</Title>
<BoardSettingsForm defaultValues={serverSettings.board} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.search.title")}</Title>
<SearchSettingsForm defaultValues={serverSettings.search} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.appearance.title")}</Title>
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />

View File

@@ -0,0 +1,67 @@
"use client";
import { Button, Group, Select, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface ChangeDefaultSearchEngineFormProps {
user: RouterOutputs["user"]["getById"];
searchEnginesData: { value: string; label: string }[];
}
export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: ChangeDefaultSearchEngineFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeDefaultSearchEngine.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
defaultSearchEngineId: variables.defaultSearchEngineId,
});
showSuccessNotification({
message: t("user.action.changeDefaultSearchEngine.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeDefaultSearchEngine.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.changeDefaultSearchEngine, {
initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId ?? "",
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select w="100%" data={searchEnginesData} {...form.getInputProps("defaultSearchEngineId")} />
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof validation.user.changeDefaultSearchEngine>;

View File

@@ -11,6 +11,7 @@ import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { ChangeDefaultSearchEngineForm } from "./_components/_change-default-search-engine";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
@@ -60,6 +61,7 @@ export default async function EditUserPage(props: Props) {
}
const boards = await api.board.getAllBoards();
const searchEngines = await api.searchEngine.getSelectable();
const isCredentialsUser = user.provider === "credentials";
@@ -97,6 +99,11 @@ export default async function EditUserPage(props: Props) {
/>
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.defaultSearchEngine")}</Title>
<ChangeDefaultSearchEngineForm user={user} searchEnginesData={searchEngines} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
<FirstDayOfWeek user={user} />

View File

@@ -1,12 +1,13 @@
import { TRPCError } from "@trpc/server";
import { createId, eq, like, sql } from "@homarr/db";
import { searchEngines } from "@homarr/db/schema";
import { asc, createId, eq, like, sql } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
import { integrationCreator } from "@homarr/integrations";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
export const searchEngineRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
@@ -29,6 +30,21 @@ export const searchEngineRouter = createTRPCRouter({
totalCount: searchEngineCount[0]?.count ?? 0,
};
}),
getSelectable: protectedProcedure
.input(z.object({ withIntegrations: z.boolean() }).default({ withIntegrations: true }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines
.findMany({
orderBy: asc(searchEngines.name),
where: input.withIntegrations ? undefined : eq(searchEngines.type, "generic"),
columns: {
id: true,
name: true,
},
})
.then((engines) => engines.map((engine) => ({ value: engine.id, label: engine.name })));
}),
byId: protectedProcedure.input(validation.common.byId).query(async ({ ctx, input }) => {
const searchEngine = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, input.id),
@@ -55,6 +71,54 @@ export const searchEngineRouter = createTRPCRouter({
urlTemplate: searchEngine.urlTemplate!,
};
}),
getDefaultSearchEngine: publicProcedure.query(async ({ ctx }) => {
const userDefaultId = ctx.session?.user.id
? ((await ctx.db.query.users
.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
defaultSearchEngineId: true,
},
})
.then((user) => user?.defaultSearchEngineId)) ?? null)
: null;
if (userDefaultId) {
return await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, userDefaultId),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
}
const serverDefaultId = await getServerSettingByKeyAsync(ctx.db, "search").then(
(setting) => setting.defaultSearchEngineId,
);
if (serverDefaultId) {
return await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, serverDefaultId),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
}
return null;
}),
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),

View File

@@ -211,6 +211,7 @@ export const userRouter = createTRPCRouter({
homeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
}),
)
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
@@ -233,6 +234,7 @@ export const userRouter = createTRPCRouter({
homeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
},
where: eq(users.id, input.userId),
});
@@ -406,6 +408,43 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
changeDefaultSearchEngine: protectedProcedure
.input(
convertIntersectionToZodObject(validation.user.changeDefaultSearchEngine.and(z.object({ userId: z.string() }))),
)
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeSearchEngine", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
const user = ctx.session.user;
// Only admins can change other users passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
defaultSearchEngineId: input.defaultSearchEngineId,
})
.where(eq(users.id, input.userId));
}),
changeColorScheme: protectedProcedure
.input(validation.user.changeColorScheme)
.output(z.void())

View File

@@ -0,0 +1,2 @@
ALTER TABLE `user` ADD `default_search_engine_id` varchar(64);--> statement-breakpoint
ALTER TABLE `user` ADD CONSTRAINT `user_default_search_engine_id_search_engine_id_fk` FOREIGN KEY (`default_search_engine_id`) REFERENCES `search_engine`(`id`) ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -134,6 +134,13 @@
"when": 1735593853768,
"tag": "0018_mighty_shaman",
"breakpoints": true
},
{
"idx": 19,
"version": "5",
"when": 1735651231818,
"tag": "0019_crazy_marvel_zombies",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `default_search_engine_id` text REFERENCES search_engine(id);

File diff suppressed because it is too large Load Diff

View File

@@ -134,6 +134,13 @@
"when": 1735593831501,
"tag": "0018_cheerful_tattoo",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1735651175378,
"tag": "0019_steady_darkhawk",
"breakpoints": true
}
]
}

View File

@@ -62,6 +62,9 @@ export const users = mysqlTable("user", {
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
defaultSearchEngineId: varchar({ length: 64 }).references(() => searchEngines.id, {
onDelete: "set null",
}),
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
@@ -409,13 +412,17 @@ export const accountRelations = relations(accounts, ({ one }) => ({
}),
}));
export const userRelations = relations(users, ({ many }) => ({
export const userRelations = relations(users, ({ one, many }) => ({
accounts: many(accounts),
boards: many(boards),
boardPermissions: many(boardUserPermissions),
groups: many(groupMembers),
ownedGroups: many(groups),
invites: many(invites),
defaultSearchEngine: one(searchEngines, {
fields: [users.defaultSearchEngineId],
references: [searchEngines.id],
}),
}));
export const mediaRelations = relations(medias, ({ one }) => ({
@@ -573,9 +580,10 @@ export const integrationItemRelations = relations(integrationItems, ({ one }) =>
}),
}));
export const searchEngineRelations = relations(searchEngines, ({ one }) => ({
export const searchEngineRelations = relations(searchEngines, ({ one, many }) => ({
integration: one(integrations, {
fields: [searchEngines.integrationId],
references: [integrations.id],
}),
usersWithDefault: many(users),
}));

View File

@@ -45,6 +45,9 @@ export const users = sqliteTable("user", {
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
defaultSearchEngineId: text().references(() => searchEngines.id, {
onDelete: "set null",
}),
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
@@ -395,7 +398,7 @@ export const accountRelations = relations(accounts, ({ one }) => ({
}),
}));
export const userRelations = relations(users, ({ many }) => ({
export const userRelations = relations(users, ({ one, many }) => ({
accounts: many(accounts),
boards: many(boards),
boardPermissions: many(boardUserPermissions),
@@ -403,6 +406,10 @@ export const userRelations = relations(users, ({ many }) => ({
ownedGroups: many(groups),
invites: many(invites),
medias: many(medias),
defaultSearchEngine: one(searchEngines, {
fields: [users.defaultSearchEngineId],
references: [searchEngines.id],
}),
}));
export const mediaRelations = relations(medias, ({ one }) => ({
@@ -560,9 +567,10 @@ export const integrationItemRelations = relations(integrationItems, ({ one }) =>
}),
}));
export const searchEngineRelations = relations(searchEngines, ({ one }) => ({
export const searchEngineRelations = relations(searchEngines, ({ one, many }) => ({
integration: one(integrations, {
fields: [searchEngines.integrationId],
references: [integrations.id],
}),
usersWithDefault: many(users),
}));

View File

@@ -7,6 +7,7 @@ export const defaultServerSettingsKeys = [
"board",
"appearance",
"culture",
"search",
] as const;
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
@@ -33,6 +34,9 @@ export const defaultServerSettings = {
culture: {
defaultLocale: "en" as SupportedLanguage,
},
search: {
defaultSearchEngineId: null as string | null,
},
} satisfies ServerSettingsRecord;
export type ServerSettings = typeof defaultServerSettings;

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;

View File

@@ -210,6 +210,16 @@
}
}
},
"changeDefaultSearchEngine": {
"notification": {
"success": {
"message": "Default search engine changed successfully"
},
"error": {
"message": "Unable to change default search engine"
}
}
},
"changeFirstDayOfWeek": {
"notification": {
"success": {
@@ -2177,6 +2187,7 @@
"item": {
"language": "Language & Region",
"board": "Home board",
"defaultSearchEngine": "Default search engine",
"firstDayOfWeek": "First day of the week",
"accessibility": "Accessibility"
}
@@ -2338,6 +2349,13 @@
"description": "Only public boards are available for selection"
}
},
"search": {
"title": "Search",
"defaultSearchEngine": {
"label": "Global default search engine",
"description": "Integration search engines can not be selected here"
}
},
"appearance": {
"title": "Appearance",
"defaultColorScheme": {
@@ -2853,6 +2871,24 @@
},
"home": {
"group": {
"search": {
"title": "Search",
"option": {
"other": {
"label": "Search with another search engine"
},
"no-default": {
"label": "No default search engine",
"description": "Set a default search engine in preferences"
},
"search": {
"label": "Search for '{query}' with {name}"
},
"from-integration": {
"description": "Start typing to search"
}
}
},
"local": {
"title": "Local results"
}

View File

@@ -109,6 +109,10 @@ const changeHomeBoardSchema = z.object({
homeBoardId: z.string().min(1),
});
const changeDefaultSearchEngineSchema = z.object({
defaultSearchEngineId: z.string().min(1),
});
const changeColorSchemeSchema = z.object({
colorScheme: zodEnumFromArray(colorSchemes),
});
@@ -132,6 +136,7 @@ export const userSchemas = {
editProfile: editProfileSchema,
changePassword: changePasswordSchema,
changeHomeBoard: changeHomeBoardSchema,
changeDefaultSearchEngine: changeDefaultSearchEngineSchema,
changePasswordApi: changePasswordApiSchema,
changeColorScheme: changeColorSchemeSchema,
firstDayOfWeek: firstDayOfWeekSchema,