From 5501ee6a7dcd9ead59dbabd54ce458029284f38a Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 29 May 2026 17:26:27 +0200 Subject: [PATCH] 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);