wip: user preferences in command palette

This commit is contained in:
Thomas Camlong committed 2026-05-29 15:37:00 +02:00
1 parent 4a67bf0ef3
commit 19d86daa87
24 files changed
+921 -116

No files matched your search

+1
View File
@@ -1 +1,2 @@
export * from "./src/context";
export * from "./src/preferences/definitions";
@@ -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<UserPreferenceKey, UserPreferenceDefinition>;
@@ -33,7 +33,10 @@ export const ChildrenActionItem = ({ childrenOptions, action, query, setChildren
<Spotlight.Action
renderRoot={renderRoot}
onClick={onClick}
closeSpotlightOnTrigger={interaction.type !== "children"}
closeSpotlightOnTrigger={
interaction.type !== "children" &&
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
}
className={classes.spotlightAction}
>
<action.Component {...childrenOptions.option} />
@@ -58,7 +58,8 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
interaction.type !== "mode" &&
interaction.type !== "children" &&
interaction.type !== "none" &&
interaction.type !== "setQuery"
interaction.type !== "setQuery" &&
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
}
className={classes.spotlightAction}
>
+49 -27
View File
@@ -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 (
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
);
@@ -46,13 +46,29 @@ interface SpotlightWithActiveModeProps {
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = queryState;
const [mode, setMode] = modeState;
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
const childrenOptions = childrenStack.at(-1) ?? null;
const t = useI18n();
const inputRef = useRef<HTMLInputElement>(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 (
<MantineSpotlight.Root
@@ -61,7 +77,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
onSpotlightClose={() => {
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 : (
<ActionIcon
onClick={() => {
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 ? (
<Group>
<Group key={childrenStack.length}>
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
</Group>
) : null}
@@ -134,25 +160,21 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
<MantineSpotlight.ActionsList>
{childrenOptions ? (
<SpotlightChildrenActions
key={childrenStack.length}
childrenOptions={childrenOptions}
query={query}
setChildrenOptions={setChildrenOptions}
setChildrenOptions={pushChildrenOptions}
/>
) : (
<SpotlightActionGroups
setQuery={setQuery}
setMode={(mode) => {
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}
+4 -1
View File
@@ -10,7 +10,10 @@ const createSearchInteraction = <TType extends string>(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<void> }>(),
createSearchInteraction("javaScript").optionsType<{
onSelect: () => MaybePromise<void>;
closeSpotlightOnTrigger?: boolean;
}>(),
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(),
createSearchInteraction("children").optionsType<{
@@ -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<Record<string, unknown>>({
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<Record<string, unkn
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
),
)
.map(({ localeKey, configuration }) => ({
key: localeKey,
Component() {
return (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap">
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
<Group wrap="nowrap" gap="xs">
<Text>{configuration.name}</Text>
<Text size="xs" c="dimmed" inherit>
({configuration.translatedName})
</Text>
</Group>
.map(({ localeKey, configuration }) =>
createCheckmarkPreferenceAction({
key: localeKey,
isSelected: localeKey === currentLocale,
onSelect: () => setLocale(localeKey as never),
isPending,
labelContent: (
<Group wrap="nowrap">
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
<Group wrap="nowrap" gap="xs">
<Text>{configuration.name}</Text>
<Text size="xs" c="dimmed" inherit>
({configuration.translatedName})
</Text>
</Group>
{localeKey === currentLocale && <IconCheck color="currentColor" size={24} />}
</Group>
);
},
useInteraction() {
const { changeLocale } = useChangeLocale();
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
},
}));
},
DetailComponent: () => {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.command.group.globalCommand.option.language.children.detail.title")}</Text>
</Stack>
);
),
}),
);
},
DetailComponent: () => <PreferenceDetailHeader titleKey="locale.children.detail.title" />,
});
@@ -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 (
<Stack mx="md" my="sm" gap="xs">
<Text>{tOption(titleKey as never)}</Text>
<Text size="xs" c="dimmed">
{t("children.detail.backHint")}
</Text>
</Stack>
);
};
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 (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%" opacity={isPending ? 0.5 : 1}>
{labelContent ?? <Text c={dimmed ? "dimmed" : undefined}>{label}</Text>}
{isSelected && <IconCheck color="currentColor" size={24} />}
</Group>
);
},
useInteraction: interaction.javaScript(() => ({
onSelect: isPending ? () => undefined : onSelect,
closeSpotlightOnTrigger: false,
})),
});
export const createLoadingPreferenceAction = () => ({
key: "loading",
Component() {
return (
<Center mx="md" my="sm">
<Loader size="sm" />
</Center>
);
},
useInteraction: interaction.javaScript(() => ({
onSelect: () => undefined,
closeSpotlightOnTrigger: false,
})),
});
@@ -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<UserPreferenceKey, "homeBoardId" | "mobileHomeBoardId">;
}
const boardDetailTitleKeys = {
homeBoardId: "homeBoardId.children.detail.title",
mobileHomeBoardId: "mobileHomeBoardId.children.detail.title",
} as const;
export const createBoardChildrenOptions = (config: BoardChildrenOptionsConfig) =>
createChildrenOptions<Record<string, unknown>>({
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: () => <PreferenceDetailHeader titleKey={boardDetailTitleKeys[config.preferenceKey]} />,
});
@@ -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<Record<string, unknown>>({
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: () => <PreferenceDetailHeader titleKey="colorScheme.children.detail.title" />,
});
@@ -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<Record<string, unknown>>({
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: () => <PreferenceDetailHeader titleKey="firstDayOfWeek.children.detail.title" />,
});
@@ -0,0 +1 @@
export { settingsChildrenOptions } from "./settings-children";
@@ -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<typeof createChildrenOptions<Record<string, unknown>>>;
export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
colorScheme: IconDeviceDesktop,
locale: IconLanguage,
defaultSearchEngineId: IconSearch,
openSearchInNewTab: IconExternalLink,
ddgBangs: IconWorldWww,
firstDayOfWeek: IconCalendarWeek,
homeBoardId: IconHome,
mobileHomeBoardId: IconLayoutBoard,
pingIconsEnabled: IconActivity,
fullPreferencesPage: IconSettings,
};
export const preferenceChildrenOptionsByKey: Record<ChildrenPreferenceKey, PreferenceChildrenOptionsFactory> = {
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);
@@ -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<Record<string, unknown>>({
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: () => <PreferenceDetailHeader titleKey="defaultSearchEngineId.children.detail.title" />,
});
@@ -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];
};
@@ -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<Record<string, unknown>>;
const weekDays = dayjs.weekdays(false);
const BooleanRow = ({ label, Icon, checked, isPending }: { label: string; Icon: TablerIcon; checked: boolean; isPending: boolean }) => (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
<Switch checked={checked} readOnly size="md" disabled={isPending} styles={{ track: { cursor: "pointer" } }} />
</Group>
);
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel: string }) => (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
<Text size="sm" c="dimmed">{valueLabel}</Text>
</Group>
);
const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
<Group mx="md" my="sm" wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
);
const useSettingsActions = (_: Record<string, unknown>, 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<Exclude<UserPreferenceKey, "fullPreferencesPage">, { value: unknown; setValue: (v: never) => void; isPending: boolean }> = {
colorScheme,
locale,
defaultSearchEngineId,
openSearchInNewTab,
ddgBangs,
firstDayOfWeek,
homeBoardId,
mobileHomeBoardId,
pingIconsEnabled,
};
const valueLabels: Record<Exclude<UserPreferenceKey, "fullPreferencesPage">, 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: () => <BooleanRow label={label} Icon={Icon} checked={checked} isPending={state.isPending} />,
useInteraction: interaction.javaScript(() => ({
onSelect: () => state.setValue(!checked as never),
closeSpotlightOnTrigger: false,
})),
}];
}
if (definition.kind === "link") {
return [{
key: definition.key,
Component: () => <LinkRow label={label} Icon={Icon} />,
useInteraction: interaction.link(() => ({
href: `/manage/users/${session?.user.id ?? ""}/general`,
})),
}];
}
const childrenKey = definition.key as keyof typeof preferenceChildrenOptionsByKey;
return [{
key: definition.key,
Component: () => <SelectRow label={label} Icon={Icon} valueLabel={valueLabels[definition.key as keyof typeof valueLabels]} />,
useInteraction: interaction.children(preferenceChildrenOptionsByKey[childrenKey]),
}];
});
};
export const settingsChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: useSettingsActions,
DetailComponent: () => {
const t = useScopedI18n("search.mode.command.group.preferences");
return (
<Stack mx="md" my="sm" gap="xs">
<Text>{t("title")}</Text>
<Text size="xs" c="dimmed">{t("children.detail.backHint")}</Text>
</Stack>
);
},
});
@@ -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<string, unknown>.
@@ -52,33 +47,9 @@ export const globalCommandGroup = createGroup<Command>({
},
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,
@@ -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;
@@ -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<string, unknown>
// 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()];
+2 -1
View File
@@ -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;
@@ -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 (
<Group px="md" py="sm" wrap="nowrap" gap="sm">
<IconSettings stroke={1.5} />
<Text>{t("title")}</Text>
</Group>
);
},
filter: (_query, _option) => true,
useInteraction: interaction.children(settingsChildrenOptions),
});
@@ -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<UserPreferenceKey>([
"defaultSearchEngineId",
"openSearchInNewTab",
"ddgBangs",
"firstDayOfWeek",
"homeBoardId",
"mobileHomeBoardId",
"pingIconsEnabled",
]);
const optimisticPreferencesRef: { current: Partial<Record<UserPreferenceKey, unknown>> } = { 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<Record<UserPreferenceKey, unknown>> = {
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<typeof useRouter>) => {
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<unknown>();
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<UserPreferenceKey, unknown> = {
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<UserPreferenceKey, (nextValue: never) => 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<UserPreferenceKey, boolean> = {
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],
};
};
+101
View File
@@ -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"
}
}
}
}
},
+1 -9
View File
@@ -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