feat(user): add search in new tab preference (#2125)

This commit is contained in:
Meier Lukas
2025-01-26 22:37:48 +01:00
committed by GitHub
parent c43a2f0488
commit 92f70f5a03
28 changed files with 3673 additions and 86 deletions

View File

@@ -36,6 +36,7 @@
"@homarr/old-schema": "workspace:^0.1.0", "@homarr/old-schema": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0", "@homarr/spotlight": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",

View File

@@ -9,10 +9,14 @@ import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env"; import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { ModalProvider } from "@homarr/modals"; import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications"; import { Notifications } from "@homarr/notifications";
import { SettingsProvider } from "@homarr/settings";
import { SpotlightProvider } from "@homarr/spotlight"; import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation"; import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
@@ -73,6 +77,8 @@ export default async function Layout(props: {
} }
const session = await auth(); const session = await auth();
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
const serverSettings = await getServerSettingsAsync(db);
const colorScheme = await getCurrentColorSchemeAsync(); const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr"; const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
const i18nMessages = await getI18nMessages(); const i18nMessages = await getI18nMessages();
@@ -81,6 +87,19 @@ export default async function Layout(props: {
(innerProps) => { (innerProps) => {
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />; return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
}, },
(innerProps) => (
<SettingsProvider
user={user}
serverSettings={{
board: {
homeBoardId: serverSettings.board.homeBoardId,
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
},
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
}}
{...innerProps}
/>
),
(innerProps) => <JotaiProvider {...innerProps} />, (innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />, (innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <DayJsLoader {...innerProps} />, (innerProps) => <DayJsLoader {...innerProps} />,

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Button, Group, Select, Stack } from "@mantine/core"; import { Button, Group, Select, Stack, Switch } from "@mantine/core";
import type { z } from "zod"; import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -11,34 +11,36 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
interface ChangeDefaultSearchEngineFormProps { interface ChangeSearchPreferencesFormProps {
user: RouterOutputs["user"]["getById"]; user: RouterOutputs["user"]["getById"];
searchEnginesData: { value: string; label: string }[]; searchEnginesData: { value: string; label: string }[];
} }
export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: ChangeDefaultSearchEngineFormProps) => { export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => {
const t = useI18n(); const t = useI18n();
const { mutate, isPending } = clientApi.user.changeDefaultSearchEngine.useMutation({ const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({
async onSettled() { async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`); await revalidatePathActionAsync(`/manage/users/${user.id}`);
}, },
onSuccess(_, variables) { onSuccess(_, variables) {
form.setInitialValues({ form.setInitialValues({
defaultSearchEngineId: variables.defaultSearchEngineId, defaultSearchEngineId: variables.defaultSearchEngineId,
openInNewTab: variables.openInNewTab,
}); });
showSuccessNotification({ showSuccessNotification({
message: t("user.action.changeDefaultSearchEngine.notification.success.message"), message: t("user.action.changeSearchPreferences.notification.success.message"),
}); });
}, },
onError() { onError() {
showErrorNotification({ showErrorNotification({
message: t("user.action.changeDefaultSearchEngine.notification.error.message"), message: t("user.action.changeSearchPreferences.notification.error.message"),
}); });
}, },
}); });
const form = useZodForm(validation.user.changeDefaultSearchEngine, { const form = useZodForm(validation.user.changeSearchPreferences, {
initialValues: { initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId ?? "", defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
}, },
}); });
@@ -52,7 +54,16 @@ export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: Chang
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md"> <Stack gap="md">
<Select w="100%" data={searchEnginesData} {...form.getInputProps("defaultSearchEngineId")} /> <Select
label={t("user.field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={t("user.field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Group justify="end"> <Group justify="end">
<Button type="submit" color="teal" loading={isPending}> <Button type="submit" color="teal" loading={isPending}>
@@ -64,4 +75,4 @@ export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: Chang
); );
}; };
type FormType = z.infer<typeof validation.user.changeDefaultSearchEngine>; type FormType = z.infer<typeof validation.user.changeSearchPreferences>;

View File

@@ -11,8 +11,8 @@ 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 { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
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";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled"; import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
@@ -102,8 +102,8 @@ export default async function EditUserPage(props: Props) {
</Stack> </Stack>
<Stack mb="lg"> <Stack mb="lg">
<Title order={2}>{tGeneral("item.defaultSearchEngine")}</Title> <Title order={2}>{tGeneral("item.search")}</Title>
<ChangeDefaultSearchEngineForm user={user} searchEnginesData={searchEngines} /> <ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
</Stack> </Stack>
<Stack mb="lg"> <Stack mb="lg">

View File

@@ -22,6 +22,7 @@ import {
import { throwIfActionForbiddenAsync } from "./board/board-access"; import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfCredentialsDisabled } from "./invite/checks"; import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries"; import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
initUser: onboardingProcedure initUser: onboardingProcedure
@@ -215,6 +216,7 @@ export const userRouter = createTRPCRouter({
firstDayOfWeek: true, firstDayOfWeek: true,
pingIconsEnabled: true, pingIconsEnabled: true,
defaultSearchEngineId: true, defaultSearchEngineId: true,
openSearchInNewTab: 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 } })
@@ -239,6 +241,7 @@ export const userRouter = createTRPCRouter({
firstDayOfWeek: true, firstDayOfWeek: true,
pingIconsEnabled: true, pingIconsEnabled: true,
defaultSearchEngineId: true, defaultSearchEngineId: true,
openSearchInNewTab: true,
}, },
where: eq(users.id, input.userId), where: eq(users.id, input.userId),
}); });
@@ -423,40 +426,32 @@ export const userRouter = createTRPCRouter({
}), }),
changeDefaultSearchEngine: protectedProcedure changeDefaultSearchEngine: protectedProcedure
.input( .input(
convertIntersectionToZodObject(validation.user.changeDefaultSearchEngine.and(z.object({ userId: z.string() }))), convertIntersectionToZodObject(
validation.user.changeSearchPreferences.omit({ openInNewTab: true }).and(z.object({ userId: z.string() })),
),
) )
.output(z.void()) .output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeSearchEngine", tags: ["users"], protect: true } }) .meta({
openapi: {
method: "PATCH",
path: "/api/users/changeSearchEngine",
tags: ["users"],
protect: true,
deprecated: true,
},
})
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const user = ctx.session.user; await changeSearchPreferencesAsync(ctx.db, ctx.session, {
// Only admins can change other users passwords ...input,
if (!user.permissions.includes("admin") && user.id !== input.userId) { openInNewTab: undefined,
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) { changeSearchPreferences: protectedProcedure
throw new TRPCError({ .input(convertIntersectionToZodObject(changeSearchPreferencesInputSchema))
code: "NOT_FOUND", .output(z.void())
message: "User not found", .meta({ openapi: { method: "PATCH", path: "/api/users/search-preferences", tags: ["users"], protect: true } })
}); .mutation(async ({ input, ctx }) => {
} await changeSearchPreferencesAsync(ctx.db, ctx.session, input);
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)
@@ -470,21 +465,6 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, ctx.session.user.id));
}), }),
getPingIconsEnabledOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) {
return false;
}
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
pingIconsEnabled: true,
},
where: eq(users.id, ctx.session.user.id),
});
return user?.pingIconsEnabled ?? false;
}),
changePingIconsEnabled: protectedProcedure changePingIconsEnabled: protectedProcedure
.input(validation.user.pingIconsEnabled.and(validation.common.byId)) .input(validation.user.pingIconsEnabled.and(validation.common.byId))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@@ -503,21 +483,6 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, ctx.session.user.id));
}), }),
getFirstDayOfWeekForUserOrDefault: publicProcedure.input(z.undefined()).query(async ({ ctx }) => {
if (!ctx.session?.user) {
return 1 as const;
}
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
firstDayOfWeek: true,
},
where: eq(users.id, ctx.session.user.id),
});
return user?.firstDayOfWeek ?? (1 as const);
}),
changeFirstDayOfWeek: protectedProcedure changeFirstDayOfWeek: protectedProcedure
.input(convertIntersectionToZodObject(validation.user.firstDayOfWeek.and(validation.common.byId))) .input(convertIntersectionToZodObject(validation.user.firstDayOfWeek.and(validation.common.byId)))
.output(z.void()) .output(z.void())

View File

@@ -0,0 +1,50 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import type { Session } from "@homarr/auth";
import type { Modify } from "@homarr/common/types";
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { users } from "@homarr/db/schema";
import { validation } from "@homarr/validation";
export const changeSearchPreferencesInputSchema = validation.user.changeSearchPreferences.and(
z.object({ userId: z.string() }),
);
export const changeSearchPreferencesAsync = async (
db: Database,
session: Session,
input: Modify<z.infer<typeof changeSearchPreferencesInputSchema>, { openInNewTab: boolean | undefined }>,
) => {
const user = 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 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 db
.update(users)
.set({
defaultSearchEngineId: input.defaultSearchEngineId,
openSearchInNewTab: input.openInNewTab,
})
.where(eq(users.id, input.userId));
};

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `open_search_in_new_tab` boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -148,6 +148,13 @@
"when": 1736514409126, "when": 1736514409126,
"tag": "0020_salty_doorman", "tag": "0020_salty_doorman",
"breakpoints": true "breakpoints": true
},
{
"idx": 21,
"version": "5",
"when": 1737883744729,
"tag": "0021_fluffy_jocasta",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `open_search_in_new_tab` integer DEFAULT true NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -148,6 +148,13 @@
"when": 1736510755691, "when": 1736510755691,
"tag": "0020_empty_hellfire_club", "tag": "0020_empty_hellfire_club",
"breakpoints": true "breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1737883733050,
"tag": "0021_famous_bruce_banner",
"breakpoints": true
} }
] ]
} }

View File

@@ -68,6 +68,7 @@ export const users = mysqlTable("user", {
defaultSearchEngineId: varchar({ length: 64 }).references(() => searchEngines.id, { defaultSearchEngineId: varchar({ length: 64 }).references(() => searchEngines.id, {
onDelete: "set null", onDelete: "set null",
}), }),
openSearchInNewTab: boolean().default(false).notNull(),
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(),

View File

@@ -51,6 +51,7 @@ export const users = sqliteTable("user", {
defaultSearchEngineId: text().references(() => searchEngines.id, { defaultSearchEngineId: text().references(() => searchEngines.id, {
onDelete: "set null", onDelete: "set null",
}), }),
openSearchInNewTab: int({ mode: "boolean" }).default(true).notNull(),
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(),

View File

@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];

View File

@@ -0,0 +1 @@
export * from "./src/context";

View File

@@ -0,0 +1,40 @@
{
"name": "@homarr/settings",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/dates": "^7.16.2",
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.19.0",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,55 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import type { DayOfWeek } from "@mantine/dates";
import type { RouterOutputs } from "@homarr/api";
import type { User } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings";
type SettingsContextProps = Pick<
User,
| "firstDayOfWeek"
| "defaultSearchEngineId"
| "homeBoardId"
| "mobileHomeBoardId"
| "openSearchInNewTab"
| "pingIconsEnabled"
>;
interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
board: Pick<ServerSettings["board"], "homeBoardId" | "mobileHomeBoardId">;
}
const SettingsContext = createContext<SettingsContextProps | null>(null);
export const SettingsProvider = ({
user,
serverSettings,
children,
}: PropsWithChildren<{ user: RouterOutputs["user"]["getById"] | null; serverSettings: PublicServerSettings }>) => {
return (
<SettingsContext.Provider
value={{
defaultSearchEngineId: user?.defaultSearchEngineId ?? serverSettings.search.defaultSearchEngineId,
openSearchInNewTab: user?.openSearchInNewTab ?? true,
firstDayOfWeek: (user?.firstDayOfWeek as DayOfWeek | undefined) ?? (1 as const),
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false,
}}
>
{children}
</SettingsContext.Provider>
);
};
export const useSettings = () => {
const context = useContext(SettingsContext);
if (!context) throw new Error("useSettingsContext must be used within a SettingsProvider");
return context;
};

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -30,6 +30,7 @@
"@homarr/integrations": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.16.2", "@mantine/core": "^7.16.2",

View File

@@ -7,6 +7,7 @@ import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationKindsByCategory, getIntegrationName } from "@homarr/definitions"; import { getIntegrationKindsByCategory, getIntegrationName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { RequestMediaModal } from "@homarr/modals-collection"; import { RequestMediaModal } from "@homarr/modals-collection";
import { useSettings } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children"; import { createChildrenOptions } from "../../lib/children";
@@ -39,6 +40,8 @@ export const useFromIntegrationSearchInteraction = (
searchEngine: SearchEngine, searchEngine: SearchEngine,
searchResult: FromIntegrationSearchResult, searchResult: FromIntegrationSearchResult,
): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => { ): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => {
const { openSearchInNewTab } = useSettings();
if (searchEngine.type !== "fromIntegration") { if (searchEngine.type !== "fromIntegration") {
throw new Error("Invalid search engine type"); throw new Error("Invalid search engine type");
} }
@@ -58,7 +61,7 @@ export const useFromIntegrationSearchInteraction = (
return { return {
type: "link", type: "link",
href: searchResult.link, href: searchResult.link,
newTab: true, newTab: openSearchInNewTab,
}; };
} }
@@ -127,10 +130,11 @@ const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenP
); );
}, },
useInteraction({ result }) { useInteraction({ result }) {
const { openSearchInNewTab } = useSettings();
return { return {
type: "link", type: "link",
href: result.link, href: result.link,
newTab: true, newTab: openSearchInNewTab,
}; };
}, },
}, },
@@ -166,6 +170,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
enabled: searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && query.length > 0, enabled: searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && query.length > 0,
}, },
); );
const { openSearchInNewTab } = useSettings();
if (searchEngine.type === "generic") { if (searchEngine.type === "generic") {
return [ return [
@@ -184,6 +189,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
useInteraction: interaction.link(({ urlTemplate }, query) => ({ useInteraction: interaction.link(({ urlTemplate }, query) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: urlTemplate!.replace("%s", query), href: urlTemplate!.replace("%s", query),
newTab: openSearchInNewTab,
})), })),
}, },
]; ];
@@ -258,11 +264,12 @@ export const searchEnginesSearchGroups = createGroup<SearchEngine>({
setChildrenOptions(searchEnginesChildrenOptions(engine)); setChildrenOptions(searchEnginesChildrenOptions(engine));
}, },
useInteraction: (searchEngine, query) => { useInteraction: (searchEngine, query) => {
const { openSearchInNewTab } = useSettings();
if (searchEngine.type === "generic" && searchEngine.urlTemplate) { if (searchEngine.type === "generic" && searchEngine.urlTemplate) {
return { return {
type: "link" as const, type: "link" as const,
href: searchEngine.urlTemplate.replace("%s", query), href: searchEngine.urlTemplate.replace("%s", query),
newTab: true, newTab: openSearchInNewTab,
}; };
} }

View File

@@ -6,6 +6,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useSettings } from "@homarr/settings";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -135,10 +136,12 @@ const createDefaultSearchEntries = (
}), }),
icon: defaultSearchEngine.iconUrl, icon: defaultSearchEngine.iconUrl,
useInteraction(query) { useInteraction(query) {
const { openSearchInNewTab } = useSettings();
return { return {
type: "link", type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: defaultSearchEngine.urlTemplate!.replace("%s", query), href: defaultSearchEngine.urlTemplate!.replace("%s", query),
newTab: openSearchInNewTab,
}; };
}, },
}, },

View File

@@ -151,6 +151,12 @@
}, },
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Use icons for pings" "label": "Use icons for pings"
},
"defaultSearchEngine": {
"label": "Default search engine"
},
"openSearchInNewTab": {
"label": "Open search results in new tab"
} }
}, },
"error": { "error": {
@@ -210,13 +216,13 @@
} }
} }
}, },
"changeDefaultSearchEngine": { "changeSearchPreferences": {
"notification": { "notification": {
"success": { "success": {
"message": "Default search engine changed successfully" "message": "Search preferences changed successfully"
}, },
"error": { "error": {
"message": "Unable to change default search engine" "message": "Unable to change search preferences"
} }
} }
}, },
@@ -2281,7 +2287,7 @@
"mobile": "Mobile" "mobile": "Mobile"
} }
}, },
"defaultSearchEngine": "Default search engine", "search": "Search",
"firstDayOfWeek": "First day of the week", "firstDayOfWeek": "First day of the week",
"accessibility": "Accessibility" "accessibility": "Accessibility"
} }

View File

@@ -110,8 +110,9 @@ const changeHomeBoardSchema = z.object({
mobileHomeBoardId: z.string().nullable(), mobileHomeBoardId: z.string().nullable(),
}); });
const changeDefaultSearchEngineSchema = z.object({ const changeSearchPreferencesSchema = z.object({
defaultSearchEngineId: z.string().min(1), defaultSearchEngineId: z.string().min(1).nullable(),
openInNewTab: z.boolean(),
}); });
const changeColorSchemeSchema = z.object({ const changeColorSchemeSchema = z.object({
@@ -137,7 +138,7 @@ export const userSchemas = {
editProfile: editProfileSchema, editProfile: editProfileSchema,
changePassword: changePasswordSchema, changePassword: changePasswordSchema,
changeHomeBoards: changeHomeBoardSchema, changeHomeBoards: changeHomeBoardSchema,
changeDefaultSearchEngine: changeDefaultSearchEngineSchema, changeSearchPreferences: changeSearchPreferencesSchema,
changePasswordApi: changePasswordApiSchema, changePasswordApi: changePasswordApiSchema,
changeColorScheme: changeColorSchemeSchema, changeColorScheme: changeColorSchemeSchema,
firstDayOfWeek: firstDayOfWeekSchema, firstDayOfWeek: firstDayOfWeekSchema,

View File

@@ -36,6 +36,7 @@
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0", "@homarr/spotlight": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",

View File

@@ -1,7 +1,7 @@
import type { MantineColor } from "@mantine/core"; import type { MantineColor } from "@mantine/core";
import { Box, Tooltip } from "@mantine/core"; import { Box, Tooltip } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { useSettings } from "@homarr/settings";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
interface PingDotProps { interface PingDotProps {
@@ -11,7 +11,7 @@ interface PingDotProps {
} }
export const PingDot = ({ color, tooltip, ...props }: PingDotProps) => { export const PingDot = ({ color, tooltip, ...props }: PingDotProps) => {
const [pingIconsEnabled] = clientApi.user.getPingIconsEnabledOrDefault.useSuspenseQuery(); const { pingIconsEnabled } = useSettings();
return ( return (
<Box bottom="2.5cqmin" right="2.5cqmin" pos="absolute"> <Box bottom="2.5cqmin" right="2.5cqmin" pos="absolute">

View File

@@ -8,6 +8,7 @@ import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { CalendarEvent } from "@homarr/integrations/types"; import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day"; import { CalendarDay } from "./calender-day";
@@ -58,7 +59,7 @@ interface CalendarBaseProps {
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => { const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
const params = useParams(); const params = useParams();
const locale = params.locale as string; const locale = params.locale as string;
const [firstDayOfWeek] = clientApi.user.getFirstDayOfWeekForUserOrDefault.useSuspenseQuery(); const { firstDayOfWeek } = useSettings();
return ( return (
<Calendar <Calendar

49
pnpm-lock.yaml generated
View File

@@ -145,6 +145,9 @@ importers:
'@homarr/server-settings': '@homarr/server-settings':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../../packages/server-settings version: link:../../packages/server-settings
'@homarr/settings':
specifier: workspace:^0.1.0
version: link:../../packages/settings
'@homarr/spotlight': '@homarr/spotlight':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../../packages/spotlight version: link:../../packages/spotlight
@@ -1600,6 +1603,46 @@ importers:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.7.3 version: 5.7.3
packages/settings:
dependencies:
'@homarr/api':
specifier: workspace:^0.1.0
version: link:../api
'@homarr/db':
specifier: workspace:^0.1.0
version: link:../db
'@homarr/server-settings':
specifier: workspace:^0.1.0
version: link:../server-settings
'@mantine/dates':
specifier: ^7.16.2
version: 7.16.2(@mantine/core@7.16.2(@mantine/hooks@7.16.2(react@19.0.0))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.16.2(react@19.0.0))(dayjs@1.11.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next:
specifier: 15.1.6
version: 15.1.6(@babel/core@7.26.0)(@playwright/test@1.49.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.83.4)
react:
specifier: 19.0.0
version: 19.0.0
react-dom:
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0
version: link:../../tooling/eslint
'@homarr/prettier-config':
specifier: workspace:^0.1.0
version: link:../../tooling/prettier
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
eslint:
specifier: ^9.19.0
version: 9.19.0
typescript:
specifier: ^5.7.3
version: 5.7.3
packages/spotlight: packages/spotlight:
dependencies: dependencies:
'@homarr/api': '@homarr/api':
@@ -1623,6 +1666,9 @@ importers:
'@homarr/modals-collection': '@homarr/modals-collection':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../modals-collection version: link:../modals-collection
'@homarr/settings':
specifier: workspace:^0.1.0
version: link:../settings
'@homarr/translation': '@homarr/translation':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../translation version: link:../translation
@@ -1849,6 +1895,9 @@ importers:
'@homarr/redis': '@homarr/redis':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../redis version: link:../redis
'@homarr/settings':
specifier: workspace:^0.1.0
version: link:../settings
'@homarr/spotlight': '@homarr/spotlight':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../spotlight version: link:../spotlight