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 { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||||
import { BoardSettingsForm } from "./_components/board-settings-form";
|
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||||
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||||
|
import { SearchSettingsForm } from "./_components/search-settings-form";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
@@ -41,6 +42,10 @@ export default async function SettingsPage() {
|
|||||||
<Title order={2}>{tSettings("section.board.title")}</Title>
|
<Title order={2}>{tSettings("section.board.title")}</Title>
|
||||||
<BoardSettingsForm defaultValues={serverSettings.board} />
|
<BoardSettingsForm defaultValues={serverSettings.board} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.search.title")}</Title>
|
||||||
|
<SearchSettingsForm defaultValues={serverSettings.search} />
|
||||||
|
</Stack>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
||||||
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
<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 { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
|
import { ChangeDefaultSearchEngineForm } from "./_components/_change-default-search-engine";
|
||||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
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 boards = await api.board.getAllBoards();
|
||||||
|
const searchEngines = await api.searchEngine.getSelectable();
|
||||||
|
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
|
|
||||||
@@ -97,6 +99,11 @@ export default async function EditUserPage(props: Props) {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.defaultSearchEngine")}</Title>
|
||||||
|
<ChangeDefaultSearchEngineForm user={user} searchEnginesData={searchEngines} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Stack mb="lg">
|
<Stack mb="lg">
|
||||||
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||||
<FirstDayOfWeek user={user} />
|
<FirstDayOfWeek user={user} />
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { createId, eq, like, sql } from "@homarr/db";
|
import { asc, createId, eq, like, sql } from "@homarr/db";
|
||||||
import { searchEngines } from "@homarr/db/schema";
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
|
import { searchEngines, users } from "@homarr/db/schema";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { integrationCreator } from "@homarr/integrations";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const searchEngineRouter = createTRPCRouter({
|
export const searchEngineRouter = createTRPCRouter({
|
||||||
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
|
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
|
||||||
@@ -29,6 +30,21 @@ export const searchEngineRouter = createTRPCRouter({
|
|||||||
totalCount: searchEngineCount[0]?.count ?? 0,
|
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 }) => {
|
byId: protectedProcedure.input(validation.common.byId).query(async ({ ctx, input }) => {
|
||||||
const searchEngine = await ctx.db.query.searchEngines.findFirst({
|
const searchEngine = await ctx.db.query.searchEngines.findFirst({
|
||||||
where: eq(searchEngines.id, input.id),
|
where: eq(searchEngines.id, input.id),
|
||||||
@@ -55,6 +71,54 @@ export const searchEngineRouter = createTRPCRouter({
|
|||||||
urlTemplate: searchEngine.urlTemplate!,
|
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 }) => {
|
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
|
||||||
return await ctx.db.query.searchEngines.findMany({
|
return await ctx.db.query.searchEngines.findMany({
|
||||||
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
|
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
homeBoardId: true,
|
homeBoardId: true,
|
||||||
firstDayOfWeek: true,
|
firstDayOfWeek: true,
|
||||||
pingIconsEnabled: true,
|
pingIconsEnabled: true,
|
||||||
|
defaultSearchEngineId: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
||||||
@@ -233,6 +234,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
homeBoardId: true,
|
homeBoardId: true,
|
||||||
firstDayOfWeek: true,
|
firstDayOfWeek: true,
|
||||||
pingIconsEnabled: true,
|
pingIconsEnabled: true,
|
||||||
|
defaultSearchEngineId: true,
|
||||||
},
|
},
|
||||||
where: eq(users.id, input.userId),
|
where: eq(users.id, input.userId),
|
||||||
});
|
});
|
||||||
@@ -406,6 +408,43 @@ export const userRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, input.userId));
|
.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
|
changeColorScheme: protectedProcedure
|
||||||
.input(validation.user.changeColorScheme)
|
.input(validation.user.changeColorScheme)
|
||||||
.output(z.void())
|
.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,
|
"when": 1735593853768,
|
||||||
"tag": "0018_mighty_shaman",
|
"tag": "0018_mighty_shaman",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1735593831501,
|
||||||
"tag": "0018_cheerful_tattoo",
|
"tag": "0018_cheerful_tattoo",
|
||||||
"breakpoints": true
|
"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, {
|
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
defaultSearchEngineId: varchar({ length: 64 }).references(() => searchEngines.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||||
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||||
pingIconsEnabled: boolean().default(false).notNull(),
|
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),
|
accounts: many(accounts),
|
||||||
boards: many(boards),
|
boards: many(boards),
|
||||||
boardPermissions: many(boardUserPermissions),
|
boardPermissions: many(boardUserPermissions),
|
||||||
groups: many(groupMembers),
|
groups: many(groupMembers),
|
||||||
ownedGroups: many(groups),
|
ownedGroups: many(groups),
|
||||||
invites: many(invites),
|
invites: many(invites),
|
||||||
|
defaultSearchEngine: one(searchEngines, {
|
||||||
|
fields: [users.defaultSearchEngineId],
|
||||||
|
references: [searchEngines.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const mediaRelations = relations(medias, ({ one }) => ({
|
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, {
|
integration: one(integrations, {
|
||||||
fields: [searchEngines.integrationId],
|
fields: [searchEngines.integrationId],
|
||||||
references: [integrations.id],
|
references: [integrations.id],
|
||||||
}),
|
}),
|
||||||
|
usersWithDefault: many(users),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export const users = sqliteTable("user", {
|
|||||||
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
|
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
defaultSearchEngineId: text().references(() => searchEngines.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
|
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
|
||||||
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||||
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
|
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),
|
accounts: many(accounts),
|
||||||
boards: many(boards),
|
boards: many(boards),
|
||||||
boardPermissions: many(boardUserPermissions),
|
boardPermissions: many(boardUserPermissions),
|
||||||
@@ -403,6 +406,10 @@ export const userRelations = relations(users, ({ many }) => ({
|
|||||||
ownedGroups: many(groups),
|
ownedGroups: many(groups),
|
||||||
invites: many(invites),
|
invites: many(invites),
|
||||||
medias: many(medias),
|
medias: many(medias),
|
||||||
|
defaultSearchEngine: one(searchEngines, {
|
||||||
|
fields: [users.defaultSearchEngineId],
|
||||||
|
references: [searchEngines.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const mediaRelations = relations(medias, ({ one }) => ({
|
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, {
|
integration: one(integrations, {
|
||||||
fields: [searchEngines.integrationId],
|
fields: [searchEngines.integrationId],
|
||||||
references: [integrations.id],
|
references: [integrations.id],
|
||||||
}),
|
}),
|
||||||
|
usersWithDefault: many(users),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const defaultServerSettingsKeys = [
|
|||||||
"board",
|
"board",
|
||||||
"appearance",
|
"appearance",
|
||||||
"culture",
|
"culture",
|
||||||
|
"search",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
|
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
|
||||||
@@ -33,6 +34,9 @@ export const defaultServerSettings = {
|
|||||||
culture: {
|
culture: {
|
||||||
defaultLocale: "en" as SupportedLanguage,
|
defaultLocale: "en" as SupportedLanguage,
|
||||||
},
|
},
|
||||||
|
search: {
|
||||||
|
defaultSearchEngineId: null as string | null,
|
||||||
|
},
|
||||||
} satisfies ServerSettingsRecord;
|
} satisfies ServerSettingsRecord;
|
||||||
|
|
||||||
export type ServerSettings = typeof defaultServerSettings;
|
export type ServerSettings = typeof defaultServerSettings;
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
|||||||
<Spotlight.Action
|
<Spotlight.Action
|
||||||
renderRoot={renderRoot}
|
renderRoot={renderRoot}
|
||||||
onClick={handleClickAsync}
|
onClick={handleClickAsync}
|
||||||
closeSpotlightOnTrigger={interaction.type !== "mode" && interaction.type !== "children"}
|
closeSpotlightOnTrigger={
|
||||||
|
interaction.type !== "mode" && interaction.type !== "children" && interaction.type !== "none"
|
||||||
|
}
|
||||||
className={classes.spotlightAction}
|
className={classes.spotlightAction}
|
||||||
>
|
>
|
||||||
<group.Component {...option} />
|
<group.Component {...option} />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
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 { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
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 type { TranslationObject } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
@@ -12,53 +12,32 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
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 { searchModes } from "../modes";
|
import { searchModes } from "../modes";
|
||||||
import { useSpotlightContextResults } from "../modes/home/context";
|
|
||||||
import { selectAction, spotlightStore } from "../spotlight-store";
|
import { selectAction, spotlightStore } from "../spotlight-store";
|
||||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||||
|
|
||||||
type SearchModeKey = keyof TranslationObject["search"]["mode"];
|
type SearchModeKey = keyof TranslationObject["search"]["mode"];
|
||||||
|
|
||||||
|
const defaultMode = "home";
|
||||||
export const Spotlight = () => {
|
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 searchModeState = useState<SearchModeKey>(defaultMode);
|
||||||
const mode = searchModeState[0];
|
const mode = searchModeState[0];
|
||||||
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
|
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) {
|
if (!activeMode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We use the "key" below to prevent the 'Different amounts of hooks' error
|
// We use the "key" below to prevent the 'Different amounts of hooks' error
|
||||||
return (
|
return <SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} />;
|
||||||
<SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} defaultMode={defaultMode} />
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SpotlightWithActiveModeProps {
|
interface SpotlightWithActiveModeProps {
|
||||||
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
|
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
|
||||||
activeMode: SearchMode;
|
activeMode: SearchMode;
|
||||||
defaultMode: SearchModeKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: SpotlightWithActiveModeProps) => {
|
const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [mode, setMode] = modeState;
|
const [mode, setMode] = modeState;
|
||||||
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
||||||
@@ -77,7 +56,7 @@ const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: Spotlig
|
|||||||
}}
|
}}
|
||||||
query={query}
|
query={query}
|
||||||
onQueryChange={(query) => {
|
onQueryChange={(query) => {
|
||||||
if ((mode !== "help" && mode !== "home") || query.length !== 1) {
|
if (mode !== "help" || query.length !== 1) {
|
||||||
setQuery(query);
|
setQuery(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +89,17 @@ const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: Spotlig
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
rightSection={
|
rightSection={
|
||||||
mode === defaultMode ? undefined : (
|
mode === defaultMode ? (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => {
|
||||||
|
setMode("help");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconQuestionMark stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode(defaultMode);
|
setMode(defaultMode);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import type { UseTRPCQueryResult } from "@trpc/react-query/shared";
|
|
||||||
|
|
||||||
import type { stringOrTranslation } from "@homarr/translation";
|
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;
|
filter: (query: string, option: TOption) => boolean;
|
||||||
sort?: (query: string, options: [TOption, TOption]) => number;
|
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;
|
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";
|
import type { CreateChildrenOptionsProps } from "./children";
|
||||||
|
|
||||||
const createSearchInteraction = <TType extends string>(type: TType) => ({
|
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
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
option: any;
|
option: any;
|
||||||
}>(),
|
}>(),
|
||||||
|
createSearchInteraction("none").optionsType<never>(),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Union of all search interactions types
|
// Union of all search interactions types
|
||||||
export type SearchInteraction = (typeof searchInteractions)[number]["type"];
|
export type SearchInteraction = (typeof searchInteractions)[number]["type"];
|
||||||
|
|
||||||
// Infer the options for the specified search interaction
|
// Infer the options for the specified search interaction
|
||||||
export type inferSearchInteractionOptions<TInteraction extends SearchInteraction> = Extract<
|
export type inferSearchInteractionOptions<TInteraction extends SearchInteraction> = Exclude<
|
||||||
(typeof searchInteractions)[number],
|
Extract<(typeof searchInteractions)[number], { type: TInteraction }>["_inferOptions"],
|
||||||
{ type: TInteraction }
|
undefined
|
||||||
>["_inferOptions"];
|
>;
|
||||||
|
|
||||||
// Infer the search interaction definition (type + options) for the specified search interaction
|
// Infer the search interaction definition (type + options) for the specified search interaction
|
||||||
export type inferSearchInteractionDefinition<TInteraction extends SearchInteraction> = {
|
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];
|
}[TInteraction];
|
||||||
|
|
||||||
// Type used for helper functions to define basic search interactions
|
// 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 { createChildrenOptions } from "../../lib/children";
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
|
import type { inferSearchInteractionDefinition } from "../../lib/interaction";
|
||||||
import { interaction } from "../../lib/interaction";
|
import { interaction } from "../../lib/interaction";
|
||||||
|
|
||||||
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
|
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
|
||||||
|
type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
type MediaRequestChildrenProps = {
|
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>({
|
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||||
useActions() {
|
useActions() {
|
||||||
const { openModal } = useModalAction(RequestMediaModal);
|
const { openModal } = useModalAction(RequestMediaModal);
|
||||||
@@ -162,47 +210,8 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
useInteraction(searchEngine) {
|
useInteraction() {
|
||||||
if (searchEngine.type !== "fromIntegration") {
|
return useFromIntegrationSearchInteraction(searchEngine, searchResult);
|
||||||
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,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|||||||
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 type { SearchMode } from "../../lib/mode";
|
||||||
import { contextSpecificSearchGroups } from "./context-specific-group";
|
import { contextSpecificSearchGroups } from "./context-specific-group";
|
||||||
|
import { homeSearchEngineGroup } from "./home-search-engine-group";
|
||||||
|
|
||||||
export const homeMode = {
|
export const homeMode = {
|
||||||
character: undefined,
|
character: undefined,
|
||||||
modeKey: "home",
|
modeKey: "home",
|
||||||
groups: [contextSpecificSearchGroups],
|
groups: [homeSearchEngineGroup, contextSpecificSearchGroups],
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
|
|||||||
@@ -210,6 +210,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"changeDefaultSearchEngine": {
|
||||||
|
"notification": {
|
||||||
|
"success": {
|
||||||
|
"message": "Default search engine changed successfully"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"message": "Unable to change default search engine"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"changeFirstDayOfWeek": {
|
"changeFirstDayOfWeek": {
|
||||||
"notification": {
|
"notification": {
|
||||||
"success": {
|
"success": {
|
||||||
@@ -2177,6 +2187,7 @@
|
|||||||
"item": {
|
"item": {
|
||||||
"language": "Language & Region",
|
"language": "Language & Region",
|
||||||
"board": "Home board",
|
"board": "Home board",
|
||||||
|
"defaultSearchEngine": "Default search engine",
|
||||||
"firstDayOfWeek": "First day of the week",
|
"firstDayOfWeek": "First day of the week",
|
||||||
"accessibility": "Accessibility"
|
"accessibility": "Accessibility"
|
||||||
}
|
}
|
||||||
@@ -2338,6 +2349,13 @@
|
|||||||
"description": "Only public boards are available for selection"
|
"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": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"defaultColorScheme": {
|
"defaultColorScheme": {
|
||||||
@@ -2853,6 +2871,24 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"group": {
|
"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": {
|
"local": {
|
||||||
"title": "Local results"
|
"title": "Local results"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ const changeHomeBoardSchema = z.object({
|
|||||||
homeBoardId: z.string().min(1),
|
homeBoardId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const changeDefaultSearchEngineSchema = z.object({
|
||||||
|
defaultSearchEngineId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
const changeColorSchemeSchema = z.object({
|
const changeColorSchemeSchema = z.object({
|
||||||
colorScheme: zodEnumFromArray(colorSchemes),
|
colorScheme: zodEnumFromArray(colorSchemes),
|
||||||
});
|
});
|
||||||
@@ -132,6 +136,7 @@ export const userSchemas = {
|
|||||||
editProfile: editProfileSchema,
|
editProfile: editProfileSchema,
|
||||||
changePassword: changePasswordSchema,
|
changePassword: changePasswordSchema,
|
||||||
changeHomeBoard: changeHomeBoardSchema,
|
changeHomeBoard: changeHomeBoardSchema,
|
||||||
|
changeDefaultSearchEngine: changeDefaultSearchEngineSchema,
|
||||||
changePasswordApi: changePasswordApiSchema,
|
changePasswordApi: changePasswordApiSchema,
|
||||||
changeColorScheme: changeColorSchemeSchema,
|
changeColorScheme: changeColorSchemeSchema,
|
||||||
firstDayOfWeek: firstDayOfWeekSchema,
|
firstDayOfWeek: firstDayOfWeekSchema,
|
||||||
|
|||||||
Reference in New Issue
Block a user