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