From d8eaef859a27d239a8091a333f0b538c0a6edc66 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 25 Jan 2026 16:00:10 +0100 Subject: [PATCH 01/16] chore(gitignore): ignore Cursor project files --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0aa3ed4c7..8562d4250 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,6 @@ e2e/shared/tmp apps/nextjs/public/images/background.png # next-intl -en.d.json.ts \ No newline at end of file +en.d.json.ts + +.cursor/ \ No newline at end of file From d99b20d3efd02eda029b7f2522b9aafbd65c5e7a Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 25 Jan 2026 16:00:21 +0100 Subject: [PATCH 02/16] feat(manage): consolidate user general settings form --- .../_components/_general-settings-form.tsx | 451 ++++++++++++++++++ .../manage/users/[userId]/general/page.tsx | 74 +-- 2 files changed, 470 insertions(+), 55 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx new file mode 100644 index 000000000..1357b330e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx @@ -0,0 +1,451 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button, Card, Divider, Group, Radio, Select, SimpleGrid, Stack, Switch, Text, TextInput, Title } from "@mantine/core"; +import type { DayOfWeek } from "@mantine/dates"; +import dayjs from "dayjs"; +import localeData from "dayjs/plugin/localeData"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { env } from "@homarr/common/env"; +import { useZodForm } from "@homarr/form"; +import { useConfirmModal } from "@homarr/modals"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { + userChangeHomeBoardsSchema, + userChangeSearchPreferencesSchema, + userEditProfileSchema, + userFirstDayOfWeekSchema, + userPingIconsEnabledSchema, +} from "@homarr/validation/user"; +import { z } from "zod/v4"; + +import type { Board } from "~/app/[locale]/boards/_types"; +import { BoardSelect } from "~/components/board/board-select"; +import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; + +dayjs.extend(localeData); + +const anchorSelector = "a[href]:not([target='_blank'])"; + +const userGeneralSettingsSchema = z.object({ + name: userEditProfileSchema.shape.name, + email: userEditProfileSchema.shape.email, + homeBoardId: userChangeHomeBoardsSchema.shape.homeBoardId, + mobileHomeBoardId: userChangeHomeBoardsSchema.shape.mobileHomeBoardId, + defaultSearchEngineId: userChangeSearchPreferencesSchema.shape.defaultSearchEngineId, + openInNewTab: userChangeSearchPreferencesSchema.shape.openInNewTab, + firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek, + pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled, +}); + +type FormValues = z.infer; + +interface UserGeneralSettingsFormProps { + user: RouterOutputs["user"]["getById"]; + boardsData: Pick[]; + searchEnginesData: { value: string; label: string }[]; + showLanguageSelector?: boolean; +} + +const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({ + name: user.name ?? "", + email: user.email ?? "", + homeBoardId: user.homeBoardId, + mobileHomeBoardId: user.mobileHomeBoardId, + defaultSearchEngineId: user.defaultSearchEngineId, + openInNewTab: user.openSearchInNewTab, + firstDayOfWeek: user.firstDayOfWeek as DayOfWeek, + pingIconsEnabled: user.pingIconsEnabled, +}); + +const valuesEqual = (a: unknown, b: unknown) => a === b; + +export const UserGeneralSettingsForm = ({ + user, + boardsData, + searchEnginesData, + showLanguageSelector = false, +}: UserGeneralSettingsFormProps) => { + const t = useI18n(); + const tGeneral = useScopedI18n("management.page.user.setting.general"); + const { openConfirmModal } = useConfirmModal(); + const router = useRouter(); + + const isCredentialsUser = user.provider === "credentials"; + + const [savedValues, setSavedValues] = useState(() => buildInitialValues(user)); + const form = useZodForm(userGeneralSettingsSchema, { initialValues: savedValues }); + const allowLeaveRef = useRef(false); + const confirmOpenRef = useRef(false); + + const editProfileMutation = clientApi.user.editProfile.useMutation(); + const changeHomeBoardsMutation = clientApi.user.changeHomeBoards.useMutation(); + const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation(); + const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation(); + const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation(); + + const isPending = + editProfileMutation.isPending || + changeHomeBoardsMutation.isPending || + changeSearchPreferencesMutation.isPending || + changeFirstDayOfWeekMutation.isPending || + changePingIconsEnabledMutation.isPending; + + const weekDays = useMemo(() => dayjs.weekdays(false), []); + + useEffect(() => { + if (!form.isDirty() || allowLeaveRef.current) return; + + const handleClick = (event: Event) => { + if (allowLeaveRef.current) return; + + const target = (event.target as HTMLElement).closest("a"); + + if (!target) return; + + event.preventDefault(); + + if (confirmOpenRef.current) return; + confirmOpenRef.current = true; + + openConfirmModal({ + title: t("common.unsavedChanges"), + children: t("common.unsavedChanges"), + confirmProps: { + children: t("common.action.discard"), + }, + onCancel() { + confirmOpenRef.current = false; + }, + onConfirm() { + allowLeaveRef.current = true; + confirmOpenRef.current = false; + router.push(target.href); + }, + }); + }; + + const handlePopState = (event: Event) => { + if (allowLeaveRef.current) return; + + // Keep the user on this page and ask for confirmation. + window.history.pushState(null, document.title, window.location.href); + event.preventDefault(); + + if (confirmOpenRef.current) return; + confirmOpenRef.current = true; + + openConfirmModal({ + title: t("common.unsavedChanges"), + children: t("common.unsavedChanges"), + confirmProps: { + children: t("common.action.discard"), + }, + onCancel() { + confirmOpenRef.current = false; + }, + onConfirm() { + allowLeaveRef.current = true; + confirmOpenRef.current = false; + window.history.back(); + }, + }); + }; + + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (env.NODE_ENV === "development") return; // Allow to reload in development + + event.preventDefault(); + event.returnValue = true; + }; + + // Add a history entry so back triggers popstate for this page first. + window.history.pushState(null, document.title, window.location.href); + + const anchors = document.querySelectorAll(anchorSelector); + anchors.forEach((link) => link.addEventListener("click", handleClick)); + window.addEventListener("popstate", handlePopState); + window.addEventListener("beforeunload", handleBeforeUnload); + + return () => { + anchors.forEach((link) => link.removeEventListener("click", handleClick)); + window.removeEventListener("popstate", handlePopState); + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [form, openConfirmModal, router, t]); + + const handleSave = useCallback(async () => { + const values = form.getValues() as FormValues; + + const tasks: Array<{ key: keyof FormValues | "profile"; run: () => Promise }> = []; + + const profileChanged = + !valuesEqual(values.name, savedValues.name) || !valuesEqual(values.email ?? "", savedValues.email ?? ""); + if (isCredentialsUser && profileChanged) { + tasks.push({ + key: "profile", + run: () => + editProfileMutation.mutateAsync({ + id: user.id, + name: values.name, + email: values.email ?? "", + }), + }); + } + + const boardsChanged = + !valuesEqual(values.homeBoardId, savedValues.homeBoardId) || + !valuesEqual(values.mobileHomeBoardId, savedValues.mobileHomeBoardId); + if (boardsChanged) { + tasks.push({ + key: "homeBoardId", + run: () => + changeHomeBoardsMutation.mutateAsync({ + userId: user.id, + homeBoardId: values.homeBoardId, + mobileHomeBoardId: values.mobileHomeBoardId, + }), + }); + } + + const searchChanged = + !valuesEqual(values.defaultSearchEngineId, savedValues.defaultSearchEngineId) || + !valuesEqual(values.openInNewTab, savedValues.openInNewTab); + if (searchChanged) { + tasks.push({ + key: "defaultSearchEngineId", + run: () => + changeSearchPreferencesMutation.mutateAsync({ + userId: user.id, + defaultSearchEngineId: values.defaultSearchEngineId, + openInNewTab: values.openInNewTab, + }), + }); + } + + const firstDayChanged = !valuesEqual(values.firstDayOfWeek, savedValues.firstDayOfWeek); + if (firstDayChanged) { + tasks.push({ + key: "firstDayOfWeek", + run: () => + changeFirstDayOfWeekMutation.mutateAsync({ + id: user.id, + firstDayOfWeek: values.firstDayOfWeek, + }), + }); + } + + const pingChanged = !valuesEqual(values.pingIconsEnabled, savedValues.pingIconsEnabled); + if (pingChanged) { + tasks.push({ + key: "pingIconsEnabled", + run: () => + changePingIconsEnabledMutation.mutateAsync({ + id: user.id, + pingIconsEnabled: values.pingIconsEnabled, + }), + }); + } + + if (tasks.length === 0) return; + + const results = await Promise.allSettled(tasks.map((t) => t.run())); + + const hasError = results.some((r) => r.status === "rejected"); + + const nextSaved: FormValues = { + ...savedValues, + }; + + if (!hasError) { + nextSaved.name = values.name; + nextSaved.email = values.email ?? ""; + nextSaved.homeBoardId = values.homeBoardId; + nextSaved.mobileHomeBoardId = values.mobileHomeBoardId; + nextSaved.defaultSearchEngineId = values.defaultSearchEngineId; + nextSaved.openInNewTab = values.openInNewTab; + nextSaved.firstDayOfWeek = values.firstDayOfWeek; + nextSaved.pingIconsEnabled = values.pingIconsEnabled; + } else { + results.forEach((r, index) => { + if (r.status === "rejected") return; + const task = tasks[index]; + if (!task) return; + if (task.key === "profile") { + nextSaved.name = values.name; + nextSaved.email = values.email ?? ""; + } + if (task.key === "homeBoardId") { + nextSaved.homeBoardId = values.homeBoardId; + nextSaved.mobileHomeBoardId = values.mobileHomeBoardId; + } + if (task.key === "defaultSearchEngineId") { + nextSaved.defaultSearchEngineId = values.defaultSearchEngineId; + nextSaved.openInNewTab = values.openInNewTab; + } + if (task.key === "firstDayOfWeek") { + nextSaved.firstDayOfWeek = values.firstDayOfWeek; + } + if (task.key === "pingIconsEnabled") { + nextSaved.pingIconsEnabled = values.pingIconsEnabled; + } + }); + } + + setSavedValues(nextSaved); + form.setInitialValues(nextSaved); + + await revalidatePathActionAsync(`/manage/users/${user.id}`); + + if (hasError) { + showErrorNotification({ + title: t("common.notification.update.error"), + message: t("common.notification.update.error"), + }); + return; + } + + showSuccessNotification({ + title: t("common.notification.update.success"), + message: t("common.notification.update.success"), + }); + }, [ + changeFirstDayOfWeekMutation, + changeHomeBoardsMutation, + changePingIconsEnabledMutation, + changeSearchPreferencesMutation, + editProfileMutation, + form, + isCredentialsUser, + savedValues, + t, + user.id, + ]); + + const handleDiscard = useCallback(() => { + form.reset(); + }, [form]); + + const firstDayInputProps = form.getInputProps("firstDayOfWeek"); + const onFirstDayChange = firstDayInputProps.onChange as (value: number) => void; + const firstDayValue = (firstDayInputProps.value as number).toString(); + + return ( +
handleSave())}> + + + + + + {t("user.name")} + + {t("user.field.username.label")} · {t("user.field.email.label")} + + {!isCredentialsUser && ( + + {t("management.page.user.fieldsDisabledExternalProvider")} + + )} + + + + + + + + + + + {tGeneral("item.board.title")} + + + + + + + + + + + {tGeneral("item.search")} + + + - + + {({ state, handleChange, handleBlur }) => ( + - )} - - - {({ state, handleChange, handleBlur }) => ( - handleChange(e.currentTarget.checked)} - onBlur={handleBlur} - error={getErrorMessage(state.meta.errors)} - /> - )} - + - - - - - - - - - ); -}; - -type FormType = z.infer; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_first-day-of-week.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_first-day-of-week.tsx deleted file mode 100644 index 71bef0737..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_first-day-of-week.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { Button, Group, Radio, Stack } from "@mantine/core"; -import type { DayOfWeek } from "@mantine/dates"; -import dayjs from "dayjs"; -import localeData from "dayjs/plugin/localeData"; -import type { z } from "zod/v4"; - -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 { userFirstDayOfWeekSchema } from "@homarr/validation/user"; - -dayjs.extend(localeData); - -interface FirstDayOfWeekProps { - user: RouterOutputs["user"]["getById"]; -} - -const weekDays = dayjs.weekdays(false); - -export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({ - async onSettled() { - await revalidatePathActionAsync(`/manage/users/${user.id}`); - }, - onSuccess(_, variables) { - form.setInitialValues({ - firstDayOfWeek: variables.firstDayOfWeek as DayOfWeek, - }); - showSuccessNotification({ - message: t("user.action.changeFirstDayOfWeek.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: t("user.action.changeFirstDayOfWeek.notification.error.message"), - }); - }, - }); - const form = useZodForm(userFirstDayOfWeekSchema, { - initialValues: { - firstDayOfWeek: user.firstDayOfWeek as DayOfWeek, - }, - }); - - const handleSubmit = (values: FormType) => { - mutate({ - id: user.id, - ...values, - }); - }; - - const inputProps = form.getInputProps("firstDayOfWeek"); - const onChange = inputProps.onChange as (value: number) => void; - const value = (inputProps.value as number).toString(); - - return ( -
- - onChange(parseInt(value))}> - - - - - - - - - - - -
- ); -}; - -type FormType = z.infer; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_ping-icons-enabled.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_ping-icons-enabled.tsx deleted file mode 100644 index d218ac39a..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_ping-icons-enabled.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import { Button, Group, Stack, Switch } from "@mantine/core"; -import type { z } from "zod/v4"; - -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 { userPingIconsEnabledSchema } from "@homarr/validation/user"; - -interface PingIconsEnabledProps { - user: RouterOutputs["user"]["getById"]; -} - -export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({ - async onSettled() { - await revalidatePathActionAsync(`/manage/users/${user.id}`); - }, - onSuccess(_, variables) { - form.setInitialValues({ - pingIconsEnabled: variables.pingIconsEnabled, - }); - showSuccessNotification({ - message: t("user.action.changePingIconsEnabled.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: t("user.action.changePingIconsEnabled.notification.error.message"), - }); - }, - }); - const form = useZodForm(userPingIconsEnabledSchema, { - initialValues: { - pingIconsEnabled: user.pingIconsEnabled, - }, - }); - - const handleSubmit = (values: FormType) => { - mutate({ - id: user.id, - ...values, - }); - }; - - return ( -
- - - - - - - -
- ); -}; - -type FormType = z.infer; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-form.tsx deleted file mode 100644 index 2db358cae..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-form.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { Button, Group, Stack, TextInput } from "@mantine/core"; - -import type { RouterInputs, 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 { userEditProfileSchema } from "@homarr/validation/user"; - -interface UserProfileFormProps { - user: RouterOutputs["user"]["getById"]; -} - -export const UserProfileForm = ({ user }: UserProfileFormProps) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.user.editProfile.useMutation({ - async onSettled() { - await revalidatePathActionAsync("/manage/users"); - }, - onSuccess(_, variables) { - // Reset form initial values to reset dirty state - form.setInitialValues({ - name: variables.name, - email: variables.email ?? "", - }); - showSuccessNotification({ - title: t("common.notification.update.success"), - message: t("user.action.editProfile.notification.success.message"), - }); - }, - onError(error) { - const message = - error.data?.code === "CONFLICT" - ? t("user.error.usernameTaken") - : t("user.action.editProfile.notification.error.message"); - showErrorNotification({ - title: t("common.notification.update.error"), - message, - }); - }, - }); - const form = useZodForm(userEditProfileSchema.omit({ id: true }), { - initialValues: { - name: user.name ?? "", - email: user.email ?? "", - }, - }); - - // Only credentials users can edit their profile - const isProviderCredentials = user.provider === "credentials"; - - const handleSubmit = useCallback( - (values: FormType) => { - if (!isProviderCredentials) return; - mutate({ - ...values, - id: user.id, - }); - }, - [isProviderCredentials, mutate, user.id], - ); - - return ( -
- - - - - {isProviderCredentials && ( - - - - )} - -
- ); -}; - -type FormType = Omit; From 19d86daa871790baa452e84a7bf2d986eae8eb22 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 29 May 2026 15:37:00 +0200 Subject: [PATCH 09/16] wip: user preferences in command palette --- packages/settings/index.ts | 1 + .../settings/src/preferences/definitions.ts | 22 ++ .../actions/items/children-action-item.tsx | 5 +- .../actions/items/group-action-item.tsx | 3 +- .../spotlight/src/components/spotlight.tsx | 76 ++++--- packages/spotlight/src/lib/interaction.ts | 5 +- .../src/modes/command/children/language.tsx | 58 ++--- .../children/preferences/action-row.tsx | 68 ++++++ .../command/children/preferences/board.tsx | 47 ++++ .../children/preferences/color-scheme.tsx | 26 +++ .../preferences/first-day-of-week.tsx | 31 +++ .../command/children/preferences/index.tsx | 1 + .../command/children/preferences/registry.ts | 52 +++++ .../children/preferences/search-engine.tsx | 35 +++ .../preferences/selectable-actions.tsx | 55 +++++ .../preferences/settings-children.tsx | 154 +++++++++++++ .../src/modes/command/global-group.tsx | 31 +-- .../spotlight/src/modes/command/index.tsx | 3 +- .../src/modes/help/home-empty-groups.tsx | 18 +- packages/spotlight/src/modes/home/index.tsx | 3 +- .../src/modes/preferences/groups.tsx | 25 +++ .../src/preferences/use-user-preference.ts | 207 ++++++++++++++++++ packages/translation/src/lang/en.json | 101 +++++++++ pnpm-lock.yaml | 10 +- 24 files changed, 921 insertions(+), 116 deletions(-) create mode 100644 packages/settings/src/preferences/definitions.ts create mode 100644 packages/spotlight/src/modes/command/children/preferences/action-row.tsx create mode 100644 packages/spotlight/src/modes/command/children/preferences/board.tsx create mode 100644 packages/spotlight/src/modes/command/children/preferences/color-scheme.tsx create mode 100644 packages/spotlight/src/modes/command/children/preferences/first-day-of-week.tsx create mode 100644 packages/spotlight/src/modes/command/children/preferences/index.tsx create mode 100644 packages/spotlight/src/modes/command/children/preferences/registry.ts create mode 100644 packages/spotlight/src/modes/command/children/preferences/search-engine.tsx create mode 100644 packages/spotlight/src/modes/command/children/preferences/selectable-actions.tsx create mode 100644 packages/spotlight/src/modes/command/children/preferences/settings-children.tsx create mode 100644 packages/spotlight/src/modes/preferences/groups.tsx create mode 100644 packages/spotlight/src/preferences/use-user-preference.ts diff --git a/packages/settings/index.ts b/packages/settings/index.ts index 123d6a3ea..aded6d905 100644 --- a/packages/settings/index.ts +++ b/packages/settings/index.ts @@ -1 +1,2 @@ export * from "./src/context"; +export * from "./src/preferences/definitions"; diff --git a/packages/settings/src/preferences/definitions.ts b/packages/settings/src/preferences/definitions.ts new file mode 100644 index 000000000..c9de2a91f --- /dev/null +++ b/packages/settings/src/preferences/definitions.ts @@ -0,0 +1,22 @@ +export const userPreferenceDefinitions = [ + { key: "colorScheme", kind: "select", guest: true }, + { key: "locale", kind: "select", guest: true }, + { key: "defaultSearchEngineId", kind: "searchEngine", guest: false }, + { key: "openSearchInNewTab", kind: "boolean", guest: false }, + { key: "ddgBangs", kind: "boolean", guest: false }, + { key: "firstDayOfWeek", kind: "select", guest: false }, + { key: "homeBoardId", kind: "board", guest: false }, + { key: "mobileHomeBoardId", kind: "board", guest: false }, + { key: "pingIconsEnabled", kind: "boolean", guest: false }, + { key: "fullPreferencesPage", kind: "link", guest: false }, +] as const; + +export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number]; + +export type UserPreferenceKey = UserPreferenceDefinition["key"]; + +export type UserPreferenceKind = UserPreferenceDefinition["kind"]; + +export const userPreferenceDefinitionByKey = Object.fromEntries( + userPreferenceDefinitions.map((definition) => [definition.key, definition]), +) as Record; diff --git a/packages/spotlight/src/components/actions/items/children-action-item.tsx b/packages/spotlight/src/components/actions/items/children-action-item.tsx index f7c98c519..3d5427506 100644 --- a/packages/spotlight/src/components/actions/items/children-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/children-action-item.tsx @@ -33,7 +33,10 @@ export const ChildrenActionItem = ({ childrenOptions, action, query, setChildren diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx index eb48e04b2..b16cd03c2 100644 --- a/packages/spotlight/src/components/actions/items/group-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx @@ -58,7 +58,8 @@ export const SpotlightGroupActionItem = interaction.type !== "mode" && interaction.type !== "children" && interaction.type !== "none" && - interaction.type !== "setQuery" + interaction.type !== "setQuery" && + (interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false) } className={classes.spotlightAction} > diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx index 4ca086f3b..8e744c0a2 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -1,7 +1,7 @@ "use client"; import type { Dispatch, SetStateAction } from "react"; -import { useMemo, useRef, useState } from "react"; +import { useCallback, 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"; @@ -13,12 +13,13 @@ import { useI18n } from "@homarr/translation/client"; import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { SearchMode } from "../lib/mode"; import { searchModes } from "../modes"; -import { useHomeEmptyGroups } from "../modes/help/home-empty-groups"; +import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups"; 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"]; +type ChildrenOptions = inferSearchInteractionOptions<"children">; const defaultMode = "home"; export const Spotlight = () => { @@ -31,7 +32,6 @@ export const Spotlight = () => { return null; } - // We use the "key" below to prevent the 'Different amounts of hooks' error return ( ); @@ -46,13 +46,29 @@ interface SpotlightWithActiveModeProps { const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => { const [query, setQuery] = queryState; const [mode, setMode] = modeState; - const [childrenOptions, setChildrenOptions] = useState | null>(null); + const [childrenStack, setChildrenStack] = useState([]); + const childrenOptions = childrenStack.at(-1) ?? null; const t = useI18n(); const inputRef = useRef(null); - // Works as always the same amount of hooks are executed const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups; const groups = useGroups(); - const homeEmptyGroups = useHomeEmptyGroups(); + const homeEmptyGroups = useHomeEmptyGroupsWithPreferences(); + + const clearChildrenStack = useCallback(() => { + setChildrenStack([]); + }, []); + + const pushChildrenOptions = useCallback((options: ChildrenOptions) => { + setChildrenStack((currentStack) => [...currentStack, options]); + setQuery(""); + setTimeout(() => selectAction(0, spotlightStore)); + }, [setQuery]); + + const popChildrenOptions = useCallback(() => { + setChildrenStack((currentStack) => currentStack.slice(0, -1)); + setQuery(""); + setTimeout(() => selectAction(0, spotlightStore)); + }, [setQuery]); return ( { setMode(defaultMode); - setChildrenOptions(null); + clearChildrenStack(); setQuery(""); }} query={query} @@ -69,17 +85,14 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery; setQuery(sanitizedQuery); - // Only switch modes when a single trigger character is typed if (sanitizedQuery.length !== 1) return; - const modeToActivate = searchModes.find((mode) => mode.character === sanitizedQuery); + const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery); if (!modeToActivate) return; setMode(modeToActivate.modeKey); - setChildrenOptions(null); - + clearChildrenStack(); setQuery(""); - setTimeout(() => selectAction(0, spotlightStore)); }} store={spotlightStore} @@ -102,11 +115,17 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh }, }} rightSection={ - mode === defaultMode ? null : ( + mode === defaultMode && childrenStack.length === 0 ? null : ( { + if (childrenStack.length > 0) { + popChildrenOptions(); + inputRef.current?.focus(); + return; + } + setMode(defaultMode); - setChildrenOptions(null); + clearChildrenStack(); inputRef.current?.focus(); }} variant="subtle" @@ -118,15 +137,22 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh } value={query} onKeyDown={(event) => { - if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") { + if (query.length !== 0 || event.key !== "Backspace") return; + + if (childrenStack.length > 0) { + popChildrenOptions(); + return; + } + + if (mode !== defaultMode) { setMode(defaultMode); - setChildrenOptions(null); + clearChildrenStack(); } }} /> {childrenOptions ? ( - + ) : null} @@ -134,25 +160,21 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh {childrenOptions ? ( ) : ( { - setMode(mode); - setChildrenOptions(null); + setMode={(nextMode) => { + setMode(nextMode); + clearChildrenStack(); setTimeout(() => selectAction(0, spotlightStore)); }} setChildrenOptions={(options) => { - setChildrenOptions(options); - - setTimeout(() => { - setQuery(""); - selectAction(0, spotlightStore); - }); + pushChildrenOptions(options); }} query={query} groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups} diff --git a/packages/spotlight/src/lib/interaction.ts b/packages/spotlight/src/lib/interaction.ts index 060a35c51..e680f381d 100644 --- a/packages/spotlight/src/lib/interaction.ts +++ b/packages/spotlight/src/lib/interaction.ts @@ -10,7 +10,10 @@ const createSearchInteraction = (type: TType) => ({ // This is used to define search interactions with their options const searchInteractions = [ createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(), - createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise }>(), + createSearchInteraction("javaScript").optionsType<{ + onSelect: () => MaybePromise; + closeSpotlightOnTrigger?: boolean; + }>(), createSearchInteraction("setQuery").optionsType<{ query: string }>(), createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(), createSearchInteraction("children").optionsType<{ diff --git a/packages/spotlight/src/modes/command/children/language.tsx b/packages/spotlight/src/modes/command/children/language.tsx index 6178872b8..1876b3930 100644 --- a/packages/spotlight/src/modes/command/children/language.tsx +++ b/packages/spotlight/src/modes/command/children/language.tsx @@ -1,16 +1,17 @@ -import { Group, Stack, Text } from "@mantine/core"; -import { IconCheck } from "@tabler/icons-react"; +import { Group, Text } from "@mantine/core"; import { localeConfigurations, supportedLanguages } from "@homarr/translation"; -import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client"; +import { useUserPreference } from "../../../preferences/use-user-preference"; import { LanguageIcon } from "@homarr/ui"; import { createChildrenOptions } from "../../../lib/children"; +import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./preferences/action-row"; export const languageChildrenOptions = createChildrenOptions>({ useActions: (_, query) => { const normalizedQuery = query.trim().toLowerCase(); - const currentLocale = useCurrentLocale(); + const { value: currentLocale, setValue: setLocale, isPending } = useUserPreference("locale"); + return supportedLanguages .map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] })) .filter( @@ -29,38 +30,25 @@ export const languageChildrenOptions = createChildrenOptions ({ - key: localeKey, - Component() { - return ( - - - - - {configuration.name} - - ({configuration.translatedName}) - - + .map(({ localeKey, configuration }) => + createCheckmarkPreferenceAction({ + key: localeKey, + isSelected: localeKey === currentLocale, + onSelect: () => setLocale(localeKey as never), + isPending, + labelContent: ( + + + + {configuration.name} + + ({configuration.translatedName}) + - {localeKey === currentLocale && } - ); - }, - useInteraction() { - const { changeLocale } = useChangeLocale(); - - return { type: "javaScript", onSelect: () => changeLocale(localeKey) }; - }, - })); - }, - DetailComponent: () => { - const t = useI18n(); - - return ( - - {t("search.mode.command.group.globalCommand.option.language.children.detail.title")} - - ); + ), + }), + ); }, + DetailComponent: () => , }); diff --git a/packages/spotlight/src/modes/command/children/preferences/action-row.tsx b/packages/spotlight/src/modes/command/children/preferences/action-row.tsx new file mode 100644 index 000000000..1e309a388 --- /dev/null +++ b/packages/spotlight/src/modes/command/children/preferences/action-row.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import { Center, Group, Loader, Stack, Text } from "@mantine/core"; +import { IconCheck } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import { interaction } from "../../../../lib/interaction"; + +export const PreferenceDetailHeader = ({ titleKey }: { titleKey: string }) => { + const tOption = useScopedI18n("search.mode.command.group.preferences.option"); + const t = useScopedI18n("search.mode.command.group.preferences"); + + return ( + + {tOption(titleKey as never)} + + {t("children.detail.backHint")} + + + ); +}; + +export const createCheckmarkPreferenceAction = ({ + key, + label, + labelContent, + isSelected, + onSelect, + dimmed, + isPending, +}: { + key: string; + label?: string; + labelContent?: ReactNode; + isSelected: boolean; + onSelect: () => void; + dimmed?: boolean; + isPending?: boolean; +}) => ({ + key, + Component() { + return ( + + {labelContent ?? {label}} + {isSelected && } + + ); + }, + useInteraction: interaction.javaScript(() => ({ + onSelect: isPending ? () => undefined : onSelect, + closeSpotlightOnTrigger: false, + })), +}); + +export const createLoadingPreferenceAction = () => ({ + key: "loading", + Component() { + return ( +
+ +
+ ); + }, + useInteraction: interaction.javaScript(() => ({ + onSelect: () => undefined, + closeSpotlightOnTrigger: false, + })), +}); diff --git a/packages/spotlight/src/modes/command/children/preferences/board.tsx b/packages/spotlight/src/modes/command/children/preferences/board.tsx new file mode 100644 index 000000000..ffa480d23 --- /dev/null +++ b/packages/spotlight/src/modes/command/children/preferences/board.tsx @@ -0,0 +1,47 @@ +import { useUserPreference } from "../../../../preferences/use-user-preference"; +import type { UserPreferenceKey } from "@homarr/settings"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { clientApi } from "@homarr/api/client"; + +import { createChildrenOptions } from "../../../../lib/children"; +import { createLoadingPreferenceAction, PreferenceDetailHeader } from "./action-row"; +import { createSelectablePreferenceActions } from "./selectable-actions"; + +interface BoardChildrenOptionsConfig { + preferenceKey: Extract; +} + +const boardDetailTitleKeys = { + homeBoardId: "homeBoardId.children.detail.title", + mobileHomeBoardId: "mobileHomeBoardId.children.detail.title", +} as const; + +export const createBoardChildrenOptions = (config: BoardChildrenOptionsConfig) => + createChildrenOptions>({ + useActions: (_, query) => { + const { value, setValue, isPending } = useUserPreference(config.preferenceKey); + const currentValue = value as string | null; + const boardsQuery = clientApi.board.getAllBoards.useQuery(); + const t = useScopedI18n("search.mode.command.group.preferences.option"); + + if (boardsQuery.isLoading) { + return [createLoadingPreferenceAction()]; + } + + return createSelectablePreferenceActions({ + query, + currentValue, + noneLabel: t("board.none"), + unavailableLabel: t("board.unavailable"), + items: (boardsQuery.data ?? []).map((board) => ({ + key: board.id, + label: board.name, + value: board.id, + })), + onSelect: (nextValue) => setValue(nextValue as never), + isPending, + }); + }, + DetailComponent: () => , + }); diff --git a/packages/spotlight/src/modes/command/children/preferences/color-scheme.tsx b/packages/spotlight/src/modes/command/children/preferences/color-scheme.tsx new file mode 100644 index 000000000..8e1cb157b --- /dev/null +++ b/packages/spotlight/src/modes/command/children/preferences/color-scheme.tsx @@ -0,0 +1,26 @@ +import type { ColorScheme } from "@homarr/definitions"; +import { colorSchemes } from "@homarr/definitions"; +import { useUserPreference } from "../../../../preferences/use-user-preference"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { createChildrenOptions } from "../../../../lib/children"; +import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./action-row"; + +export const colorSchemeChildrenOptions = createChildrenOptions>({ + useActions: () => { + const tOptions = useScopedI18n("common.colorScheme.options"); + const { value, setValue, isPending } = useUserPreference("colorScheme"); + const currentValue = value as ColorScheme; + + return colorSchemes.map((scheme) => + createCheckmarkPreferenceAction({ + key: scheme, + label: tOptions(scheme), + isSelected: currentValue === scheme, + onSelect: () => setValue(scheme as never), + isPending, + }), + ); + }, + DetailComponent: () => , +}); diff --git a/packages/spotlight/src/modes/command/children/preferences/first-day-of-week.tsx b/packages/spotlight/src/modes/command/children/preferences/first-day-of-week.tsx new file mode 100644 index 000000000..3b7ab56c6 --- /dev/null +++ b/packages/spotlight/src/modes/command/children/preferences/first-day-of-week.tsx @@ -0,0 +1,31 @@ +import type { DayOfWeek } from "@mantine/dates"; +import dayjs from "dayjs"; +import localeData from "dayjs/plugin/localeData"; + +import { useUserPreference } from "../../../../preferences/use-user-preference"; + +import { createChildrenOptions } from "../../../../lib/children"; +import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./action-row"; + +dayjs.extend(localeData); + +const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0]; +const weekDays = dayjs.weekdays(false); + +export const firstDayOfWeekChildrenOptions = createChildrenOptions>({ + useActions: () => { + const { value, setValue, isPending } = useUserPreference("firstDayOfWeek"); + const currentValue = value as DayOfWeek; + + return firstDayOfWeekOptions.map((day) => + createCheckmarkPreferenceAction({ + key: day.toString(), + label: weekDays[day] ?? day.toString(), + isSelected: currentValue === day, + onSelect: () => setValue(day as never), + isPending, + }), + ); + }, + DetailComponent: () => , +}); diff --git a/packages/spotlight/src/modes/command/children/preferences/index.tsx b/packages/spotlight/src/modes/command/children/preferences/index.tsx new file mode 100644 index 000000000..8eafebb6c --- /dev/null +++ b/packages/spotlight/src/modes/command/children/preferences/index.tsx @@ -0,0 +1 @@ +export { settingsChildrenOptions } from "./settings-children"; diff --git a/packages/spotlight/src/modes/command/children/preferences/registry.ts b/packages/spotlight/src/modes/command/children/preferences/registry.ts new file mode 100644 index 000000000..70839c2bd --- /dev/null +++ b/packages/spotlight/src/modes/command/children/preferences/registry.ts @@ -0,0 +1,52 @@ +import { + IconActivity, + IconCalendarWeek, + IconDeviceDesktop, + IconExternalLink, + IconHome, + IconLanguage, + IconLayoutBoard, + IconSearch, + IconSettings, + IconWorldWww, +} from "@tabler/icons-react"; + +import { userPreferenceDefinitions } from "@homarr/settings"; +import type { UserPreferenceKey } from "@homarr/settings"; +import type { TablerIcon } from "@homarr/ui"; + +import { createChildrenOptions } from "../../../../lib/children"; +import { languageChildrenOptions } from "../language"; +import { createBoardChildrenOptions } from "./board"; +import { colorSchemeChildrenOptions } from "./color-scheme"; +import { firstDayOfWeekChildrenOptions } from "./first-day-of-week"; +import { searchEngineChildrenOptions } from "./search-engine"; + +type ChildrenPreferenceKey = "colorScheme" | "locale" | "defaultSearchEngineId" | "firstDayOfWeek" | "homeBoardId" | "mobileHomeBoardId"; + +type PreferenceChildrenOptionsFactory = ReturnType>>; + +export const preferenceIcons: Record = { + colorScheme: IconDeviceDesktop, + locale: IconLanguage, + defaultSearchEngineId: IconSearch, + openSearchInNewTab: IconExternalLink, + ddgBangs: IconWorldWww, + firstDayOfWeek: IconCalendarWeek, + homeBoardId: IconHome, + mobileHomeBoardId: IconLayoutBoard, + pingIconsEnabled: IconActivity, + fullPreferencesPage: IconSettings, +}; + +export const preferenceChildrenOptionsByKey: Record = { + colorScheme: colorSchemeChildrenOptions, + locale: languageChildrenOptions, + defaultSearchEngineId: searchEngineChildrenOptions, + firstDayOfWeek: firstDayOfWeekChildrenOptions, + homeBoardId: createBoardChildrenOptions({ preferenceKey: "homeBoardId" }), + mobileHomeBoardId: createBoardChildrenOptions({ preferenceKey: "mobileHomeBoardId" }), +}; + +export const visiblePreferenceDefinitions = (isAuthenticated: boolean) => + userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated); diff --git a/packages/spotlight/src/modes/command/children/preferences/search-engine.tsx b/packages/spotlight/src/modes/command/children/preferences/search-engine.tsx new file mode 100644 index 000000000..5f0cc23f5 --- /dev/null +++ b/packages/spotlight/src/modes/command/children/preferences/search-engine.tsx @@ -0,0 +1,35 @@ +import { clientApi } from "@homarr/api/client"; +import { useUserPreference } from "../../../../preferences/use-user-preference"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { createChildrenOptions } from "../../../../lib/children"; +import { createLoadingPreferenceAction, PreferenceDetailHeader } from "./action-row"; +import { createSelectablePreferenceActions } from "./selectable-actions"; + +export const searchEngineChildrenOptions = createChildrenOptions>({ + useActions: (_, query) => { + const { value, setValue, isPending } = useUserPreference("defaultSearchEngineId"); + const currentValue = value as string | null; + const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(); + const t = useScopedI18n("search.mode.command.group.preferences.option"); + + if (searchEnginesQuery.isLoading) { + return [createLoadingPreferenceAction()]; + } + + return createSelectablePreferenceActions({ + query, + currentValue, + noneLabel: t("searchEngine.none"), + unavailableLabel: t("searchEngine.unavailable"), + items: (searchEnginesQuery.data ?? []).map((engine) => ({ + key: engine.value, + label: engine.label, + value: engine.value, + })), + onSelect: (nextValue) => setValue(nextValue as never), + isPending, + }); + }, + DetailComponent: () => , +}); diff --git a/packages/spotlight/src/modes/command/children/preferences/selectable-actions.tsx b/packages/spotlight/src/modes/command/children/preferences/selectable-actions.tsx new file mode 100644 index 000000000..51e7e9cd8 --- /dev/null +++ b/packages/spotlight/src/modes/command/children/preferences/selectable-actions.tsx @@ -0,0 +1,55 @@ +import { createCheckmarkPreferenceAction } from "./action-row"; + +interface SelectablePreferenceItem { + key: string; + label: string; + value: string; +} + +export const createSelectablePreferenceActions = ({ + query, + currentValue, + noneLabel, + unavailableLabel, + items, + onSelect, + isPending, +}: { + query: string; + currentValue: string | null; + noneLabel: string; + unavailableLabel: string; + items: SelectablePreferenceItem[]; + onSelect: (value: string | null) => void; + isPending?: boolean; +}) => { + const hasCurrentSelection = currentValue !== null && !items.some((item) => item.value === currentValue); + const orphanedItem: SelectablePreferenceItem | null = hasCurrentSelection + ? { key: `orphaned-${currentValue}`, label: unavailableLabel, value: currentValue } + : null; + const normalizedQuery = query.trim().toLowerCase(); + const filteredItems = items.filter((item) => item.label.toLowerCase().includes(normalizedQuery)); + const visibleItems = orphanedItem ? [orphanedItem, ...filteredItems] : filteredItems; + + const noneAction = createCheckmarkPreferenceAction({ + key: "none", + label: noneLabel, + isSelected: currentValue === null, + onSelect: () => onSelect(null), + dimmed: true, + isPending, + }); + + const itemActions = visibleItems.map((item) => + createCheckmarkPreferenceAction({ + key: item.key, + label: item.label, + isSelected: currentValue === item.value, + onSelect: () => onSelect(item.value), + dimmed: item.key.startsWith("orphaned-"), + isPending, + }), + ); + + return [noneAction, ...itemActions]; +}; diff --git a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx new file mode 100644 index 000000000..01d57e1c9 --- /dev/null +++ b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { Group, Stack, Switch, Text } from "@mantine/core"; +import type { DayOfWeek } from "@mantine/dates"; +import dayjs from "dayjs"; +import localeData from "dayjs/plugin/localeData"; + +import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; +import type { ColorScheme } from "@homarr/definitions"; +import type { UserPreferenceKey } from "@homarr/settings"; +import { localeConfigurations } from "@homarr/translation"; +import { useScopedI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; + +import { createChildrenOptions } from "../../../../lib/children"; +import type { ChildrenAction } from "../../../../lib/children"; +import { interaction } from "../../../../lib/interaction"; +import { useUserPreference } from "../../../../preferences/use-user-preference"; +import { preferenceChildrenOptionsByKey, preferenceIcons, visiblePreferenceDefinitions } from "./registry"; + +dayjs.extend(localeData); + +type SettingsAction = ChildrenAction>; + +const weekDays = dayjs.weekdays(false); + +const BooleanRow = ({ label, Icon, checked, isPending }: { label: string; Icon: TablerIcon; checked: boolean; isPending: boolean }) => ( + + + + {label} + + + +); + +const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel: string }) => ( + + + + {label} + + {valueLabel} + +); + +const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => ( + + + {label} + +); + +const useSettingsActions = (_: Record, query: string): SettingsAction[] => { + const t = useScopedI18n("search.mode.command.group.preferences.option"); + const tColorScheme = useScopedI18n("common.colorScheme.options"); + const { data: session } = useSession(); + const isAuthenticated = Boolean(session?.user); + const normalizedQuery = query.trim().toLowerCase(); + + const colorScheme = useUserPreference("colorScheme"); + const locale = useUserPreference("locale"); + const defaultSearchEngineId = useUserPreference("defaultSearchEngineId"); + const openSearchInNewTab = useUserPreference("openSearchInNewTab"); + const ddgBangs = useUserPreference("ddgBangs"); + const firstDayOfWeek = useUserPreference("firstDayOfWeek"); + const homeBoardId = useUserPreference("homeBoardId"); + const mobileHomeBoardId = useUserPreference("mobileHomeBoardId"); + const pingIconsEnabled = useUserPreference("pingIconsEnabled"); + + const boardsQuery = clientApi.board.getAllBoards.useQuery(); + const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(); + + const boards = boardsQuery.data ?? []; + const engines = searchEnginesQuery.data ?? []; + + const prefState: Record, { value: unknown; setValue: (v: never) => void; isPending: boolean }> = { + colorScheme, + locale, + defaultSearchEngineId, + openSearchInNewTab, + ddgBangs, + firstDayOfWeek, + homeBoardId, + mobileHomeBoardId, + pingIconsEnabled, + }; + + const valueLabels: Record, string> = { + colorScheme: tColorScheme(colorScheme.value as ColorScheme), + locale: localeConfigurations[locale.value as keyof typeof localeConfigurations]?.name ?? String(locale.value), + defaultSearchEngineId: engines.find((e) => e.value === defaultSearchEngineId.value)?.label ?? t("searchEngine.none"), + openSearchInNewTab: "", + ddgBangs: "", + firstDayOfWeek: weekDays[firstDayOfWeek.value as DayOfWeek] ?? String(firstDayOfWeek.value), + homeBoardId: boards.find((b) => b.id === homeBoardId.value)?.name ?? t("board.none"), + mobileHomeBoardId: boards.find((b) => b.id === mobileHomeBoardId.value)?.name ?? t("board.none"), + pingIconsEnabled: "", + }; + + const definitions = visiblePreferenceDefinitions(isAuthenticated); + + return definitions.flatMap((definition): SettingsAction[] => { + const label = t(`${definition.key}.label` as never); + const Icon = preferenceIcons[definition.key]; + + if (!label.toLowerCase().includes(normalizedQuery)) return []; + + if (definition.kind === "boolean") { + const state = prefState[definition.key as keyof typeof prefState]; + const checked = state.value as boolean; + return [{ + key: definition.key, + Component: () => , + useInteraction: interaction.javaScript(() => ({ + onSelect: () => state.setValue(!checked as never), + closeSpotlightOnTrigger: false, + })), + }]; + } + + if (definition.kind === "link") { + return [{ + key: definition.key, + Component: () => , + useInteraction: interaction.link(() => ({ + href: `/manage/users/${session?.user.id ?? ""}/general`, + })), + }]; + } + + const childrenKey = definition.key as keyof typeof preferenceChildrenOptionsByKey; + return [{ + key: definition.key, + Component: () => , + useInteraction: interaction.children(preferenceChildrenOptionsByKey[childrenKey]), + }]; + }); +}; + +export const settingsChildrenOptions = createChildrenOptions>({ + useActions: useSettingsActions, + DetailComponent: () => { + const t = useScopedI18n("search.mode.command.group.preferences"); + return ( + + {t("title")} + {t("children.detail.backHint")} + + ); + }, +}); + diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx index 4d2f60d37..f1d44e434 100644 --- a/packages/spotlight/src/modes/command/global-group.tsx +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -1,14 +1,10 @@ -import { Group, Text, useComputedColorScheme, useMantineColorScheme } from "@mantine/core"; +import { Group, Text } from "@mantine/core"; import { IconBox, IconCategoryPlus, - IconDeviceDesktop, IconFileImport, - IconLanguage, IconMailForward, - IconMoon, IconPlug, - IconSun, IconUserPlus, IconUsersGroup, } from "@tabler/icons-react"; @@ -22,7 +18,6 @@ import type { TablerIcon } from "@homarr/ui"; import { createGroup } from "../../lib/group"; import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction"; -import { languageChildrenOptions } from "./children/language"; import { newIntegrationChildrenOptions } from "./children/new-integration"; // This has to be type so it can be interpreted as Record. @@ -52,33 +47,9 @@ export const globalCommandGroup = createGroup({ }, useOptions() { const tOption = useScopedI18n("search.mode.command.group.globalCommand.option"); - const { colorScheme } = useMantineColorScheme(); - const computedColorScheme = useComputedColorScheme("light"); const { data: session } = useSession(); const commands: (Command & { hidden?: boolean })[] = [ - { - commandKey: "colorScheme", - icon: colorScheme === "auto" ? IconDeviceDesktop : computedColorScheme === "dark" ? IconSun : IconMoon, - name: - colorScheme === "auto" - ? tOption("colorScheme.auto") - : tOption(`colorScheme.${computedColorScheme === "dark" ? "light" : "dark"}`), - useInteraction: () => { - const { toggleColorScheme } = useMantineColorScheme(); - - return { - type: "javaScript", - onSelect: toggleColorScheme, - }; - }, - }, - { - commandKey: "language", - icon: IconLanguage, - name: tOption("language.label"), - useInteraction: interaction.children(languageChildrenOptions), - }, { commandKey: "newBoard", icon: IconCategoryPlus, diff --git a/packages/spotlight/src/modes/command/index.tsx b/packages/spotlight/src/modes/command/index.tsx index eee549dd8..30d8db633 100644 --- a/packages/spotlight/src/modes/command/index.tsx +++ b/packages/spotlight/src/modes/command/index.tsx @@ -1,9 +1,10 @@ import type { SearchMode } from "../../lib/mode"; import { contextSpecificActionsSearchGroups } from "./context-specific-group"; import { globalCommandGroup } from "./global-group"; +import { preferencesGroup } from "../preferences/groups"; export const commandMode = { modeKey: "command", character: ">", - groups: [contextSpecificActionsSearchGroups, globalCommandGroup], + groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup], } satisfies SearchMode; diff --git a/packages/spotlight/src/modes/help/home-empty-groups.tsx b/packages/spotlight/src/modes/help/home-empty-groups.tsx index e035e66b9..977d3c8c7 100644 --- a/packages/spotlight/src/modes/help/home-empty-groups.tsx +++ b/packages/spotlight/src/modes/help/home-empty-groups.tsx @@ -20,11 +20,11 @@ import { appIntegrationBoardMode } from "../app-integration-board"; import { commandMode } from "../command"; import { externalMode } from "../external"; import { pageMode } from "../page"; +import { preferencesGroup } from "../preferences/groups"; import { userGroupMode } from "../user-group"; -// We need type to allow it as generic type for Record -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions type QuickLinkOption = { + key: string; icon: TablerIcon; name: string; path: string; @@ -41,19 +41,13 @@ export const useHomeEmptyGroups = () => { return [ createGroup({ - keyPath: "path", + keyPath: "key", title: () => "Quick links", options: (() => { const quickLinks: QuickLinkOption[] = []; - if (session?.user) { - quickLinks.push({ - icon: IconSettings, - path: `/manage/users/${session.user.id}/general`, - name: tPages("preferences.label"), - }); - } quickLinks.push({ + key: "manageBoards", icon: IconLayoutDashboard, path: "/manage/boards", name: tPages("manageBoard.label"), @@ -61,6 +55,7 @@ export const useHomeEmptyGroups = () => { if (session?.user.permissions.includes("admin")) { quickLinks.push({ + key: "manageSettings", icon: IconSettings, path: "/manage/settings", name: tPages("manageSettings.label"), @@ -69,6 +64,7 @@ export const useHomeEmptyGroups = () => { if (session) { quickLinks.push({ + key: "manageSearchEngines", icon: IconSearch, path: "/manage/search-engines", name: tPages("manageSearchEngine.label"), @@ -138,3 +134,5 @@ export const useHomeEmptyGroups = () => { }), ] as const; }; + +export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()]; diff --git a/packages/spotlight/src/modes/home/index.tsx b/packages/spotlight/src/modes/home/index.tsx index 7851e6a39..67ef6db2a 100644 --- a/packages/spotlight/src/modes/home/index.tsx +++ b/packages/spotlight/src/modes/home/index.tsx @@ -1,9 +1,10 @@ import type { SearchMode } from "../../lib/mode"; +import { preferencesGroup } from "../preferences/groups"; import { contextSpecificSearchGroups } from "./context-specific-group"; import { homeSearchEngineGroup } from "./home-search-engine-group"; export const homeMode = { character: undefined, modeKey: "home", - groups: [homeSearchEngineGroup, contextSpecificSearchGroups], + groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups], } satisfies SearchMode; diff --git a/packages/spotlight/src/modes/preferences/groups.tsx b/packages/spotlight/src/modes/preferences/groups.tsx new file mode 100644 index 000000000..4a0ccd08f --- /dev/null +++ b/packages/spotlight/src/modes/preferences/groups.tsx @@ -0,0 +1,25 @@ +import { Group, Text } from "@mantine/core"; +import { IconSettings } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import { createGroup } from "../../lib/group"; +import { interaction } from "../../lib/interaction"; +import { settingsChildrenOptions } from "../command/children/preferences/settings-children"; + +export const preferencesGroup = createGroup({ + keyPath: "key", + title: (t) => t("search.mode.command.group.preferences.title"), + options: [{ key: "settings" }], + Component: () => { + const t = useScopedI18n("search.mode.command.group.preferences"); + return ( + + + {t("title")} + + ); + }, + filter: (_query, _option) => true, + useInteraction: interaction.children(settingsChildrenOptions), +}); diff --git a/packages/spotlight/src/preferences/use-user-preference.ts b/packages/spotlight/src/preferences/use-user-preference.ts new file mode 100644 index 000000000..214df50b6 --- /dev/null +++ b/packages/spotlight/src/preferences/use-user-preference.ts @@ -0,0 +1,207 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMantineColorScheme } from "@mantine/core"; +import type { DayOfWeek } from "@mantine/dates"; + +import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; +import type { ColorScheme } from "@homarr/definitions"; +import type { SettingsContextProps } from "@homarr/settings/creator"; +import type { UserPreferenceKey } from "@homarr/settings"; +import { useSettings } from "@homarr/settings"; +import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client"; + +const optimisticPreferenceKeys = new Set([ + "defaultSearchEngineId", + "openSearchInNewTab", + "ddgBangs", + "firstDayOfWeek", + "homeBoardId", + "mobileHomeBoardId", + "pingIconsEnabled", +]); + +const optimisticPreferencesRef: { current: Partial> } = { current: {} }; + +const searchPreferenceKeys = ["defaultSearchEngineId", "openSearchInNewTab", "ddgBangs"] as const; +const homeBoardPreferenceKeys = ["homeBoardId", "mobileHomeBoardId"] as const; + +const clearOptimisticPreferences = (keys: readonly UserPreferenceKey[]) => { + keys.forEach((preferenceKey) => { + delete optimisticPreferencesRef.current[preferenceKey]; + }); +}; + +const getEffectivePreferenceValue = (preferenceKey: UserPreferenceKey, settings: SettingsContextProps): unknown => { + const optimisticValue = optimisticPreferencesRef.current[preferenceKey]; + if (optimisticValue !== undefined) { + return optimisticValue; + } + + const settingsValues: Partial> = { + defaultSearchEngineId: settings.defaultSearchEngineId, + openSearchInNewTab: settings.openSearchInNewTab, + ddgBangs: settings.ddgBangs, + firstDayOfWeek: settings.firstDayOfWeek, + homeBoardId: settings.homeBoardId, + mobileHomeBoardId: settings.mobileHomeBoardId, + pingIconsEnabled: settings.pingIconsEnabled, + }; + + return settingsValues[preferenceKey]; +}; + +const refreshAfterMutation = (router: ReturnType) => { + router.refresh(); +}; + +export const useUserPreference = (key: UserPreferenceKey) => { + const router = useRouter(); + const settings = useSettings(); + const settingsRef = useRef(settings); + settingsRef.current = settings; + + const { data: session } = useSession(); + const { colorScheme, setColorScheme } = useMantineColorScheme(); + const currentLocale = useCurrentLocale(); + const { changeLocale } = useChangeLocale(); + const [optimisticValue, setOptimisticValue] = useState(); + + const { mutate: mutateSearchPreferences, isPending: isSearchPreferencesPending } = + clientApi.user.changeSearchPreferences.useMutation({ + onSuccess: () => refreshAfterMutation(router), + onError: () => clearOptimisticPreferences(searchPreferenceKeys), + }); + + const { mutate: mutateHomeBoards, isPending: isHomeBoardsPending } = clientApi.user.changeHomeBoards.useMutation({ + onSuccess: () => refreshAfterMutation(router), + onError: () => clearOptimisticPreferences(homeBoardPreferenceKeys), + }); + + const { mutate: mutateFirstDayOfWeek, isPending: isFirstDayOfWeekPending } = + clientApi.user.changeFirstDayOfWeek.useMutation({ + onSuccess: () => refreshAfterMutation(router), + onError: () => clearOptimisticPreferences(["firstDayOfWeek"]), + }); + + const { mutate: mutatePingIconsEnabled, isPending: isPingIconsPending } = + clientApi.user.changePingIconsEnabled.useMutation({ + onSuccess: () => refreshAfterMutation(router), + onError: () => clearOptimisticPreferences(["pingIconsEnabled"]), + }); + + const userId = session?.user.id; + + const persistedValues: Record = { + colorScheme: colorScheme as ColorScheme, + locale: currentLocale, + defaultSearchEngineId: settings.defaultSearchEngineId, + openSearchInNewTab: settings.openSearchInNewTab, + ddgBangs: settings.ddgBangs, + firstDayOfWeek: settings.firstDayOfWeek, + homeBoardId: settings.homeBoardId, + mobileHomeBoardId: settings.mobileHomeBoardId, + pingIconsEnabled: settings.pingIconsEnabled, + fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null, + }; + + const persistedValue = persistedValues[key]; + + useEffect(() => { + delete optimisticPreferencesRef.current[key]; + setOptimisticValue(undefined); + }, [persistedValue, key]); + + const setPersistedValue = (value: never) => { + const currentSettings = settingsRef.current; + + const setters: Record void> = { + colorScheme: (nextValue) => setColorScheme(nextValue as ColorScheme), + locale: (nextValue) => changeLocale(nextValue as typeof currentLocale), + defaultSearchEngineId: (nextValue) => { + if (!userId) return; + mutateSearchPreferences({ + userId, + defaultSearchEngineId: nextValue as string | null, + openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, + ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, + }); + }, + openSearchInNewTab: (nextValue) => { + if (!userId) return; + mutateSearchPreferences({ + userId, + defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as string | null, + openInNewTab: nextValue as boolean, + ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, + }); + }, + ddgBangs: (nextValue) => { + if (!userId) return; + mutateSearchPreferences({ + userId, + defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as string | null, + openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, + ddgBangsEnabled: nextValue as boolean, + }); + }, + firstDayOfWeek: (nextValue) => { + if (!userId) return; + mutateFirstDayOfWeek({ id: userId, firstDayOfWeek: nextValue as DayOfWeek }); + }, + homeBoardId: (nextValue) => { + if (!userId) return; + mutateHomeBoards({ + userId, + homeBoardId: nextValue as string | null, + mobileHomeBoardId: getEffectivePreferenceValue("mobileHomeBoardId", currentSettings) as string | null, + }); + }, + mobileHomeBoardId: (nextValue) => { + if (!userId) return; + mutateHomeBoards({ + userId, + homeBoardId: getEffectivePreferenceValue("homeBoardId", currentSettings) as string | null, + mobileHomeBoardId: nextValue as string | null, + }); + }, + pingIconsEnabled: (nextValue) => { + if (!userId) return; + mutatePingIconsEnabled({ id: userId, pingIconsEnabled: nextValue as boolean }); + }, + fullPreferencesPage: () => undefined, + }; + + setters[key](value); + }; + + const setValue = (value: never) => { + if (optimisticPreferenceKeys.has(key)) { + optimisticPreferencesRef.current[key] = value; + setOptimisticValue(value); + } + + setPersistedValue(value); + }; + + const pendingStates: Record = { + colorScheme: false, + locale: false, + defaultSearchEngineId: isSearchPreferencesPending, + openSearchInNewTab: isSearchPreferencesPending, + ddgBangs: isSearchPreferencesPending, + firstDayOfWeek: isFirstDayOfWeekPending, + homeBoardId: isHomeBoardsPending, + mobileHomeBoardId: isHomeBoardsPending, + pingIconsEnabled: isPingIconsPending, + fullPreferencesPage: false, + }; + + return { + value: optimisticPreferencesRef.current[key] ?? optimisticValue ?? persistedValue, + setValue, + isPending: pendingStates[key], + }; +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a62728845..c7387d24b 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4547,6 +4547,107 @@ "label": "Create a new group" } } + }, + "preferences": { + "title": "Preferences", + "children": { + "detail": { + "title": "Select a preference to change", + "backHint": "Press Backspace to go back" + } + }, + "option": { + "preferences": { + "label": "Open preferences" + }, + "boolean": { + "enabled": "Enabled", + "disabled": "Disabled" + }, + "board": { + "none": "None", + "unavailable": "Currently selected (unavailable)" + }, + "searchEngine": { + "none": "None", + "unavailable": "Currently selected (unavailable)" + }, + "colorScheme": { + "label": "Color scheme", + "children": { + "detail": { + "title": "Select your color scheme" + } + } + }, + "locale": { + "label": "Language", + "children": { + "detail": { + "title": "Select your preferred language" + } + } + }, + "defaultSearchEngineId": { + "label": "Default search engine", + "children": { + "detail": { + "title": "Select your default search engine" + } + } + }, + "openSearchInNewTab": { + "label": "Open search results in new tab", + "children": { + "detail": { + "title": "Open search results in new tab" + } + } + }, + "ddgBangs": { + "label": "Enable DuckDuckGo bangs", + "children": { + "detail": { + "title": "Enable DuckDuckGo bangs" + } + } + }, + "firstDayOfWeek": { + "label": "First day of the week", + "children": { + "detail": { + "title": "Select the first day of the week" + } + } + }, + "homeBoardId": { + "label": "Home board", + "children": { + "detail": { + "title": "Select your home board" + } + } + }, + "mobileHomeBoardId": { + "label": "Mobile home board", + "children": { + "detail": { + "title": "Select your mobile home board" + } + } + }, + "pingIconsEnabled": { + "label": "Use icons for pings", + "children": { + "detail": { + "title": "Use icons for pings" + } + } + }, + "fullPreferencesPage": { + "label": "Open full preferences page" + } + } } } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d09d3fd6c..c2a6bb7bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3264,9 +3264,6 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - '@floating-ui/core@1.7.3': - resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} - '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -10454,18 +10451,13 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 - '@floating-ui/core@1.7.3': - dependencies: - '@floating-ui/utils': 0.2.11 - optional: true - '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 '@floating-ui/dom@1.7.4': dependencies: - '@floating-ui/core': 1.7.3 + '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 optional: true From e182d28605c87066f6e50b11aa21777a04543e3e Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 29 May 2026 16:23:29 +0200 Subject: [PATCH 10/16] continue working on user prefernces in command palette --- packages/api/src/router/user.ts | 4 +- .../settings/src/preferences/definitions.ts | 20 +- packages/spotlight/package.json | 1 + .../spotlight/src/components/spotlight.tsx | 28 +- .../src/modes/command/children/language.tsx | 2 +- .../command/children/preferences/registry.ts | 10 +- .../preferences/settings-children.tsx | 105 +++--- .../src/modes/preferences/groups.tsx | 77 ++++- .../src/preferences/use-user-preference.ts | 318 +++++++++++++----- packages/translation/src/lang/en.json | 8 +- pnpm-lock.yaml | 3 + 11 files changed, 415 insertions(+), 161 deletions(-) diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 9c4f16c80..53a7cd08f 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -542,7 +542,7 @@ export const userRouter = createTRPCRouter({ .set({ pingIconsEnabled: input.pingIconsEnabled, }) - .where(eq(users.id, ctx.session.user.id)); + .where(eq(users.id, input.id)); }), changeDdgBangs: protectedProcedure .input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema))) @@ -603,7 +603,7 @@ export const userRouter = createTRPCRouter({ .set({ firstDayOfWeek: input.firstDayOfWeek, }) - .where(eq(users.id, ctx.session.user.id)); + .where(eq(users.id, input.id)); }), }); diff --git a/packages/settings/src/preferences/definitions.ts b/packages/settings/src/preferences/definitions.ts index c9de2a91f..6575cb74f 100644 --- a/packages/settings/src/preferences/definitions.ts +++ b/packages/settings/src/preferences/definitions.ts @@ -1,14 +1,14 @@ export const userPreferenceDefinitions = [ - { key: "colorScheme", kind: "select", guest: true }, - { key: "locale", kind: "select", guest: true }, - { key: "defaultSearchEngineId", kind: "searchEngine", guest: false }, - { key: "openSearchInNewTab", kind: "boolean", guest: false }, - { key: "ddgBangs", kind: "boolean", guest: false }, - { key: "firstDayOfWeek", kind: "select", guest: false }, - { key: "homeBoardId", kind: "board", guest: false }, - { key: "mobileHomeBoardId", kind: "board", guest: false }, - { key: "pingIconsEnabled", kind: "boolean", guest: false }, - { key: "fullPreferencesPage", kind: "link", guest: false }, + { key: "colorScheme", kind: "select", guest: true, aliases: ["theme", "dark", "light", "auto"] }, + { key: "locale", kind: "select", guest: true, aliases: ["language", "translation"] }, + { key: "defaultSearchEngineId", kind: "searchEngine", guest: false, aliases: ["search", "search engine"] }, + { key: "openSearchInNewTab", kind: "boolean", guest: false, aliases: ["search", "new tab", "external"] }, + { key: "ddgBangs", kind: "boolean", guest: false, aliases: ["duckduckgo", "bangs", "search"] }, + { key: "firstDayOfWeek", kind: "select", guest: false, aliases: ["calendar", "week", "weekday"] }, + { key: "homeBoardId", kind: "board", guest: false, aliases: ["home", "board", "dashboard"] }, + { key: "mobileHomeBoardId", kind: "board", guest: false, aliases: ["mobile", "home", "board", "dashboard"] }, + { key: "pingIconsEnabled", kind: "boolean", guest: false, aliases: ["ping", "icon", "status"] }, + { key: "fullPreferencesPage", kind: "link", guest: false, aliases: ["settings", "manage", "profile"] }, ] as const; export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number]; diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json index a3a36a3da..8dc8f6e01 100644 --- a/packages/spotlight/package.json +++ b/packages/spotlight/package.json @@ -29,6 +29,7 @@ "@homarr/integrations": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0", + "@homarr/notifications": "workspace:^0.1.0", "@homarr/settings": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0", diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx index 8e744c0a2..1ea06198e 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -4,7 +4,7 @@ import type { Dispatch, SetStateAction } from "react"; import { useCallback, 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 { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react"; import { hotkeys } from "@homarr/definitions"; import type { TranslationObject } from "@homarr/translation"; @@ -58,11 +58,14 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh setChildrenStack([]); }, []); - const pushChildrenOptions = useCallback((options: ChildrenOptions) => { - setChildrenStack((currentStack) => [...currentStack, options]); - setQuery(""); - setTimeout(() => selectAction(0, spotlightStore)); - }, [setQuery]); + const pushChildrenOptions = useCallback( + (options: ChildrenOptions) => { + setChildrenStack((currentStack) => [...currentStack, options]); + setQuery(""); + setTimeout(() => selectAction(0, spotlightStore)); + }, + [setQuery], + ); const popChildrenOptions = useCallback(() => { setChildrenStack((currentStack) => currentStack.slice(0, -1)); @@ -117,6 +120,11 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh rightSection={ mode === defaultMode && childrenStack.length === 0 ? null : ( 0 + ? t("search.mode.command.group.preferences.children.detail.backAction") + : t("common.action.close") + } onClick={() => { if (childrenStack.length > 0) { popChildrenOptions(); @@ -129,9 +137,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh inputRef.current?.focus(); }} variant="subtle" - aria-label={t("common.action.close")} + aria-label={ + childrenStack.length > 0 + ? t("search.mode.command.group.preferences.children.detail.backAction") + : t("common.action.close") + } > - + {childrenStack.length > 0 ? : } ) } diff --git a/packages/spotlight/src/modes/command/children/language.tsx b/packages/spotlight/src/modes/command/children/language.tsx index 1876b3930..e73f91d84 100644 --- a/packages/spotlight/src/modes/command/children/language.tsx +++ b/packages/spotlight/src/modes/command/children/language.tsx @@ -19,7 +19,7 @@ export const languageChildrenOptions = createChildrenOptions Math.min( languageA.configuration.name.toLowerCase().indexOf(normalizedQuery), diff --git a/packages/spotlight/src/modes/command/children/preferences/registry.ts b/packages/spotlight/src/modes/command/children/preferences/registry.ts index 70839c2bd..9baf13397 100644 --- a/packages/spotlight/src/modes/command/children/preferences/registry.ts +++ b/packages/spotlight/src/modes/command/children/preferences/registry.ts @@ -15,14 +15,20 @@ import { userPreferenceDefinitions } from "@homarr/settings"; import type { UserPreferenceKey } from "@homarr/settings"; import type { TablerIcon } from "@homarr/ui"; -import { createChildrenOptions } from "../../../../lib/children"; +import type { createChildrenOptions } from "../../../../lib/children"; import { languageChildrenOptions } from "../language"; import { createBoardChildrenOptions } from "./board"; import { colorSchemeChildrenOptions } from "./color-scheme"; import { firstDayOfWeekChildrenOptions } from "./first-day-of-week"; import { searchEngineChildrenOptions } from "./search-engine"; -type ChildrenPreferenceKey = "colorScheme" | "locale" | "defaultSearchEngineId" | "firstDayOfWeek" | "homeBoardId" | "mobileHomeBoardId"; +type ChildrenPreferenceKey = + | "colorScheme" + | "locale" + | "defaultSearchEngineId" + | "firstDayOfWeek" + | "homeBoardId" + | "mobileHomeBoardId"; type PreferenceChildrenOptionsFactory = ReturnType>>; diff --git a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx index 01d57e1c9..07c46efd5 100644 --- a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx +++ b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx @@ -16,7 +16,7 @@ import type { TablerIcon } from "@homarr/ui"; import { createChildrenOptions } from "../../../../lib/children"; import type { ChildrenAction } from "../../../../lib/children"; import { interaction } from "../../../../lib/interaction"; -import { useUserPreference } from "../../../../preferences/use-user-preference"; +import { useUserPreferences } from "../../../../preferences/use-user-preference"; import { preferenceChildrenOptionsByKey, preferenceIcons, visiblePreferenceDefinitions } from "./registry"; dayjs.extend(localeData); @@ -25,7 +25,17 @@ type SettingsAction = ChildrenAction>; const weekDays = dayjs.weekdays(false); -const BooleanRow = ({ label, Icon, checked, isPending }: { label: string; Icon: TablerIcon; checked: boolean; isPending: boolean }) => ( +const BooleanRow = ({ + label, + Icon, + checked, + isPending, +}: { + label: string; + Icon: TablerIcon; + checked: boolean; + isPending: boolean; +}) => ( @@ -41,7 +51,9 @@ const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIco {label} - {valueLabel} + + {valueLabel} + ); @@ -58,24 +70,28 @@ const useSettingsActions = (_: Record, query: string): Settings const { data: session } = useSession(); const isAuthenticated = Boolean(session?.user); const normalizedQuery = query.trim().toLowerCase(); + const preferences = useUserPreferences(); - const colorScheme = useUserPreference("colorScheme"); - const locale = useUserPreference("locale"); - const defaultSearchEngineId = useUserPreference("defaultSearchEngineId"); - const openSearchInNewTab = useUserPreference("openSearchInNewTab"); - const ddgBangs = useUserPreference("ddgBangs"); - const firstDayOfWeek = useUserPreference("firstDayOfWeek"); - const homeBoardId = useUserPreference("homeBoardId"); - const mobileHomeBoardId = useUserPreference("mobileHomeBoardId"); - const pingIconsEnabled = useUserPreference("pingIconsEnabled"); + const colorScheme = preferences.getPreference("colorScheme"); + const locale = preferences.getPreference("locale"); + const defaultSearchEngineId = preferences.getPreference("defaultSearchEngineId"); + const openSearchInNewTab = preferences.getPreference("openSearchInNewTab"); + const ddgBangs = preferences.getPreference("ddgBangs"); + const firstDayOfWeek = preferences.getPreference("firstDayOfWeek"); + const homeBoardId = preferences.getPreference("homeBoardId"); + const mobileHomeBoardId = preferences.getPreference("mobileHomeBoardId"); + const pingIconsEnabled = preferences.getPreference("pingIconsEnabled"); - const boardsQuery = clientApi.board.getAllBoards.useQuery(); - const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(); + const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated }); + const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated }); const boards = boardsQuery.data ?? []; const engines = searchEnginesQuery.data ?? []; - const prefState: Record, { value: unknown; setValue: (v: never) => void; isPending: boolean }> = { + const prefState: Record< + Exclude, + { value: unknown; setValue: (v: never) => void; isPending: boolean } + > = { colorScheme, locale, defaultSearchEngineId, @@ -90,7 +106,8 @@ const useSettingsActions = (_: Record, query: string): Settings const valueLabels: Record, string> = { colorScheme: tColorScheme(colorScheme.value as ColorScheme), locale: localeConfigurations[locale.value as keyof typeof localeConfigurations]?.name ?? String(locale.value), - defaultSearchEngineId: engines.find((e) => e.value === defaultSearchEngineId.value)?.label ?? t("searchEngine.none"), + defaultSearchEngineId: + engines.find((e) => e.value === defaultSearchEngineId.value)?.label ?? t("searchEngine.none"), openSearchInNewTab: "", ddgBangs: "", firstDayOfWeek: weekDays[firstDayOfWeek.value as DayOfWeek] ?? String(firstDayOfWeek.value), @@ -105,37 +122,46 @@ const useSettingsActions = (_: Record, query: string): Settings const label = t(`${definition.key}.label` as never); const Icon = preferenceIcons[definition.key]; - if (!label.toLowerCase().includes(normalizedQuery)) return []; + const matchesSearch = [label, ...definition.aliases].some((value) => value.toLowerCase().includes(normalizedQuery)); + if (!matchesSearch) return []; if (definition.kind === "boolean") { const state = prefState[definition.key as keyof typeof prefState]; const checked = state.value as boolean; - return [{ - key: definition.key, - Component: () => , - useInteraction: interaction.javaScript(() => ({ - onSelect: () => state.setValue(!checked as never), - closeSpotlightOnTrigger: false, - })), - }]; + return [ + { + key: definition.key, + Component: () => , + useInteraction: interaction.javaScript(() => ({ + onSelect: state.isPending ? () => undefined : () => state.setValue(!checked as never), + closeSpotlightOnTrigger: false, + })), + }, + ]; } if (definition.kind === "link") { - return [{ - key: definition.key, - Component: () => , - useInteraction: interaction.link(() => ({ - href: `/manage/users/${session?.user.id ?? ""}/general`, - })), - }]; + return [ + { + key: definition.key, + Component: () => , + useInteraction: interaction.link(() => ({ + href: `/manage/users/${session?.user.id ?? ""}/general`, + })), + }, + ]; } const childrenKey = definition.key as keyof typeof preferenceChildrenOptionsByKey; - return [{ - key: definition.key, - Component: () => , - useInteraction: interaction.children(preferenceChildrenOptionsByKey[childrenKey]), - }]; + return [ + { + key: definition.key, + Component: () => ( + + ), + useInteraction: interaction.children(preferenceChildrenOptionsByKey[childrenKey]), + }, + ]; }); }; @@ -146,9 +172,10 @@ export const settingsChildrenOptions = createChildrenOptions {t("title")} - {t("children.detail.backHint")} + + {t("children.detail.backHint")} +
); }, }); - diff --git a/packages/spotlight/src/modes/preferences/groups.tsx b/packages/spotlight/src/modes/preferences/groups.tsx index 4a0ccd08f..4b3db8dc4 100644 --- a/packages/spotlight/src/modes/preferences/groups.tsx +++ b/packages/spotlight/src/modes/preferences/groups.tsx @@ -1,25 +1,84 @@ import { Group, Text } from "@mantine/core"; import { IconSettings } from "@tabler/icons-react"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useSession } from "@homarr/auth/client"; +import { colorSchemes } from "@homarr/definitions"; +import { userPreferenceDefinitions } from "@homarr/settings"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { createGroup } from "../../lib/group"; -import { interaction } from "../../lib/interaction"; +import { preferenceChildrenOptionsByKey, preferenceIcons } from "../command/children/preferences/registry"; import { settingsChildrenOptions } from "../command/children/preferences/settings-children"; -export const preferencesGroup = createGroup({ +const normalizeSearchValue = (value: string) => value.trim().toLowerCase(); + +type PreferenceChildKey = Extract; + +type PreferencesGroupOption = + | { + key: "settings"; + preferenceKey: null; + } + | { + key: PreferenceChildKey; + preferenceKey: PreferenceChildKey; + }; + +const settingsOption = { key: "settings", preferenceKey: null } satisfies PreferencesGroupOption; + +export const preferencesGroup = createGroup({ keyPath: "key", title: (t) => t("search.mode.command.group.preferences.title"), - options: [{ key: "settings" }], - Component: () => { + useOptions(query) { + const t = useI18n(); + const tColorScheme = useScopedI18n("common.colorScheme.options"); + const { data: session } = useSession(); + const isAuthenticated = Boolean(session?.user); + const visibleDefinitions = userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated); + const normalizedQuery = normalizeSearchValue(query); + if (normalizedQuery.length === 0) return [settingsOption]; + + const settingsSearchValues = [ + t("search.mode.command.group.preferences.title"), + ...visibleDefinitions.flatMap((definition) => definition.aliases), + ]; + + const matchingPreferenceOptions = visibleDefinitions.flatMap((definition): PreferencesGroupOption[] => { + const label = t(`search.mode.command.group.preferences.option.${definition.key}.label` as never); + const searchValues = [ + label, + ...definition.aliases, + ...(definition.key === "colorScheme" ? colorSchemes.map((scheme) => tColorScheme(scheme)) : []), + ]; + if (!searchValues.some((value) => normalizeSearchValue(value).includes(normalizedQuery))) return []; + if (!(definition.key in preferenceChildrenOptionsByKey)) return []; + const preferenceKey = definition.key as PreferenceChildKey; + + return [{ key: preferenceKey, preferenceKey }]; + }); + + if (matchingPreferenceOptions.length > 0) return matchingPreferenceOptions; + + return settingsSearchValues.some((value) => normalizeSearchValue(value).includes(normalizedQuery)) + ? [settingsOption] + : []; + }, + Component: (option) => { const t = useScopedI18n("search.mode.command.group.preferences"); + const tOption = useScopedI18n("search.mode.command.group.preferences.option"); + const Icon = option.preferenceKey ? preferenceIcons[option.preferenceKey] : IconSettings; + const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title"); + return ( - - {t("title")} + + {label} ); }, - filter: (_query, _option) => true, - useInteraction: interaction.children(settingsChildrenOptions), + filter: () => true, + useInteraction: (option) => ({ + type: "children", + ...(option.preferenceKey ? preferenceChildrenOptionsByKey[option.preferenceKey]({}) : settingsChildrenOptions({})), + }), }); diff --git a/packages/spotlight/src/preferences/use-user-preference.ts b/packages/spotlight/src/preferences/use-user-preference.ts index 214df50b6..8cf47c7ea 100644 --- a/packages/spotlight/src/preferences/use-user-preference.ts +++ b/packages/spotlight/src/preferences/use-user-preference.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import { useMantineColorScheme } from "@mantine/core"; import type { DayOfWeek } from "@mantine/dates"; @@ -8,10 +8,11 @@ import type { DayOfWeek } from "@mantine/dates"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import type { ColorScheme } from "@homarr/definitions"; +import { showErrorNotification } from "@homarr/notifications"; import type { SettingsContextProps } from "@homarr/settings/creator"; import type { UserPreferenceKey } from "@homarr/settings"; import { useSettings } from "@homarr/settings"; -import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client"; +import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client"; const optimisticPreferenceKeys = new Set([ "defaultSearchEngineId", @@ -24,14 +25,36 @@ const optimisticPreferenceKeys = new Set([ ]); const optimisticPreferencesRef: { current: Partial> } = { current: {} }; +let optimisticPreferencesVersion = 0; +const optimisticPreferenceListeners = new Set<() => void>(); const searchPreferenceKeys = ["defaultSearchEngineId", "openSearchInNewTab", "ddgBangs"] as const; const homeBoardPreferenceKeys = ["homeBoardId", "mobileHomeBoardId"] as const; +const notifyOptimisticPreferenceListeners = () => { + optimisticPreferencesVersion += 1; + optimisticPreferenceListeners.forEach((listener) => listener()); +}; + +const subscribeToOptimisticPreferenceChanges = (listener: () => void) => { + optimisticPreferenceListeners.add(listener); + return () => optimisticPreferenceListeners.delete(listener); +}; + +const setOptimisticPreference = (preferenceKey: UserPreferenceKey, value: unknown) => { + optimisticPreferencesRef.current[preferenceKey] = value; + notifyOptimisticPreferenceListeners(); +}; + const clearOptimisticPreferences = (keys: readonly UserPreferenceKey[]) => { + let changed = false; keys.forEach((preferenceKey) => { + changed ||= preferenceKey in optimisticPreferencesRef.current; delete optimisticPreferencesRef.current[preferenceKey]; }); + if (!changed) return; + + notifyOptimisticPreferenceListeners(); }; const getEffectivePreferenceValue = (preferenceKey: UserPreferenceKey, settings: SettingsContextProps): unknown => { @@ -57,7 +80,7 @@ const refreshAfterMutation = (router: ReturnType) => { router.refresh(); }; -export const useUserPreference = (key: UserPreferenceKey) => { +export const useUserPreferences = () => { const router = useRouter(); const settings = useSettings(); const settingsRef = useRef(settings); @@ -67,141 +90,258 @@ export const useUserPreference = (key: UserPreferenceKey) => { const { colorScheme, setColorScheme } = useMantineColorScheme(); const currentLocale = useCurrentLocale(); const { changeLocale } = useChangeLocale(); - const [optimisticValue, setOptimisticValue] = useState(); - - const { mutate: mutateSearchPreferences, isPending: isSearchPreferencesPending } = - clientApi.user.changeSearchPreferences.useMutation({ - onSuccess: () => refreshAfterMutation(router), - onError: () => clearOptimisticPreferences(searchPreferenceKeys), - }); - - const { mutate: mutateHomeBoards, isPending: isHomeBoardsPending } = clientApi.user.changeHomeBoards.useMutation({ - onSuccess: () => refreshAfterMutation(router), - onError: () => clearOptimisticPreferences(homeBoardPreferenceKeys), + const t = useI18n(); + const pendingGroupsRef = useRef({ + search: false, + homeBoards: false, + firstDayOfWeek: false, + pingIcons: false, }); - const { mutate: mutateFirstDayOfWeek, isPending: isFirstDayOfWeekPending } = - clientApi.user.changeFirstDayOfWeek.useMutation({ + useSyncExternalStore( + subscribeToOptimisticPreferenceChanges, + () => optimisticPreferencesVersion, + () => optimisticPreferencesVersion, + ); + + const showPreferenceErrorNotification = () => { + showErrorNotification({ + message: t("search.mode.command.group.preferences.notification.error.message"), + }); + }; + + const { mutateAsync: mutateSearchPreferencesAsync, isPending: isSearchPreferencesPending } = + clientApi.user.changeSearchPreferences.useMutation({ onSuccess: () => refreshAfterMutation(router), - onError: () => clearOptimisticPreferences(["firstDayOfWeek"]), }); - const { mutate: mutatePingIconsEnabled, isPending: isPingIconsPending } = + const { mutateAsync: mutateHomeBoardsAsync, isPending: isHomeBoardsPending } = + clientApi.user.changeHomeBoards.useMutation({ + onSuccess: () => refreshAfterMutation(router), + }); + + const { mutateAsync: mutateFirstDayOfWeekAsync, isPending: isFirstDayOfWeekPending } = + clientApi.user.changeFirstDayOfWeek.useMutation({ + onSuccess: () => refreshAfterMutation(router), + }); + + const { mutateAsync: mutatePingIconsEnabledAsync, isPending: isPingIconsPending } = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: () => refreshAfterMutation(router), - onError: () => clearOptimisticPreferences(["pingIconsEnabled"]), }); const userId = session?.user.id; - const persistedValues: Record = { - colorScheme: colorScheme as ColorScheme, - locale: currentLocale, - defaultSearchEngineId: settings.defaultSearchEngineId, - openSearchInNewTab: settings.openSearchInNewTab, - ddgBangs: settings.ddgBangs, - firstDayOfWeek: settings.firstDayOfWeek, - homeBoardId: settings.homeBoardId, - mobileHomeBoardId: settings.mobileHomeBoardId, - pingIconsEnabled: settings.pingIconsEnabled, - fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null, - }; + const persistedValues = useMemo>( + () => ({ + colorScheme: colorScheme as ColorScheme, + locale: currentLocale, + defaultSearchEngineId: settings.defaultSearchEngineId, + openSearchInNewTab: settings.openSearchInNewTab, + ddgBangs: settings.ddgBangs, + firstDayOfWeek: settings.firstDayOfWeek, + homeBoardId: settings.homeBoardId, + mobileHomeBoardId: settings.mobileHomeBoardId, + pingIconsEnabled: settings.pingIconsEnabled, + fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null, + }), + [ + colorScheme, + currentLocale, + settings.defaultSearchEngineId, + settings.openSearchInNewTab, + settings.ddgBangs, + settings.firstDayOfWeek, + settings.homeBoardId, + settings.mobileHomeBoardId, + settings.pingIconsEnabled, + userId, + ], + ); - const persistedValue = persistedValues[key]; + const getValue = (preferenceKey: UserPreferenceKey) => + preferenceKey in optimisticPreferencesRef.current + ? optimisticPreferencesRef.current[preferenceKey] + : persistedValues[preferenceKey]; useEffect(() => { - delete optimisticPreferencesRef.current[key]; - setOptimisticValue(undefined); - }, [persistedValue, key]); + clearOptimisticPreferences( + Object.entries(persistedValues) + .filter( + ([preferenceKey, value]) => optimisticPreferencesRef.current[preferenceKey as UserPreferenceKey] === value, + ) + .map(([preferenceKey]) => preferenceKey as UserPreferenceKey), + ); + }, [persistedValues]); - const setPersistedValue = (value: never) => { + const setPersistedValueAsync = async (key: UserPreferenceKey, value: never) => { const currentSettings = settingsRef.current; - const setters: Record void> = { + const setters: Record Promise | void> = { colorScheme: (nextValue) => setColorScheme(nextValue as ColorScheme), locale: (nextValue) => changeLocale(nextValue as typeof currentLocale), - defaultSearchEngineId: (nextValue) => { + defaultSearchEngineId: async (nextValue) => { if (!userId) return; - mutateSearchPreferences({ - userId, - defaultSearchEngineId: nextValue as string | null, - openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, - ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, - }); + if (pendingGroupsRef.current.search) return; + pendingGroupsRef.current.search = true; + try { + await mutateSearchPreferencesAsync({ + userId, + defaultSearchEngineId: nextValue as string | null, + openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, + ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, + }); + } finally { + pendingGroupsRef.current.search = false; + } }, - openSearchInNewTab: (nextValue) => { + openSearchInNewTab: async (nextValue) => { if (!userId) return; - mutateSearchPreferences({ - userId, - defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as string | null, - openInNewTab: nextValue as boolean, - ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, - }); + if (pendingGroupsRef.current.search) return; + pendingGroupsRef.current.search = true; + try { + await mutateSearchPreferencesAsync({ + userId, + defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as + | string + | null, + openInNewTab: nextValue as boolean, + ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, + }); + } finally { + pendingGroupsRef.current.search = false; + } }, - ddgBangs: (nextValue) => { + ddgBangs: async (nextValue) => { if (!userId) return; - mutateSearchPreferences({ - userId, - defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as string | null, - openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, - ddgBangsEnabled: nextValue as boolean, - }); + if (pendingGroupsRef.current.search) return; + pendingGroupsRef.current.search = true; + try { + await mutateSearchPreferencesAsync({ + userId, + defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as + | string + | null, + openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, + ddgBangsEnabled: nextValue as boolean, + }); + } finally { + pendingGroupsRef.current.search = false; + } }, - firstDayOfWeek: (nextValue) => { + firstDayOfWeek: async (nextValue) => { if (!userId) return; - mutateFirstDayOfWeek({ id: userId, firstDayOfWeek: nextValue as DayOfWeek }); + if (pendingGroupsRef.current.firstDayOfWeek) return; + pendingGroupsRef.current.firstDayOfWeek = true; + try { + await mutateFirstDayOfWeekAsync({ id: userId, firstDayOfWeek: nextValue as DayOfWeek }); + } finally { + pendingGroupsRef.current.firstDayOfWeek = false; + } }, - homeBoardId: (nextValue) => { + homeBoardId: async (nextValue) => { if (!userId) return; - mutateHomeBoards({ - userId, - homeBoardId: nextValue as string | null, - mobileHomeBoardId: getEffectivePreferenceValue("mobileHomeBoardId", currentSettings) as string | null, - }); + if (pendingGroupsRef.current.homeBoards) return; + pendingGroupsRef.current.homeBoards = true; + try { + await mutateHomeBoardsAsync({ + userId, + homeBoardId: nextValue as string | null, + mobileHomeBoardId: getEffectivePreferenceValue("mobileHomeBoardId", currentSettings) as string | null, + }); + } finally { + pendingGroupsRef.current.homeBoards = false; + } }, - mobileHomeBoardId: (nextValue) => { + mobileHomeBoardId: async (nextValue) => { if (!userId) return; - mutateHomeBoards({ - userId, - homeBoardId: getEffectivePreferenceValue("homeBoardId", currentSettings) as string | null, - mobileHomeBoardId: nextValue as string | null, - }); + if (pendingGroupsRef.current.homeBoards) return; + pendingGroupsRef.current.homeBoards = true; + try { + await mutateHomeBoardsAsync({ + userId, + homeBoardId: getEffectivePreferenceValue("homeBoardId", currentSettings) as string | null, + mobileHomeBoardId: nextValue as string | null, + }); + } finally { + pendingGroupsRef.current.homeBoards = false; + } }, - pingIconsEnabled: (nextValue) => { + pingIconsEnabled: async (nextValue) => { if (!userId) return; - mutatePingIconsEnabled({ id: userId, pingIconsEnabled: nextValue as boolean }); + if (pendingGroupsRef.current.pingIcons) return; + pendingGroupsRef.current.pingIcons = true; + try { + await mutatePingIconsEnabledAsync({ id: userId, pingIconsEnabled: nextValue as boolean }); + } finally { + pendingGroupsRef.current.pingIcons = false; + } }, fullPreferencesPage: () => undefined, }; - setters[key](value); + await setters[key](value); }; - const setValue = (value: never) => { + const isPreferencePending = (key: UserPreferenceKey) => { + if (searchPreferenceKeys.includes(key as never)) + return isSearchPreferencesPending || pendingGroupsRef.current.search; + if (homeBoardPreferenceKeys.includes(key as never)) + return isHomeBoardsPending || pendingGroupsRef.current.homeBoards; + if (key === "firstDayOfWeek") return isFirstDayOfWeekPending || pendingGroupsRef.current.firstDayOfWeek; + if (key === "pingIconsEnabled") return isPingIconsPending || pendingGroupsRef.current.pingIcons; + + return false; + }; + + const setValueAsync = async (key: UserPreferenceKey, value: never) => { + if (optimisticPreferenceKeys.has(key) && !userId) return; + if (isPreferencePending(key)) return; + if (optimisticPreferenceKeys.has(key)) { - optimisticPreferencesRef.current[key] = value; - setOptimisticValue(value); + setOptimisticPreference(key, value); } - setPersistedValue(value); + try { + await setPersistedValueAsync(key, value); + } catch { + if (searchPreferenceKeys.includes(key as never)) { + clearOptimisticPreferences(searchPreferenceKeys); + } else if (homeBoardPreferenceKeys.includes(key as never)) { + clearOptimisticPreferences(homeBoardPreferenceKeys); + } else { + clearOptimisticPreferences([key]); + } + showPreferenceErrorNotification(); + } }; const pendingStates: Record = { colorScheme: false, locale: false, - defaultSearchEngineId: isSearchPreferencesPending, - openSearchInNewTab: isSearchPreferencesPending, - ddgBangs: isSearchPreferencesPending, - firstDayOfWeek: isFirstDayOfWeekPending, - homeBoardId: isHomeBoardsPending, - mobileHomeBoardId: isHomeBoardsPending, - pingIconsEnabled: isPingIconsPending, + defaultSearchEngineId: isPreferencePending("defaultSearchEngineId"), + openSearchInNewTab: isPreferencePending("openSearchInNewTab"), + ddgBangs: isPreferencePending("ddgBangs"), + firstDayOfWeek: isPreferencePending("firstDayOfWeek"), + homeBoardId: isPreferencePending("homeBoardId"), + mobileHomeBoardId: isPreferencePending("mobileHomeBoardId"), + pingIconsEnabled: isPreferencePending("pingIconsEnabled"), fullPreferencesPage: false, }; return { - value: optimisticPreferencesRef.current[key] ?? optimisticValue ?? persistedValue, - setValue, - isPending: pendingStates[key], + getPreference(preferenceKey: UserPreferenceKey) { + return { + value: getValue(preferenceKey), + setValue: (value: never) => setValueAsync(preferenceKey, value), + isPending: pendingStates[preferenceKey], + }; + }, }; }; + +export const useUserPreference = (key: UserPreferenceKey) => { + const preferences = useUserPreferences(); + + return preferences.getPreference(key); +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index c7387d24b..c45101639 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4553,7 +4553,13 @@ "children": { "detail": { "title": "Select a preference to change", - "backHint": "Press Backspace to go back" + "backHint": "Press Backspace or use the back button to go back", + "backAction": "Go back" + } + }, + "notification": { + "error": { + "message": "Unable to update preference" } }, "option": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2a6bb7bb..1cf63c6c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2144,6 +2144,9 @@ importers: '@homarr/modals-collection': specifier: workspace:^0.1.0 version: link:../modals-collection + '@homarr/notifications': + specifier: workspace:^0.1.0 + version: link:../notifications '@homarr/settings': specifier: workspace:^0.1.0 version: link:../settings From f6592b27a1f9e6207d0504e3f6e4d3c6c785373c Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 29 May 2026 17:26:02 +0200 Subject: [PATCH 11/16] refactor(settings): add mutationGroup to preference definitions Enrich userPreferenceDefinitions with mutationGroup field to express which tRPC mutation handles each preference key. Move visiblePreferenceDefinitions helper from spotlight registry to the shared settings package. This enables data-driven mutation dispatch in the CMDK hook and eliminates manual key-to-group mappings. --- .../settings/src/preferences/definitions.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/settings/src/preferences/definitions.ts b/packages/settings/src/preferences/definitions.ts index 6575cb74f..8e82ec684 100644 --- a/packages/settings/src/preferences/definitions.ts +++ b/packages/settings/src/preferences/definitions.ts @@ -1,22 +1,24 @@ export const userPreferenceDefinitions = [ - { key: "colorScheme", kind: "select", guest: true, aliases: ["theme", "dark", "light", "auto"] }, - { key: "locale", kind: "select", guest: true, aliases: ["language", "translation"] }, - { key: "defaultSearchEngineId", kind: "searchEngine", guest: false, aliases: ["search", "search engine"] }, - { key: "openSearchInNewTab", kind: "boolean", guest: false, aliases: ["search", "new tab", "external"] }, - { key: "ddgBangs", kind: "boolean", guest: false, aliases: ["duckduckgo", "bangs", "search"] }, - { key: "firstDayOfWeek", kind: "select", guest: false, aliases: ["calendar", "week", "weekday"] }, - { key: "homeBoardId", kind: "board", guest: false, aliases: ["home", "board", "dashboard"] }, - { key: "mobileHomeBoardId", kind: "board", guest: false, aliases: ["mobile", "home", "board", "dashboard"] }, - { key: "pingIconsEnabled", kind: "boolean", guest: false, aliases: ["ping", "icon", "status"] }, - { key: "fullPreferencesPage", kind: "link", guest: false, aliases: ["settings", "manage", "profile"] }, + { key: "colorScheme", kind: "select", guest: true, mutationGroup: "colorScheme", aliases: ["theme", "dark", "light", "auto"] }, + { key: "locale", kind: "select", guest: true, mutationGroup: "locale", aliases: ["language", "translation"] }, + { key: "defaultSearchEngineId", kind: "searchEngine", guest: false, mutationGroup: "searchPreferences", aliases: ["search", "search engine"] }, + { key: "openSearchInNewTab", kind: "boolean", guest: false, mutationGroup: "searchPreferences", aliases: ["search", "new tab", "external"] }, + { key: "ddgBangs", kind: "boolean", guest: false, mutationGroup: "searchPreferences", aliases: ["duckduckgo", "bangs", "search"] }, + { key: "firstDayOfWeek", kind: "select", guest: false, mutationGroup: "firstDayOfWeek", aliases: ["calendar", "week", "weekday"] }, + { key: "homeBoardId", kind: "board", guest: false, mutationGroup: "homeBoards", aliases: ["home", "board", "dashboard"] }, + { key: "mobileHomeBoardId", kind: "board", guest: false, mutationGroup: "homeBoards", aliases: ["mobile", "home", "board", "dashboard"] }, + { key: "pingIconsEnabled", kind: "boolean", guest: false, mutationGroup: "pingIconsEnabled", aliases: ["ping", "icon", "status"] }, + { key: "fullPreferencesPage", kind: "link", guest: false, mutationGroup: null, aliases: ["settings", "manage", "profile"] }, ] as const; export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number]; - export type UserPreferenceKey = UserPreferenceDefinition["key"]; - export type UserPreferenceKind = UserPreferenceDefinition["kind"]; +export type UserPreferenceMutationGroup = Exclude; export const userPreferenceDefinitionByKey = Object.fromEntries( userPreferenceDefinitions.map((definition) => [definition.key, definition]), ) as Record; + +export const visiblePreferenceDefinitions = (isAuthenticated: boolean) => + userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated); From 5501ee6a7dcd9ead59dbabd54ce458029284f38a Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 29 May 2026 17:26:27 +0200 Subject: [PATCH 12/16] refactor(spotlight): registry-driven preferences with data-driven hook Replace 5 per-preference children files with a single preference-registry.tsx that auto-generates CMDK children via factory functions. Rewrite useUserPreferences from ~347 lines to ~160 lines by deriving mutation dispatch, pending state, and optimistic values from preference definitions. Simplify settings-children.tsx and groups.tsx to iterate over visiblePreferenceDefinitions instead of maintaining manual mappings. Deleted: board.tsx, color-scheme.tsx, first-day-of-week.tsx, search-engine.tsx, registry.ts Net reduction: ~261 lines across 9 files. --- .../command/children/preferences/board.tsx | 47 --- .../children/preferences/color-scheme.tsx | 26 -- .../preferences/first-day-of-week.tsx | 31 -- .../command/children/preferences/registry.ts | 58 --- .../children/preferences/search-engine.tsx | 35 -- .../preferences/settings-children.tsx | 145 +++---- .../src/modes/preferences/groups.tsx | 65 ++- .../src/preferences/preference-registry.tsx | 137 ++++++ .../src/preferences/use-user-preference.ts | 391 ++++++------------ 9 files changed, 336 insertions(+), 599 deletions(-) delete mode 100644 packages/spotlight/src/modes/command/children/preferences/board.tsx delete mode 100644 packages/spotlight/src/modes/command/children/preferences/color-scheme.tsx delete mode 100644 packages/spotlight/src/modes/command/children/preferences/first-day-of-week.tsx delete mode 100644 packages/spotlight/src/modes/command/children/preferences/registry.ts delete mode 100644 packages/spotlight/src/modes/command/children/preferences/search-engine.tsx create mode 100644 packages/spotlight/src/preferences/preference-registry.tsx diff --git a/packages/spotlight/src/modes/command/children/preferences/board.tsx b/packages/spotlight/src/modes/command/children/preferences/board.tsx deleted file mode 100644 index ffa480d23..000000000 --- a/packages/spotlight/src/modes/command/children/preferences/board.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useUserPreference } from "../../../../preferences/use-user-preference"; -import type { UserPreferenceKey } from "@homarr/settings"; -import { useScopedI18n } from "@homarr/translation/client"; - -import { clientApi } from "@homarr/api/client"; - -import { createChildrenOptions } from "../../../../lib/children"; -import { createLoadingPreferenceAction, PreferenceDetailHeader } from "./action-row"; -import { createSelectablePreferenceActions } from "./selectable-actions"; - -interface BoardChildrenOptionsConfig { - preferenceKey: Extract; -} - -const boardDetailTitleKeys = { - homeBoardId: "homeBoardId.children.detail.title", - mobileHomeBoardId: "mobileHomeBoardId.children.detail.title", -} as const; - -export const createBoardChildrenOptions = (config: BoardChildrenOptionsConfig) => - createChildrenOptions>({ - useActions: (_, query) => { - const { value, setValue, isPending } = useUserPreference(config.preferenceKey); - const currentValue = value as string | null; - const boardsQuery = clientApi.board.getAllBoards.useQuery(); - const t = useScopedI18n("search.mode.command.group.preferences.option"); - - if (boardsQuery.isLoading) { - return [createLoadingPreferenceAction()]; - } - - return createSelectablePreferenceActions({ - query, - currentValue, - noneLabel: t("board.none"), - unavailableLabel: t("board.unavailable"), - items: (boardsQuery.data ?? []).map((board) => ({ - key: board.id, - label: board.name, - value: board.id, - })), - onSelect: (nextValue) => setValue(nextValue as never), - isPending, - }); - }, - DetailComponent: () => , - }); diff --git a/packages/spotlight/src/modes/command/children/preferences/color-scheme.tsx b/packages/spotlight/src/modes/command/children/preferences/color-scheme.tsx deleted file mode 100644 index 8e1cb157b..000000000 --- a/packages/spotlight/src/modes/command/children/preferences/color-scheme.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { ColorScheme } from "@homarr/definitions"; -import { colorSchemes } from "@homarr/definitions"; -import { useUserPreference } from "../../../../preferences/use-user-preference"; -import { useScopedI18n } from "@homarr/translation/client"; - -import { createChildrenOptions } from "../../../../lib/children"; -import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./action-row"; - -export const colorSchemeChildrenOptions = createChildrenOptions>({ - useActions: () => { - const tOptions = useScopedI18n("common.colorScheme.options"); - const { value, setValue, isPending } = useUserPreference("colorScheme"); - const currentValue = value as ColorScheme; - - return colorSchemes.map((scheme) => - createCheckmarkPreferenceAction({ - key: scheme, - label: tOptions(scheme), - isSelected: currentValue === scheme, - onSelect: () => setValue(scheme as never), - isPending, - }), - ); - }, - DetailComponent: () => , -}); diff --git a/packages/spotlight/src/modes/command/children/preferences/first-day-of-week.tsx b/packages/spotlight/src/modes/command/children/preferences/first-day-of-week.tsx deleted file mode 100644 index 3b7ab56c6..000000000 --- a/packages/spotlight/src/modes/command/children/preferences/first-day-of-week.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { DayOfWeek } from "@mantine/dates"; -import dayjs from "dayjs"; -import localeData from "dayjs/plugin/localeData"; - -import { useUserPreference } from "../../../../preferences/use-user-preference"; - -import { createChildrenOptions } from "../../../../lib/children"; -import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./action-row"; - -dayjs.extend(localeData); - -const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0]; -const weekDays = dayjs.weekdays(false); - -export const firstDayOfWeekChildrenOptions = createChildrenOptions>({ - useActions: () => { - const { value, setValue, isPending } = useUserPreference("firstDayOfWeek"); - const currentValue = value as DayOfWeek; - - return firstDayOfWeekOptions.map((day) => - createCheckmarkPreferenceAction({ - key: day.toString(), - label: weekDays[day] ?? day.toString(), - isSelected: currentValue === day, - onSelect: () => setValue(day as never), - isPending, - }), - ); - }, - DetailComponent: () => , -}); diff --git a/packages/spotlight/src/modes/command/children/preferences/registry.ts b/packages/spotlight/src/modes/command/children/preferences/registry.ts deleted file mode 100644 index 9baf13397..000000000 --- a/packages/spotlight/src/modes/command/children/preferences/registry.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - IconActivity, - IconCalendarWeek, - IconDeviceDesktop, - IconExternalLink, - IconHome, - IconLanguage, - IconLayoutBoard, - IconSearch, - IconSettings, - IconWorldWww, -} from "@tabler/icons-react"; - -import { userPreferenceDefinitions } from "@homarr/settings"; -import type { UserPreferenceKey } from "@homarr/settings"; -import type { TablerIcon } from "@homarr/ui"; - -import type { createChildrenOptions } from "../../../../lib/children"; -import { languageChildrenOptions } from "../language"; -import { createBoardChildrenOptions } from "./board"; -import { colorSchemeChildrenOptions } from "./color-scheme"; -import { firstDayOfWeekChildrenOptions } from "./first-day-of-week"; -import { searchEngineChildrenOptions } from "./search-engine"; - -type ChildrenPreferenceKey = - | "colorScheme" - | "locale" - | "defaultSearchEngineId" - | "firstDayOfWeek" - | "homeBoardId" - | "mobileHomeBoardId"; - -type PreferenceChildrenOptionsFactory = ReturnType>>; - -export const preferenceIcons: Record = { - colorScheme: IconDeviceDesktop, - locale: IconLanguage, - defaultSearchEngineId: IconSearch, - openSearchInNewTab: IconExternalLink, - ddgBangs: IconWorldWww, - firstDayOfWeek: IconCalendarWeek, - homeBoardId: IconHome, - mobileHomeBoardId: IconLayoutBoard, - pingIconsEnabled: IconActivity, - fullPreferencesPage: IconSettings, -}; - -export const preferenceChildrenOptionsByKey: Record = { - colorScheme: colorSchemeChildrenOptions, - locale: languageChildrenOptions, - defaultSearchEngineId: searchEngineChildrenOptions, - firstDayOfWeek: firstDayOfWeekChildrenOptions, - homeBoardId: createBoardChildrenOptions({ preferenceKey: "homeBoardId" }), - mobileHomeBoardId: createBoardChildrenOptions({ preferenceKey: "mobileHomeBoardId" }), -}; - -export const visiblePreferenceDefinitions = (isAuthenticated: boolean) => - userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated); diff --git a/packages/spotlight/src/modes/command/children/preferences/search-engine.tsx b/packages/spotlight/src/modes/command/children/preferences/search-engine.tsx deleted file mode 100644 index 5f0cc23f5..000000000 --- a/packages/spotlight/src/modes/command/children/preferences/search-engine.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { clientApi } from "@homarr/api/client"; -import { useUserPreference } from "../../../../preferences/use-user-preference"; -import { useScopedI18n } from "@homarr/translation/client"; - -import { createChildrenOptions } from "../../../../lib/children"; -import { createLoadingPreferenceAction, PreferenceDetailHeader } from "./action-row"; -import { createSelectablePreferenceActions } from "./selectable-actions"; - -export const searchEngineChildrenOptions = createChildrenOptions>({ - useActions: (_, query) => { - const { value, setValue, isPending } = useUserPreference("defaultSearchEngineId"); - const currentValue = value as string | null; - const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(); - const t = useScopedI18n("search.mode.command.group.preferences.option"); - - if (searchEnginesQuery.isLoading) { - return [createLoadingPreferenceAction()]; - } - - return createSelectablePreferenceActions({ - query, - currentValue, - noneLabel: t("searchEngine.none"), - unavailableLabel: t("searchEngine.unavailable"), - items: (searchEnginesQuery.data ?? []).map((engine) => ({ - key: engine.value, - label: engine.label, - value: engine.value, - })), - onSelect: (nextValue) => setValue(nextValue as never), - isPending, - }); - }, - DetailComponent: () => , -}); diff --git a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx index 07c46efd5..095394902 100644 --- a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx +++ b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx @@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import type { ColorScheme } from "@homarr/definitions"; import type { UserPreferenceKey } from "@homarr/settings"; +import { visiblePreferenceDefinitions } from "@homarr/settings"; import { localeConfigurations } from "@homarr/translation"; import { useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; @@ -17,7 +18,7 @@ import { createChildrenOptions } from "../../../../lib/children"; import type { ChildrenAction } from "../../../../lib/children"; import { interaction } from "../../../../lib/interaction"; import { useUserPreferences } from "../../../../preferences/use-user-preference"; -import { preferenceChildrenOptionsByKey, preferenceIcons, visiblePreferenceDefinitions } from "./registry"; +import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../../../preferences/preference-registry"; dayjs.extend(localeData); @@ -25,17 +26,7 @@ type SettingsAction = ChildrenAction>; const weekDays = dayjs.weekdays(false); -const BooleanRow = ({ - label, - Icon, - checked, - isPending, -}: { - label: string; - Icon: TablerIcon; - checked: boolean; - isPending: boolean; -}) => ( +const BooleanRow = ({ label, Icon, checked, isPending }: { label: string; Icon: TablerIcon; checked: boolean; isPending: boolean }) => ( @@ -51,9 +42,7 @@ const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIco {label} - - {valueLabel} - + {valueLabel} ); @@ -64,6 +53,8 @@ const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => ( ); +const SELECT_KINDS = new Set(["select", "searchEngine", "board"]); + const useSettingsActions = (_: Record, query: string): SettingsAction[] => { const t = useScopedI18n("search.mode.command.group.preferences.option"); const tColorScheme = useScopedI18n("common.colorScheme.options"); @@ -72,97 +63,65 @@ const useSettingsActions = (_: Record, query: string): Settings const normalizedQuery = query.trim().toLowerCase(); const preferences = useUserPreferences(); - const colorScheme = preferences.getPreference("colorScheme"); - const locale = preferences.getPreference("locale"); - const defaultSearchEngineId = preferences.getPreference("defaultSearchEngineId"); - const openSearchInNewTab = preferences.getPreference("openSearchInNewTab"); - const ddgBangs = preferences.getPreference("ddgBangs"); - const firstDayOfWeek = preferences.getPreference("firstDayOfWeek"); - const homeBoardId = preferences.getPreference("homeBoardId"); - const mobileHomeBoardId = preferences.getPreference("mobileHomeBoardId"); - const pingIconsEnabled = preferences.getPreference("pingIconsEnabled"); - const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated }); const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated }); - const boards = boardsQuery.data ?? []; - const engines = searchEnginesQuery.data ?? []; - - const prefState: Record< - Exclude, - { value: unknown; setValue: (v: never) => void; isPending: boolean } - > = { - colorScheme, - locale, - defaultSearchEngineId, - openSearchInNewTab, - ddgBangs, - firstDayOfWeek, - homeBoardId, - mobileHomeBoardId, - pingIconsEnabled, + const valueLabelResolvers: Record string> = { + colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme), + locale: () => { + const val = preferences.getPreference("locale").value; + return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val); + }, + defaultSearchEngineId: () => + searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value)?.label ?? t("searchEngine.none" as never), + firstDayOfWeek: () => weekDays[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "", + homeBoardId: () => + boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ?? t("board.none" as never), + mobileHomeBoardId: () => + boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ?? t("board.none" as never), }; - const valueLabels: Record, string> = { - colorScheme: tColorScheme(colorScheme.value as ColorScheme), - locale: localeConfigurations[locale.value as keyof typeof localeConfigurations]?.name ?? String(locale.value), - defaultSearchEngineId: - engines.find((e) => e.value === defaultSearchEngineId.value)?.label ?? t("searchEngine.none"), - openSearchInNewTab: "", - ddgBangs: "", - firstDayOfWeek: weekDays[firstDayOfWeek.value as DayOfWeek] ?? String(firstDayOfWeek.value), - homeBoardId: boards.find((b) => b.id === homeBoardId.value)?.name ?? t("board.none"), - mobileHomeBoardId: boards.find((b) => b.id === mobileHomeBoardId.value)?.name ?? t("board.none"), - pingIconsEnabled: "", - }; + return visiblePreferenceDefinitions(isAuthenticated) + .map((definition): SettingsAction | null => { + const label = t(`${definition.key}.label` as never); + const Icon = preferenceIcons[definition.key]; - const definitions = visiblePreferenceDefinitions(isAuthenticated); + const matchesSearch = [label, ...definition.aliases].some((v) => v.toLowerCase().includes(normalizedQuery)); + if (!matchesSearch) return null; - return definitions.flatMap((definition): SettingsAction[] => { - const label = t(`${definition.key}.label` as never); - const Icon = preferenceIcons[definition.key]; - - const matchesSearch = [label, ...definition.aliases].some((value) => value.toLowerCase().includes(normalizedQuery)); - if (!matchesSearch) return []; - - if (definition.kind === "boolean") { - const state = prefState[definition.key as keyof typeof prefState]; - const checked = state.value as boolean; - return [ - { + if (definition.kind === "boolean") { + const pref = preferences.getPreference(definition.key); + return { key: definition.key, - Component: () => , + Component: () => , useInteraction: interaction.javaScript(() => ({ - onSelect: state.isPending ? () => undefined : () => state.setValue(!checked as never), + onSelect: pref.isPending ? () => undefined : () => pref.setValue(!(pref.value as boolean) as never), closeSpotlightOnTrigger: false, })), - }, - ]; - } + }; + } - if (definition.kind === "link") { - return [ - { + if (definition.kind === "link") { + return { key: definition.key, Component: () => , - useInteraction: interaction.link(() => ({ - href: `/manage/users/${session?.user.id ?? ""}/general`, - })), - }, - ]; - } + useInteraction: interaction.link(() => ({ href: `/manage/users/${session?.user.id ?? ""}/general` })), + }; + } - const childrenKey = definition.key as keyof typeof preferenceChildrenOptionsByKey; - return [ - { - key: definition.key, - Component: () => ( - - ), - useInteraction: interaction.children(preferenceChildrenOptionsByKey[childrenKey]), - }, - ]; - }); + if (SELECT_KINDS.has(definition.kind)) { + const children = preferenceChildrenOptionsByKey[definition.key]; + if (!children) return null; + return { + key: definition.key, + Component: () => , + useInteraction: interaction.children(children), + }; + } + + return null; + }) + .filter((action): action is SettingsAction => action !== null); }; export const settingsChildrenOptions = createChildrenOptions>({ @@ -172,9 +131,7 @@ export const settingsChildrenOptions = createChildrenOptions {t("title")} - - {t("children.detail.backHint")} - + {t("children.detail.backHint")} ); }, diff --git a/packages/spotlight/src/modes/preferences/groups.tsx b/packages/spotlight/src/modes/preferences/groups.tsx index 4b3db8dc4..37c6bc922 100644 --- a/packages/spotlight/src/modes/preferences/groups.tsx +++ b/packages/spotlight/src/modes/preferences/groups.tsx @@ -3,28 +3,18 @@ import { IconSettings } from "@tabler/icons-react"; import { useSession } from "@homarr/auth/client"; import { colorSchemes } from "@homarr/definitions"; -import { userPreferenceDefinitions } from "@homarr/settings"; +import { visiblePreferenceDefinitions } from "@homarr/settings"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { createGroup } from "../../lib/group"; -import { preferenceChildrenOptionsByKey, preferenceIcons } from "../command/children/preferences/registry"; +import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../preferences/preference-registry"; import { settingsChildrenOptions } from "../command/children/preferences/settings-children"; -const normalizeSearchValue = (value: string) => value.trim().toLowerCase(); - -type PreferenceChildKey = Extract; - type PreferencesGroupOption = - | { - key: "settings"; - preferenceKey: null; - } - | { - key: PreferenceChildKey; - preferenceKey: PreferenceChildKey; - }; + | { key: "settings"; preferenceKey: null } + | { key: string; preferenceKey: string }; -const settingsOption = { key: "settings", preferenceKey: null } satisfies PreferencesGroupOption; +const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null }; export const preferencesGroup = createGroup({ keyPath: "key", @@ -33,40 +23,37 @@ export const preferencesGroup = createGroup({ const t = useI18n(); const tColorScheme = useScopedI18n("common.colorScheme.options"); const { data: session } = useSession(); - const isAuthenticated = Boolean(session?.user); - const visibleDefinitions = userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated); - const normalizedQuery = normalizeSearchValue(query); - if (normalizedQuery.length === 0) return [settingsOption]; + const definitions = visiblePreferenceDefinitions(Boolean(session?.user)); + const q = query.trim().toLowerCase(); - const settingsSearchValues = [ - t("search.mode.command.group.preferences.title"), - ...visibleDefinitions.flatMap((definition) => definition.aliases), - ]; + if (q.length === 0) return [settingsOption]; - const matchingPreferenceOptions = visibleDefinitions.flatMap((definition): PreferencesGroupOption[] => { - const label = t(`search.mode.command.group.preferences.option.${definition.key}.label` as never); + const matching = definitions.flatMap((def): PreferencesGroupOption[] => { + if (!preferenceChildrenOptionsByKey[def.key]) return []; + const label = t(`search.mode.command.group.preferences.option.${def.key}.label` as never); const searchValues = [ label, - ...definition.aliases, - ...(definition.key === "colorScheme" ? colorSchemes.map((scheme) => tColorScheme(scheme)) : []), + ...def.aliases, + ...(def.key === "colorScheme" ? colorSchemes.map((s) => tColorScheme(s)) : []), ]; - if (!searchValues.some((value) => normalizeSearchValue(value).includes(normalizedQuery))) return []; - if (!(definition.key in preferenceChildrenOptionsByKey)) return []; - const preferenceKey = definition.key as PreferenceChildKey; - - return [{ key: preferenceKey, preferenceKey }]; + if (!searchValues.some((v) => v.toLowerCase().includes(q))) return []; + return [{ key: def.key, preferenceKey: def.key }]; }); - if (matchingPreferenceOptions.length > 0) return matchingPreferenceOptions; + if (matching.length > 0) return matching; - return settingsSearchValues.some((value) => normalizeSearchValue(value).includes(normalizedQuery)) - ? [settingsOption] - : []; + const allSearchValues = [ + t("search.mode.command.group.preferences.title"), + ...definitions.flatMap((d) => d.aliases), + ]; + return allSearchValues.some((v) => v.toLowerCase().includes(q)) ? [settingsOption] : []; }, Component: (option) => { const t = useScopedI18n("search.mode.command.group.preferences"); const tOption = useScopedI18n("search.mode.command.group.preferences.option"); - const Icon = option.preferenceKey ? preferenceIcons[option.preferenceKey] : IconSettings; + const Icon = option.preferenceKey + ? preferenceIcons[option.preferenceKey as keyof typeof preferenceIcons] + : IconSettings; const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title"); return ( @@ -79,6 +66,8 @@ export const preferencesGroup = createGroup({ filter: () => true, useInteraction: (option) => ({ type: "children", - ...(option.preferenceKey ? preferenceChildrenOptionsByKey[option.preferenceKey]({}) : settingsChildrenOptions({})), + ...(option.preferenceKey + ? preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]!({}) + : settingsChildrenOptions({})), }), }); diff --git a/packages/spotlight/src/preferences/preference-registry.tsx b/packages/spotlight/src/preferences/preference-registry.tsx new file mode 100644 index 000000000..947f1f4fe --- /dev/null +++ b/packages/spotlight/src/preferences/preference-registry.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { + IconActivity, + IconCalendarWeek, + IconDeviceDesktop, + IconExternalLink, + IconHome, + IconLanguage, + IconLayoutBoard, + IconSearch, + IconSettings, + IconWorldWww, +} from "@tabler/icons-react"; +import type { DayOfWeek } from "@mantine/dates"; +import dayjs from "dayjs"; +import localeData from "dayjs/plugin/localeData"; + +import { clientApi } from "@homarr/api/client"; +import { colorSchemes } from "@homarr/definitions"; +import type { UserPreferenceKey } from "@homarr/settings"; +import { useScopedI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; + +import { createChildrenOptions } from "../lib/children"; +import { + createCheckmarkPreferenceAction, + createLoadingPreferenceAction, + PreferenceDetailHeader, +} from "../modes/command/children/preferences/action-row"; +import { createSelectablePreferenceActions } from "../modes/command/children/preferences/selectable-actions"; +import { languageChildrenOptions } from "../modes/command/children/language"; +import { useUserPreference } from "./use-user-preference"; + +dayjs.extend(localeData); + +type ChildrenFactory = ReturnType>>; + +export const preferenceIcons: Record = { + colorScheme: IconDeviceDesktop, + locale: IconLanguage, + defaultSearchEngineId: IconSearch, + openSearchInNewTab: IconExternalLink, + ddgBangs: IconWorldWww, + firstDayOfWeek: IconCalendarWeek, + homeBoardId: IconHome, + mobileHomeBoardId: IconLayoutBoard, + pingIconsEnabled: IconActivity, + fullPreferencesPage: IconSettings, +}; + +const createStaticChildren = ( + preferenceKey: UserPreferenceKey, + useOptions: () => Array<{ key: string; label: string; value: unknown }>, +): ChildrenFactory => + createChildrenOptions>({ + useActions: () => { + const { value, setValue, isPending } = useUserPreference(preferenceKey); + return useOptions().map((opt) => + createCheckmarkPreferenceAction({ + key: opt.key, + label: opt.label, + isSelected: value === opt.value, + onSelect: () => setValue(opt.value as never), + isPending, + }), + ); + }, + DetailComponent: () => , + }); + +const createAsyncSelectableChildren = ( + preferenceKey: UserPreferenceKey, + useQuery: () => { data: TItem[] | undefined; isLoading: boolean }, + mapItem: (item: TItem) => { key: string; label: string; value: string }, + noneKey: string, + unavailableKey: string, +): ChildrenFactory => + createChildrenOptions>({ + useActions: (_, query) => { + const { value, setValue, isPending } = useUserPreference(preferenceKey); + const result = useQuery(); + const t = useScopedI18n("search.mode.command.group.preferences.option"); + + if (result.isLoading) return [createLoadingPreferenceAction()]; + + return createSelectablePreferenceActions({ + query, + currentValue: value as string | null, + noneLabel: t(noneKey as never), + unavailableLabel: t(unavailableKey as never), + items: (result.data ?? []).map(mapItem), + onSelect: (v) => setValue(v as never), + isPending, + }); + }, + DetailComponent: () => , + }); + +const weekDays = dayjs.weekdays(false); +const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0]; + +export const preferenceChildrenOptionsByKey: Partial> = { + colorScheme: createStaticChildren("colorScheme", () => { + const t = useScopedI18n("common.colorScheme.options"); + return colorSchemes.map((s) => ({ key: s, label: t(s), value: s })); + }), + locale: languageChildrenOptions, + defaultSearchEngineId: createAsyncSelectableChildren( + "defaultSearchEngineId", + () => clientApi.searchEngine.getSelectable.useQuery(), + (e) => ({ key: e.value, label: e.label, value: e.value }), + "searchEngine.none", + "searchEngine.unavailable", + ), + firstDayOfWeek: createStaticChildren("firstDayOfWeek", () => + firstDayOfWeekOptions.map((day) => ({ + key: day.toString(), + label: weekDays[day] ?? day.toString(), + value: day, + })), + ), + homeBoardId: createAsyncSelectableChildren( + "homeBoardId", + () => clientApi.board.getAllBoards.useQuery(), + (b) => ({ key: b.id, label: b.name, value: b.id }), + "board.none", + "board.unavailable", + ), + mobileHomeBoardId: createAsyncSelectableChildren( + "mobileHomeBoardId", + () => clientApi.board.getAllBoards.useQuery(), + (b) => ({ key: b.id, label: b.name, value: b.id }), + "board.none", + "board.unavailable", + ), +}; diff --git a/packages/spotlight/src/preferences/use-user-preference.ts b/packages/spotlight/src/preferences/use-user-preference.ts index 8cf47c7ea..cf6c78f77 100644 --- a/packages/spotlight/src/preferences/use-user-preference.ts +++ b/packages/spotlight/src/preferences/use-user-preference.ts @@ -10,76 +10,59 @@ import { useSession } from "@homarr/auth/client"; import type { ColorScheme } from "@homarr/definitions"; import { showErrorNotification } from "@homarr/notifications"; import type { SettingsContextProps } from "@homarr/settings/creator"; +import { + userPreferenceDefinitions, + userPreferenceDefinitionByKey, +} from "@homarr/settings"; import type { UserPreferenceKey } from "@homarr/settings"; import { useSettings } from "@homarr/settings"; import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client"; -const optimisticPreferenceKeys = new Set([ - "defaultSearchEngineId", - "openSearchInNewTab", - "ddgBangs", - "firstDayOfWeek", - "homeBoardId", - "mobileHomeBoardId", - "pingIconsEnabled", -]); +// --- Optimistic store (module-level, shared across all hook instances) --- -const optimisticPreferencesRef: { current: Partial> } = { current: {} }; -let optimisticPreferencesVersion = 0; -const optimisticPreferenceListeners = new Set<() => void>(); +const optimisticRef: { current: Partial> } = { current: {} }; +let optimisticVersion = 0; +const optimisticListeners = new Set<() => void>(); -const searchPreferenceKeys = ["defaultSearchEngineId", "openSearchInNewTab", "ddgBangs"] as const; -const homeBoardPreferenceKeys = ["homeBoardId", "mobileHomeBoardId"] as const; - -const notifyOptimisticPreferenceListeners = () => { - optimisticPreferencesVersion += 1; - optimisticPreferenceListeners.forEach((listener) => listener()); +const notifyListeners = () => { + optimisticVersion += 1; + optimisticListeners.forEach((fn) => fn()); }; -const subscribeToOptimisticPreferenceChanges = (listener: () => void) => { - optimisticPreferenceListeners.add(listener); - return () => optimisticPreferenceListeners.delete(listener); +const subscribeOptimistic = (listener: () => void) => { + optimisticListeners.add(listener); + return () => optimisticListeners.delete(listener); }; -const setOptimisticPreference = (preferenceKey: UserPreferenceKey, value: unknown) => { - optimisticPreferencesRef.current[preferenceKey] = value; - notifyOptimisticPreferenceListeners(); +const setOptimistic = (key: UserPreferenceKey, value: unknown) => { + optimisticRef.current[key] = value; + notifyListeners(); }; -const clearOptimisticPreferences = (keys: readonly UserPreferenceKey[]) => { +const clearOptimistic = (keys: readonly UserPreferenceKey[]) => { let changed = false; - keys.forEach((preferenceKey) => { - changed ||= preferenceKey in optimisticPreferencesRef.current; - delete optimisticPreferencesRef.current[preferenceKey]; - }); - if (!changed) return; - - notifyOptimisticPreferenceListeners(); -}; - -const getEffectivePreferenceValue = (preferenceKey: UserPreferenceKey, settings: SettingsContextProps): unknown => { - const optimisticValue = optimisticPreferencesRef.current[preferenceKey]; - if (optimisticValue !== undefined) { - return optimisticValue; + for (const key of keys) { + changed ||= key in optimisticRef.current; + delete optimisticRef.current[key]; } - - const settingsValues: Partial> = { - defaultSearchEngineId: settings.defaultSearchEngineId, - openSearchInNewTab: settings.openSearchInNewTab, - ddgBangs: settings.ddgBangs, - firstDayOfWeek: settings.firstDayOfWeek, - homeBoardId: settings.homeBoardId, - mobileHomeBoardId: settings.mobileHomeBoardId, - pingIconsEnabled: settings.pingIconsEnabled, - }; - - return settingsValues[preferenceKey]; + if (changed) notifyListeners(); }; -const refreshAfterMutation = (router: ReturnType) => { - router.refresh(); +const CLIENT_ONLY_GROUPS = new Set(["colorScheme", "locale"]); + +const isDbBacked = (key: UserPreferenceKey) => { + const group = userPreferenceDefinitionByKey[key].mutationGroup; + return group !== null && !CLIENT_ONLY_GROUPS.has(group); }; +const getGroupKeys = (key: UserPreferenceKey): UserPreferenceKey[] => { + const group = userPreferenceDefinitionByKey[key].mutationGroup; + if (!group) return [key]; + return userPreferenceDefinitions.filter((d) => d.mutationGroup === group).map((d) => d.key); +}; + +// --- Hook --- + export const useUserPreferences = () => { const router = useRouter(); const settings = useSettings(); @@ -91,257 +74,125 @@ export const useUserPreferences = () => { const currentLocale = useCurrentLocale(); const { changeLocale } = useChangeLocale(); const t = useI18n(); - const pendingGroupsRef = useRef({ - search: false, - homeBoards: false, - firstDayOfWeek: false, - pingIcons: false, - }); + const userId = session?.user.id; + const pendingRef = useRef>({}); - useSyncExternalStore( - subscribeToOptimisticPreferenceChanges, - () => optimisticPreferencesVersion, - () => optimisticPreferencesVersion, - ); + useSyncExternalStore(subscribeOptimistic, () => optimisticVersion, () => optimisticVersion); - const showPreferenceErrorNotification = () => { - showErrorNotification({ - message: t("search.mode.command.group.preferences.notification.error.message"), - }); + const refresh = () => router.refresh(); + + const searchMutation = clientApi.user.changeSearchPreferences.useMutation({ onSuccess: refresh }); + const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh }); + const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh }); + const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh }); + + const getEffective = (key: UserPreferenceKey): unknown => + key in optimisticRef.current + ? optimisticRef.current[key] + : settingsRef.current[key as keyof SettingsContextProps]; + + const pick = (field: UserPreferenceKey, changedKey: UserPreferenceKey, value: unknown) => + field === changedKey ? value : getEffective(field); + + const groupDispatchers: Record Promise> = { + searchPreferences: (key, value) => + searchMutation.mutateAsync({ + userId: userId!, + defaultSearchEngineId: pick("defaultSearchEngineId", key, value) as string | null, + openInNewTab: pick("openSearchInNewTab", key, value) as boolean, + ddgBangsEnabled: pick("ddgBangs", key, value) as boolean, + }), + homeBoards: (key, value) => + homeBoardsMutation.mutateAsync({ + userId: userId!, + homeBoardId: pick("homeBoardId", key, value) as string | null, + mobileHomeBoardId: pick("mobileHomeBoardId", key, value) as string | null, + }), + firstDayOfWeek: (_key, value) => + firstDayMutation.mutateAsync({ id: userId!, firstDayOfWeek: value as DayOfWeek }), + pingIconsEnabled: (_key, value) => + pingMutation.mutateAsync({ id: userId!, pingIconsEnabled: value as boolean }), }; - const { mutateAsync: mutateSearchPreferencesAsync, isPending: isSearchPreferencesPending } = - clientApi.user.changeSearchPreferences.useMutation({ - onSuccess: () => refreshAfterMutation(router), - }); + const groupPending: Record = { + searchPreferences: searchMutation.isPending, + homeBoards: homeBoardsMutation.isPending, + firstDayOfWeek: firstDayMutation.isPending, + pingIconsEnabled: pingMutation.isPending, + }; - const { mutateAsync: mutateHomeBoardsAsync, isPending: isHomeBoardsPending } = - clientApi.user.changeHomeBoards.useMutation({ - onSuccess: () => refreshAfterMutation(router), - }); - - const { mutateAsync: mutateFirstDayOfWeekAsync, isPending: isFirstDayOfWeekPending } = - clientApi.user.changeFirstDayOfWeek.useMutation({ - onSuccess: () => refreshAfterMutation(router), - }); - - const { mutateAsync: mutatePingIconsEnabledAsync, isPending: isPingIconsPending } = - clientApi.user.changePingIconsEnabled.useMutation({ - onSuccess: () => refreshAfterMutation(router), - }); - - const userId = session?.user.id; - - const persistedValues = useMemo>( - () => ({ + const persistedValues = useMemo>(() => { + const values: Record = { colorScheme: colorScheme as ColorScheme, locale: currentLocale, - defaultSearchEngineId: settings.defaultSearchEngineId, - openSearchInNewTab: settings.openSearchInNewTab, - ddgBangs: settings.ddgBangs, - firstDayOfWeek: settings.firstDayOfWeek, - homeBoardId: settings.homeBoardId, - mobileHomeBoardId: settings.mobileHomeBoardId, - pingIconsEnabled: settings.pingIconsEnabled, fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null, - }), - [ - colorScheme, - currentLocale, - settings.defaultSearchEngineId, - settings.openSearchInNewTab, - settings.ddgBangs, - settings.firstDayOfWeek, - settings.homeBoardId, - settings.mobileHomeBoardId, - settings.pingIconsEnabled, - userId, - ], - ); - - const getValue = (preferenceKey: UserPreferenceKey) => - preferenceKey in optimisticPreferencesRef.current - ? optimisticPreferencesRef.current[preferenceKey] - : persistedValues[preferenceKey]; + }; + for (const def of userPreferenceDefinitions) { + if (def.key in values) continue; + values[def.key] = settings[def.key as keyof SettingsContextProps]; + } + return values as Record; + }, [colorScheme, currentLocale, userId, settings]); useEffect(() => { - clearOptimisticPreferences( - Object.entries(persistedValues) - .filter( - ([preferenceKey, value]) => optimisticPreferencesRef.current[preferenceKey as UserPreferenceKey] === value, - ) - .map(([preferenceKey]) => preferenceKey as UserPreferenceKey), - ); + const matchedKeys = Object.entries(persistedValues) + .filter(([key, value]) => optimisticRef.current[key as UserPreferenceKey] === value) + .map(([key]) => key as UserPreferenceKey); + clearOptimistic(matchedKeys); }, [persistedValues]); - const setPersistedValueAsync = async (key: UserPreferenceKey, value: never) => { - const currentSettings = settingsRef.current; + const getValue = (key: UserPreferenceKey) => + key in optimisticRef.current ? optimisticRef.current[key] : persistedValues[key]; - const setters: Record Promise | void> = { - colorScheme: (nextValue) => setColorScheme(nextValue as ColorScheme), - locale: (nextValue) => changeLocale(nextValue as typeof currentLocale), - defaultSearchEngineId: async (nextValue) => { - if (!userId) return; - if (pendingGroupsRef.current.search) return; - pendingGroupsRef.current.search = true; - try { - await mutateSearchPreferencesAsync({ - userId, - defaultSearchEngineId: nextValue as string | null, - openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, - ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, - }); - } finally { - pendingGroupsRef.current.search = false; - } - }, - openSearchInNewTab: async (nextValue) => { - if (!userId) return; - if (pendingGroupsRef.current.search) return; - pendingGroupsRef.current.search = true; - try { - await mutateSearchPreferencesAsync({ - userId, - defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as - | string - | null, - openInNewTab: nextValue as boolean, - ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, - }); - } finally { - pendingGroupsRef.current.search = false; - } - }, - ddgBangs: async (nextValue) => { - if (!userId) return; - if (pendingGroupsRef.current.search) return; - pendingGroupsRef.current.search = true; - try { - await mutateSearchPreferencesAsync({ - userId, - defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as - | string - | null, - openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, - ddgBangsEnabled: nextValue as boolean, - }); - } finally { - pendingGroupsRef.current.search = false; - } - }, - firstDayOfWeek: async (nextValue) => { - if (!userId) return; - if (pendingGroupsRef.current.firstDayOfWeek) return; - pendingGroupsRef.current.firstDayOfWeek = true; - try { - await mutateFirstDayOfWeekAsync({ id: userId, firstDayOfWeek: nextValue as DayOfWeek }); - } finally { - pendingGroupsRef.current.firstDayOfWeek = false; - } - }, - homeBoardId: async (nextValue) => { - if (!userId) return; - if (pendingGroupsRef.current.homeBoards) return; - pendingGroupsRef.current.homeBoards = true; - try { - await mutateHomeBoardsAsync({ - userId, - homeBoardId: nextValue as string | null, - mobileHomeBoardId: getEffectivePreferenceValue("mobileHomeBoardId", currentSettings) as string | null, - }); - } finally { - pendingGroupsRef.current.homeBoards = false; - } - }, - mobileHomeBoardId: async (nextValue) => { - if (!userId) return; - if (pendingGroupsRef.current.homeBoards) return; - pendingGroupsRef.current.homeBoards = true; - try { - await mutateHomeBoardsAsync({ - userId, - homeBoardId: getEffectivePreferenceValue("homeBoardId", currentSettings) as string | null, - mobileHomeBoardId: nextValue as string | null, - }); - } finally { - pendingGroupsRef.current.homeBoards = false; - } - }, - pingIconsEnabled: async (nextValue) => { - if (!userId) return; - if (pendingGroupsRef.current.pingIcons) return; - pendingGroupsRef.current.pingIcons = true; - try { - await mutatePingIconsEnabledAsync({ id: userId, pingIconsEnabled: nextValue as boolean }); - } finally { - pendingGroupsRef.current.pingIcons = false; - } - }, - fullPreferencesPage: () => undefined, - }; - - await setters[key](value); + const isPreferencePending = (key: UserPreferenceKey): boolean => { + const group = userPreferenceDefinitionByKey[key].mutationGroup; + if (!group || CLIENT_ONLY_GROUPS.has(group)) return false; + return (groupPending[group] ?? false) || (pendingRef.current[group] ?? false); }; - const isPreferencePending = (key: UserPreferenceKey) => { - if (searchPreferenceKeys.includes(key as never)) - return isSearchPreferencesPending || pendingGroupsRef.current.search; - if (homeBoardPreferenceKeys.includes(key as never)) - return isHomeBoardsPending || pendingGroupsRef.current.homeBoards; - if (key === "firstDayOfWeek") return isFirstDayOfWeekPending || pendingGroupsRef.current.firstDayOfWeek; - if (key === "pingIconsEnabled") return isPingIconsPending || pendingGroupsRef.current.pingIcons; - - return false; + const clientSetters: Partial void>> = { + colorScheme: (value) => setColorScheme(value as ColorScheme), + locale: (value) => changeLocale(value as typeof currentLocale), + fullPreferencesPage: () => undefined, }; - const setValueAsync = async (key: UserPreferenceKey, value: never) => { - if (optimisticPreferenceKeys.has(key) && !userId) return; + const setValueAsync = async (key: UserPreferenceKey, value: unknown) => { + if (isDbBacked(key) && !userId) return; if (isPreferencePending(key)) return; - if (optimisticPreferenceKeys.has(key)) { - setOptimisticPreference(key, value); + const clientSetter = clientSetters[key]; + if (clientSetter) { + clientSetter(value); + return; } + setOptimistic(key, value); + const group = userPreferenceDefinitionByKey[key].mutationGroup!; + const dispatcher = groupDispatchers[group]; + if (!dispatcher) return; + + pendingRef.current[group] = true; try { - await setPersistedValueAsync(key, value); + await dispatcher(key, value); } catch { - if (searchPreferenceKeys.includes(key as never)) { - clearOptimisticPreferences(searchPreferenceKeys); - } else if (homeBoardPreferenceKeys.includes(key as never)) { - clearOptimisticPreferences(homeBoardPreferenceKeys); - } else { - clearOptimisticPreferences([key]); - } - showPreferenceErrorNotification(); + clearOptimistic(getGroupKeys(key)); + showErrorNotification({ + message: t("search.mode.command.group.preferences.notification.error.message"), + }); + } finally { + pendingRef.current[group] = false; } }; - const pendingStates: Record = { - colorScheme: false, - locale: false, - defaultSearchEngineId: isPreferencePending("defaultSearchEngineId"), - openSearchInNewTab: isPreferencePending("openSearchInNewTab"), - ddgBangs: isPreferencePending("ddgBangs"), - firstDayOfWeek: isPreferencePending("firstDayOfWeek"), - homeBoardId: isPreferencePending("homeBoardId"), - mobileHomeBoardId: isPreferencePending("mobileHomeBoardId"), - pingIconsEnabled: isPreferencePending("pingIconsEnabled"), - fullPreferencesPage: false, - }; - return { getPreference(preferenceKey: UserPreferenceKey) { return { value: getValue(preferenceKey), setValue: (value: never) => setValueAsync(preferenceKey, value), - isPending: pendingStates[preferenceKey], + isPending: isPreferencePending(preferenceKey), }; }, }; }; -export const useUserPreference = (key: UserPreferenceKey) => { - const preferences = useUserPreferences(); - - return preferences.getPreference(key); -}; +export const useUserPreference = (key: UserPreferenceKey) => useUserPreferences().getPreference(key); From a2c4b29bfdb9fdb39bc5a39c30f0dd90d14e7f51 Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Fri, 29 May 2026 17:52:12 +0000 Subject: [PATCH 13/16] style: auto-fix lint and format --- .../settings/src/preferences/definitions.ts | 72 ++++++++++++++++--- .../preferences/settings-children.tsx | 37 +++++++--- .../src/modes/preferences/groups.tsx | 4 +- .../src/preferences/use-user-preference.ts | 21 +++--- 4 files changed, 102 insertions(+), 32 deletions(-) diff --git a/packages/settings/src/preferences/definitions.ts b/packages/settings/src/preferences/definitions.ts index 8e82ec684..528ce8aba 100644 --- a/packages/settings/src/preferences/definitions.ts +++ b/packages/settings/src/preferences/definitions.ts @@ -1,14 +1,68 @@ export const userPreferenceDefinitions = [ - { key: "colorScheme", kind: "select", guest: true, mutationGroup: "colorScheme", aliases: ["theme", "dark", "light", "auto"] }, + { + key: "colorScheme", + kind: "select", + guest: true, + mutationGroup: "colorScheme", + aliases: ["theme", "dark", "light", "auto"], + }, { key: "locale", kind: "select", guest: true, mutationGroup: "locale", aliases: ["language", "translation"] }, - { key: "defaultSearchEngineId", kind: "searchEngine", guest: false, mutationGroup: "searchPreferences", aliases: ["search", "search engine"] }, - { key: "openSearchInNewTab", kind: "boolean", guest: false, mutationGroup: "searchPreferences", aliases: ["search", "new tab", "external"] }, - { key: "ddgBangs", kind: "boolean", guest: false, mutationGroup: "searchPreferences", aliases: ["duckduckgo", "bangs", "search"] }, - { key: "firstDayOfWeek", kind: "select", guest: false, mutationGroup: "firstDayOfWeek", aliases: ["calendar", "week", "weekday"] }, - { key: "homeBoardId", kind: "board", guest: false, mutationGroup: "homeBoards", aliases: ["home", "board", "dashboard"] }, - { key: "mobileHomeBoardId", kind: "board", guest: false, mutationGroup: "homeBoards", aliases: ["mobile", "home", "board", "dashboard"] }, - { key: "pingIconsEnabled", kind: "boolean", guest: false, mutationGroup: "pingIconsEnabled", aliases: ["ping", "icon", "status"] }, - { key: "fullPreferencesPage", kind: "link", guest: false, mutationGroup: null, aliases: ["settings", "manage", "profile"] }, + { + key: "defaultSearchEngineId", + kind: "searchEngine", + guest: false, + mutationGroup: "searchPreferences", + aliases: ["search", "search engine"], + }, + { + key: "openSearchInNewTab", + kind: "boolean", + guest: false, + mutationGroup: "searchPreferences", + aliases: ["search", "new tab", "external"], + }, + { + key: "ddgBangs", + kind: "boolean", + guest: false, + mutationGroup: "searchPreferences", + aliases: ["duckduckgo", "bangs", "search"], + }, + { + key: "firstDayOfWeek", + kind: "select", + guest: false, + mutationGroup: "firstDayOfWeek", + aliases: ["calendar", "week", "weekday"], + }, + { + key: "homeBoardId", + kind: "board", + guest: false, + mutationGroup: "homeBoards", + aliases: ["home", "board", "dashboard"], + }, + { + key: "mobileHomeBoardId", + kind: "board", + guest: false, + mutationGroup: "homeBoards", + aliases: ["mobile", "home", "board", "dashboard"], + }, + { + key: "pingIconsEnabled", + kind: "boolean", + guest: false, + mutationGroup: "pingIconsEnabled", + aliases: ["ping", "icon", "status"], + }, + { + key: "fullPreferencesPage", + kind: "link", + guest: false, + mutationGroup: null, + aliases: ["settings", "manage", "profile"], + }, ] as const; export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number]; diff --git a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx index 095394902..04b4bdb31 100644 --- a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx +++ b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx @@ -26,7 +26,17 @@ type SettingsAction = ChildrenAction>; const weekDays = dayjs.weekdays(false); -const BooleanRow = ({ label, Icon, checked, isPending }: { label: string; Icon: TablerIcon; checked: boolean; isPending: boolean }) => ( +const BooleanRow = ({ + label, + Icon, + checked, + isPending, +}: { + label: string; + Icon: TablerIcon; + checked: boolean; + isPending: boolean; +}) => ( @@ -42,7 +52,9 @@ const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIco {label} - {valueLabel} + + {valueLabel} + ); @@ -73,12 +85,15 @@ const useSettingsActions = (_: Record, query: string): Settings return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val); }, defaultSearchEngineId: () => - searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value)?.label ?? t("searchEngine.none" as never), + searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value) + ?.label ?? t("searchEngine.none" as never), firstDayOfWeek: () => weekDays[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "", homeBoardId: () => - boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ?? t("board.none" as never), + boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ?? + t("board.none" as never), mobileHomeBoardId: () => - boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ?? t("board.none" as never), + boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ?? + t("board.none" as never), }; return visiblePreferenceDefinitions(isAuthenticated) @@ -93,7 +108,9 @@ const useSettingsActions = (_: Record, query: string): Settings const pref = preferences.getPreference(definition.key); return { key: definition.key, - Component: () => , + Component: () => ( + + ), useInteraction: interaction.javaScript(() => ({ onSelect: pref.isPending ? () => undefined : () => pref.setValue(!(pref.value as boolean) as never), closeSpotlightOnTrigger: false, @@ -114,7 +131,9 @@ const useSettingsActions = (_: Record, query: string): Settings if (!children) return null; return { key: definition.key, - Component: () => , + Component: () => ( + + ), useInteraction: interaction.children(children), }; } @@ -131,7 +150,9 @@ export const settingsChildrenOptions = createChildrenOptions {t("title")} - {t("children.detail.backHint")} + + {t("children.detail.backHint")} + ); }, diff --git a/packages/spotlight/src/modes/preferences/groups.tsx b/packages/spotlight/src/modes/preferences/groups.tsx index 37c6bc922..33b229ef3 100644 --- a/packages/spotlight/src/modes/preferences/groups.tsx +++ b/packages/spotlight/src/modes/preferences/groups.tsx @@ -10,9 +10,7 @@ import { createGroup } from "../../lib/group"; import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../preferences/preference-registry"; import { settingsChildrenOptions } from "../command/children/preferences/settings-children"; -type PreferencesGroupOption = - | { key: "settings"; preferenceKey: null } - | { key: string; preferenceKey: string }; +type PreferencesGroupOption = { key: "settings"; preferenceKey: null } | { key: string; preferenceKey: string }; const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null }; diff --git a/packages/spotlight/src/preferences/use-user-preference.ts b/packages/spotlight/src/preferences/use-user-preference.ts index cf6c78f77..f2ea49d13 100644 --- a/packages/spotlight/src/preferences/use-user-preference.ts +++ b/packages/spotlight/src/preferences/use-user-preference.ts @@ -10,10 +10,7 @@ import { useSession } from "@homarr/auth/client"; import type { ColorScheme } from "@homarr/definitions"; import { showErrorNotification } from "@homarr/notifications"; import type { SettingsContextProps } from "@homarr/settings/creator"; -import { - userPreferenceDefinitions, - userPreferenceDefinitionByKey, -} from "@homarr/settings"; +import { userPreferenceDefinitions, userPreferenceDefinitionByKey } from "@homarr/settings"; import type { UserPreferenceKey } from "@homarr/settings"; import { useSettings } from "@homarr/settings"; import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client"; @@ -77,7 +74,11 @@ export const useUserPreferences = () => { const userId = session?.user.id; const pendingRef = useRef>({}); - useSyncExternalStore(subscribeOptimistic, () => optimisticVersion, () => optimisticVersion); + useSyncExternalStore( + subscribeOptimistic, + () => optimisticVersion, + () => optimisticVersion, + ); const refresh = () => router.refresh(); @@ -87,9 +88,7 @@ export const useUserPreferences = () => { const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh }); const getEffective = (key: UserPreferenceKey): unknown => - key in optimisticRef.current - ? optimisticRef.current[key] - : settingsRef.current[key as keyof SettingsContextProps]; + key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps]; const pick = (field: UserPreferenceKey, changedKey: UserPreferenceKey, value: unknown) => field === changedKey ? value : getEffective(field); @@ -108,10 +107,8 @@ export const useUserPreferences = () => { homeBoardId: pick("homeBoardId", key, value) as string | null, mobileHomeBoardId: pick("mobileHomeBoardId", key, value) as string | null, }), - firstDayOfWeek: (_key, value) => - firstDayMutation.mutateAsync({ id: userId!, firstDayOfWeek: value as DayOfWeek }), - pingIconsEnabled: (_key, value) => - pingMutation.mutateAsync({ id: userId!, pingIconsEnabled: value as boolean }), + firstDayOfWeek: (_key, value) => firstDayMutation.mutateAsync({ id: userId!, firstDayOfWeek: value as DayOfWeek }), + pingIconsEnabled: (_key, value) => pingMutation.mutateAsync({ id: userId!, pingIconsEnabled: value as boolean }), }; const groupPending: Record = { From 044971680520b7d64abfb7c919b2955a7dec99a1 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 1 Jun 2026 10:25:35 +0200 Subject: [PATCH 14/16] fix(spotlight): address PR review feedback - Clear optimistic store on user/session change to prevent stale values leaking across logins - Move dispatcher lookup before setOptimistic to avoid permanent ghost entries when no dispatcher exists - Inline dayjs.weekdays() calls to reflect runtime locale changes instead of caching at module load - Remove unused UserPreferenceKey import --- .../command/children/preferences/settings-children.tsx | 5 +---- .../spotlight/src/preferences/preference-registry.tsx | 3 +-- .../spotlight/src/preferences/use-user-preference.ts | 10 +++++++++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx index 04b4bdb31..fc0760583 100644 --- a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx +++ b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx @@ -8,7 +8,6 @@ import localeData from "dayjs/plugin/localeData"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import type { ColorScheme } from "@homarr/definitions"; -import type { UserPreferenceKey } from "@homarr/settings"; import { visiblePreferenceDefinitions } from "@homarr/settings"; import { localeConfigurations } from "@homarr/translation"; import { useScopedI18n } from "@homarr/translation/client"; @@ -24,8 +23,6 @@ dayjs.extend(localeData); type SettingsAction = ChildrenAction>; -const weekDays = dayjs.weekdays(false); - const BooleanRow = ({ label, Icon, @@ -87,7 +84,7 @@ const useSettingsActions = (_: Record, query: string): Settings defaultSearchEngineId: () => searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value) ?.label ?? t("searchEngine.none" as never), - firstDayOfWeek: () => weekDays[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "", + firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "", homeBoardId: () => boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ?? t("board.none" as never), diff --git a/packages/spotlight/src/preferences/preference-registry.tsx b/packages/spotlight/src/preferences/preference-registry.tsx index 947f1f4fe..0a7fbe9df 100644 --- a/packages/spotlight/src/preferences/preference-registry.tsx +++ b/packages/spotlight/src/preferences/preference-registry.tsx @@ -97,7 +97,6 @@ const createAsyncSelectableChildren = ( DetailComponent: () => , }); -const weekDays = dayjs.weekdays(false); const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0]; export const preferenceChildrenOptionsByKey: Partial> = { @@ -116,7 +115,7 @@ export const preferenceChildrenOptionsByKey: Partial firstDayOfWeekOptions.map((day) => ({ key: day.toString(), - label: weekDays[day] ?? day.toString(), + label: dayjs.weekdays(false)[day] ?? day.toString(), value: day, })), ), diff --git a/packages/spotlight/src/preferences/use-user-preference.ts b/packages/spotlight/src/preferences/use-user-preference.ts index f2ea49d13..0bbd67a57 100644 --- a/packages/spotlight/src/preferences/use-user-preference.ts +++ b/packages/spotlight/src/preferences/use-user-preference.ts @@ -73,6 +73,13 @@ export const useUserPreferences = () => { const t = useI18n(); const userId = session?.user.id; const pendingRef = useRef>({}); + const prevUserIdRef = useRef(userId); + + useEffect(() => { + if (prevUserIdRef.current === userId) return; + prevUserIdRef.current = userId; + clearOptimistic(userPreferenceDefinitions.map((d) => d.key)); + }, [userId]); useSyncExternalStore( subscribeOptimistic, @@ -163,11 +170,12 @@ export const useUserPreferences = () => { return; } - setOptimistic(key, value); const group = userPreferenceDefinitionByKey[key].mutationGroup!; const dispatcher = groupDispatchers[group]; if (!dispatcher) return; + setOptimistic(key, value); + pendingRef.current[group] = true; try { await dispatcher(key, value); From e67d603570d72386f29efeb1856afae0f4e061fb Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 1 Jun 2026 10:30:41 +0200 Subject: [PATCH 15/16] fix(spotlight): remove all non-null assertions to pass oxlint - Refactor groupDispatchers to accept userId as a parameter instead of closing over nullable userId - Replace mutationGroup! with a guard clause - Use optional chaining for preferenceChildrenOptionsByKey lookup with fallback --- .../src/modes/preferences/groups.tsx | 3 ++- .../src/preferences/use-user-preference.ts | 21 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/spotlight/src/modes/preferences/groups.tsx b/packages/spotlight/src/modes/preferences/groups.tsx index 33b229ef3..213b8784a 100644 --- a/packages/spotlight/src/modes/preferences/groups.tsx +++ b/packages/spotlight/src/modes/preferences/groups.tsx @@ -65,7 +65,8 @@ export const preferencesGroup = createGroup({ useInteraction: (option) => ({ type: "children", ...(option.preferenceKey - ? preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]!({}) + ? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ?? + settingsChildrenOptions({})) : settingsChildrenOptions({})), }), }); diff --git a/packages/spotlight/src/preferences/use-user-preference.ts b/packages/spotlight/src/preferences/use-user-preference.ts index 0bbd67a57..81545a5ab 100644 --- a/packages/spotlight/src/preferences/use-user-preference.ts +++ b/packages/spotlight/src/preferences/use-user-preference.ts @@ -100,22 +100,22 @@ export const useUserPreferences = () => { const pick = (field: UserPreferenceKey, changedKey: UserPreferenceKey, value: unknown) => field === changedKey ? value : getEffective(field); - const groupDispatchers: Record Promise> = { - searchPreferences: (key, value) => + const groupDispatchers: Record Promise> = { + searchPreferences: (uid, key, value) => searchMutation.mutateAsync({ - userId: userId!, + userId: uid, defaultSearchEngineId: pick("defaultSearchEngineId", key, value) as string | null, openInNewTab: pick("openSearchInNewTab", key, value) as boolean, ddgBangsEnabled: pick("ddgBangs", key, value) as boolean, }), - homeBoards: (key, value) => + homeBoards: (uid, key, value) => homeBoardsMutation.mutateAsync({ - userId: userId!, + userId: uid, homeBoardId: pick("homeBoardId", key, value) as string | null, mobileHomeBoardId: pick("mobileHomeBoardId", key, value) as string | null, }), - firstDayOfWeek: (_key, value) => firstDayMutation.mutateAsync({ id: userId!, firstDayOfWeek: value as DayOfWeek }), - pingIconsEnabled: (_key, value) => pingMutation.mutateAsync({ id: userId!, pingIconsEnabled: value as boolean }), + firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }), + pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }), }; const groupPending: Record = { @@ -170,15 +170,16 @@ export const useUserPreferences = () => { return; } - const group = userPreferenceDefinitionByKey[key].mutationGroup!; + const group = userPreferenceDefinitionByKey[key].mutationGroup; + if (!group) return; const dispatcher = groupDispatchers[group]; - if (!dispatcher) return; + if (!dispatcher || !userId) return; setOptimistic(key, value); pendingRef.current[group] = true; try { - await dispatcher(key, value); + await dispatcher(userId, key, value); } catch { clearOptimistic(getGroupKeys(key)); showErrorNotification({ From 3a1adab3e5889d4298d2ce5c9ab14ef0ef5500af Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:31:53 +0000 Subject: [PATCH 16/16] style: auto-fix lint and format --- packages/spotlight/src/modes/preferences/groups.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spotlight/src/modes/preferences/groups.tsx b/packages/spotlight/src/modes/preferences/groups.tsx index 213b8784a..151259d6f 100644 --- a/packages/spotlight/src/modes/preferences/groups.tsx +++ b/packages/spotlight/src/modes/preferences/groups.tsx @@ -66,7 +66,7 @@ export const preferencesGroup = createGroup({ type: "children", ...(option.preferenceKey ? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ?? - settingsChildrenOptions({})) + settingsChildrenOptions({})) : settingsChildrenOptions({})), }), });