diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 114aa9a7d..f3f5da75e 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -546,7 +546,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))) @@ -607,7 +607,7 @@ export const userRouter = createTRPCRouter({ .set({ firstDayOfWeek: input.firstDayOfWeek, }) - .where(eq(users.id, ctx.session.user.id)); + .where(eq(users.id, input.id)); }), completeTour: protectedProcedure .input(z.object({ tour: z.enum(["manage", "board"]) })) 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..528ce8aba --- /dev/null +++ b/packages/settings/src/preferences/definitions.ts @@ -0,0 +1,78 @@ +export const userPreferenceDefinitions = [ + { + 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); diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json index 39868e02e..b9c16aef8 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/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..1ea06198e 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -1,10 +1,10 @@ "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"; +import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react"; import { hotkeys } from "@homarr/definitions"; import type { TranslationObject } from "@homarr/translation"; @@ -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,32 @@ 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 +88,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,31 +118,53 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh }, }} rightSection={ - mode === defaultMode ? null : ( + 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(); + inputRef.current?.focus(); + return; + } + setMode(defaultMode); - setChildrenOptions(null); + clearChildrenStack(); 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 ? : } ) } 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 +172,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..e73f91d84 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( @@ -18,7 +19,7 @@ export const languageChildrenOptions = createChildrenOptions Math.min( languageA.configuration.name.toLowerCase().indexOf(normalizedQuery), @@ -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/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/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..fc0760583 --- /dev/null +++ b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx @@ -0,0 +1,156 @@ +"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 { visiblePreferenceDefinitions } 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 { useUserPreferences } from "../../../../preferences/use-user-preference"; +import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../../../preferences/preference-registry"; + +dayjs.extend(localeData); + +type SettingsAction = ChildrenAction>; + +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 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"); + const { data: session } = useSession(); + const isAuthenticated = Boolean(session?.user); + const normalizedQuery = query.trim().toLowerCase(); + const preferences = useUserPreferences(); + + const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated }); + const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated }); + + 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: () => 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), + mobileHomeBoardId: () => + boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ?? + t("board.none" as never), + }; + + return visiblePreferenceDefinitions(isAuthenticated) + .map((definition): SettingsAction | null => { + const label = t(`${definition.key}.label` as never); + const Icon = preferenceIcons[definition.key]; + + const matchesSearch = [label, ...definition.aliases].some((v) => v.toLowerCase().includes(normalizedQuery)); + if (!matchesSearch) return null; + + if (definition.kind === "boolean") { + const pref = preferences.getPreference(definition.key); + return { + key: definition.key, + Component: () => ( + + ), + useInteraction: interaction.javaScript(() => ({ + onSelect: pref.isPending ? () => undefined : () => pref.setValue(!(pref.value as boolean) as never), + closeSpotlightOnTrigger: false, + })), + }; + } + + if (definition.kind === "link") { + return { + key: definition.key, + Component: () => , + useInteraction: interaction.link(() => ({ href: `/manage/users/${session?.user.id ?? ""}/general` })), + }; + } + + 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>({ + 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..151259d6f --- /dev/null +++ b/packages/spotlight/src/modes/preferences/groups.tsx @@ -0,0 +1,72 @@ +import { Group, Text } from "@mantine/core"; +import { IconSettings } from "@tabler/icons-react"; + +import { useSession } from "@homarr/auth/client"; +import { colorSchemes } from "@homarr/definitions"; +import { visiblePreferenceDefinitions } from "@homarr/settings"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; + +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 }; + +const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null }; + +export const preferencesGroup = createGroup({ + keyPath: "key", + title: (t) => t("search.mode.command.group.preferences.title"), + useOptions(query) { + const t = useI18n(); + const tColorScheme = useScopedI18n("common.colorScheme.options"); + const { data: session } = useSession(); + const definitions = visiblePreferenceDefinitions(Boolean(session?.user)); + const q = query.trim().toLowerCase(); + + if (q.length === 0) return [settingsOption]; + + 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, + ...def.aliases, + ...(def.key === "colorScheme" ? colorSchemes.map((s) => tColorScheme(s)) : []), + ]; + if (!searchValues.some((v) => v.toLowerCase().includes(q))) return []; + return [{ key: def.key, preferenceKey: def.key }]; + }); + + if (matching.length > 0) return matching; + + 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 as keyof typeof preferenceIcons] + : IconSettings; + const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title"); + + return ( + + + {label} + + ); + }, + filter: () => true, + useInteraction: (option) => ({ + type: "children", + ...(option.preferenceKey + ? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ?? + settingsChildrenOptions({})) + : 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..0a7fbe9df --- /dev/null +++ b/packages/spotlight/src/preferences/preference-registry.tsx @@ -0,0 +1,136 @@ +"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 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: dayjs.weekdays(false)[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 new file mode 100644 index 000000000..81545a5ab --- /dev/null +++ b/packages/spotlight/src/preferences/use-user-preference.ts @@ -0,0 +1,204 @@ +"use client"; + +import { useEffect, useMemo, useRef, useSyncExternalStore } 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 { 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"; + +// --- Optimistic store (module-level, shared across all hook instances) --- + +const optimisticRef: { current: Partial> } = { current: {} }; +let optimisticVersion = 0; +const optimisticListeners = new Set<() => void>(); + +const notifyListeners = () => { + optimisticVersion += 1; + optimisticListeners.forEach((fn) => fn()); +}; + +const subscribeOptimistic = (listener: () => void) => { + optimisticListeners.add(listener); + return () => optimisticListeners.delete(listener); +}; + +const setOptimistic = (key: UserPreferenceKey, value: unknown) => { + optimisticRef.current[key] = value; + notifyListeners(); +}; + +const clearOptimistic = (keys: readonly UserPreferenceKey[]) => { + let changed = false; + for (const key of keys) { + changed ||= key in optimisticRef.current; + delete optimisticRef.current[key]; + } + if (changed) notifyListeners(); +}; + +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(); + const settingsRef = useRef(settings); + settingsRef.current = settings; + + const { data: session } = useSession(); + const { colorScheme, setColorScheme } = useMantineColorScheme(); + const currentLocale = useCurrentLocale(); + const { changeLocale } = useChangeLocale(); + 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, + () => optimisticVersion, + () => optimisticVersion, + ); + + 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: (uid, key, value) => + searchMutation.mutateAsync({ + 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: (uid, key, value) => + homeBoardsMutation.mutateAsync({ + userId: uid, + homeBoardId: pick("homeBoardId", key, value) as string | null, + mobileHomeBoardId: pick("mobileHomeBoardId", key, value) as string | null, + }), + 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 = { + searchPreferences: searchMutation.isPending, + homeBoards: homeBoardsMutation.isPending, + firstDayOfWeek: firstDayMutation.isPending, + pingIconsEnabled: pingMutation.isPending, + }; + + const persistedValues = useMemo>(() => { + const values: Record = { + colorScheme: colorScheme as ColorScheme, + locale: currentLocale, + fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null, + }; + 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(() => { + const matchedKeys = Object.entries(persistedValues) + .filter(([key, value]) => optimisticRef.current[key as UserPreferenceKey] === value) + .map(([key]) => key as UserPreferenceKey); + clearOptimistic(matchedKeys); + }, [persistedValues]); + + const getValue = (key: UserPreferenceKey) => + key in optimisticRef.current ? optimisticRef.current[key] : persistedValues[key]; + + 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 clientSetters: Partial void>> = { + colorScheme: (value) => setColorScheme(value as ColorScheme), + locale: (value) => changeLocale(value as typeof currentLocale), + fullPreferencesPage: () => undefined, + }; + + const setValueAsync = async (key: UserPreferenceKey, value: unknown) => { + if (isDbBacked(key) && !userId) return; + if (isPreferencePending(key)) return; + + const clientSetter = clientSetters[key]; + if (clientSetter) { + clientSetter(value); + return; + } + + const group = userPreferenceDefinitionByKey[key].mutationGroup; + if (!group) return; + const dispatcher = groupDispatchers[group]; + if (!dispatcher || !userId) return; + + setOptimistic(key, value); + + pendingRef.current[group] = true; + try { + await dispatcher(userId, key, value); + } catch { + clearOptimistic(getGroupKeys(key)); + showErrorNotification({ + message: t("search.mode.command.group.preferences.notification.error.message"), + }); + } finally { + pendingRef.current[group] = false; + } + }; + + return { + getPreference(preferenceKey: UserPreferenceKey) { + return { + value: getValue(preferenceKey), + setValue: (value: never) => setValueAsync(preferenceKey, value), + isPending: isPreferencePending(preferenceKey), + }; + }, + }; +}; + +export const useUserPreference = (key: UserPreferenceKey) => useUserPreferences().getPreference(key); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 1cd56f36f..d0d798f7c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4646,6 +4646,113 @@ "label": "Create a new group" } } + }, + "preferences": { + "title": "Preferences", + "children": { + "detail": { + "title": "Select a preference to change", + "backHint": "Press Backspace or use the back button to go back", + "backAction": "Go back" + } + }, + "notification": { + "error": { + "message": "Unable to update preference" + } + }, + "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 b1ec0610d..fded9c869 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2156,6 +2156,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 @@ -3280,9 +3283,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==} @@ -10484,18 +10484,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