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,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;