mirror of
https://github.com/ajnart/homarr.git
synced 2026-01-30 11:19:12 +01:00
feat(spotlight): add default search engine (#1807)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>;
|
||||
@@ -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} />
|
||||
|
||||
@@ -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()}%`),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
1684
packages/db/migrations/mysql/meta/0019_snapshot.json
Normal file
1684
packages/db/migrations/mysql/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
packages/db/migrations/sqlite/0019_steady_darkhawk.sql
Normal file
1
packages/db/migrations/sqlite/0019_steady_darkhawk.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `default_search_engine_id` text REFERENCES search_engine(id);
|
||||
1609
packages/db/migrations/sqlite/meta/0019_snapshot.json
Normal file
1609
packages/db/migrations/sqlite/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,13 @@
|
||||
"when": 1735593831501,
|
||||
"tag": "0018_cheerful_tattoo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1735651175378,
|
||||
"tag": "0019_steady_darkhawk",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
173
packages/spotlight/src/modes/home/home-search-engine-group.tsx
Normal file
173
packages/spotlight/src/modes/home/home-search-engine-group.tsx
Normal 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);
|
||||
},
|
||||
}));
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user