continue working on user prefernces in command palette
This commit is contained in:
11 files changed
+415
-161
No files matched your search
@@ -542,7 +542,7 @@ export const userRouter = createTRPCRouter({
|
||||
.set({
|
||||
pingIconsEnabled: input.pingIconsEnabled,
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changeDdgBangs: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
|
||||
@@ -603,7 +603,7 @@ export const userRouter = createTRPCRouter({
|
||||
.set({
|
||||
firstDayOfWeek: input.firstDayOfWeek,
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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 },
|
||||
{ key: "colorScheme", kind: "select", guest: true, aliases: ["theme", "dark", "light", "auto"] },
|
||||
{ key: "locale", kind: "select", guest: true, aliases: ["language", "translation"] },
|
||||
{ key: "defaultSearchEngineId", kind: "searchEngine", guest: false, aliases: ["search", "search engine"] },
|
||||
{ key: "openSearchInNewTab", kind: "boolean", guest: false, aliases: ["search", "new tab", "external"] },
|
||||
{ key: "ddgBangs", kind: "boolean", guest: false, aliases: ["duckduckgo", "bangs", "search"] },
|
||||
{ key: "firstDayOfWeek", kind: "select", guest: false, aliases: ["calendar", "week", "weekday"] },
|
||||
{ key: "homeBoardId", kind: "board", guest: false, aliases: ["home", "board", "dashboard"] },
|
||||
{ key: "mobileHomeBoardId", kind: "board", guest: false, aliases: ["mobile", "home", "board", "dashboard"] },
|
||||
{ key: "pingIconsEnabled", kind: "boolean", guest: false, aliases: ["ping", "icon", "status"] },
|
||||
{ key: "fullPreferencesPage", kind: "link", guest: false, aliases: ["settings", "manage", "profile"] },
|
||||
] as const;
|
||||
|
||||
export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number];
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||
import { IconSearch, IconX } from "@tabler/icons-react";
|
||||
import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
@@ -58,11 +58,14 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
setChildrenStack([]);
|
||||
}, []);
|
||||
|
||||
const pushChildrenOptions = useCallback((options: ChildrenOptions) => {
|
||||
setChildrenStack((currentStack) => [...currentStack, options]);
|
||||
setQuery("");
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}, [setQuery]);
|
||||
const pushChildrenOptions = useCallback(
|
||||
(options: ChildrenOptions) => {
|
||||
setChildrenStack((currentStack) => [...currentStack, options]);
|
||||
setQuery("");
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
},
|
||||
[setQuery],
|
||||
);
|
||||
|
||||
const popChildrenOptions = useCallback(() => {
|
||||
setChildrenStack((currentStack) => currentStack.slice(0, -1));
|
||||
@@ -117,6 +120,11 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
rightSection={
|
||||
mode === defaultMode && childrenStack.length === 0 ? null : (
|
||||
<ActionIcon
|
||||
title={
|
||||
childrenStack.length > 0
|
||||
? t("search.mode.command.group.preferences.children.detail.backAction")
|
||||
: t("common.action.close")
|
||||
}
|
||||
onClick={() => {
|
||||
if (childrenStack.length > 0) {
|
||||
popChildrenOptions();
|
||||
@@ -129,9 +137,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
variant="subtle"
|
||||
aria-label={t("common.action.close")}
|
||||
aria-label={
|
||||
childrenStack.length > 0
|
||||
? t("search.mode.command.group.preferences.children.detail.backAction")
|
||||
: t("common.action.close")
|
||||
}
|
||||
>
|
||||
<IconX stroke={1.5} />
|
||||
{childrenStack.length > 0 ? <IconArrowLeft stroke={1.5} /> : <IconX stroke={1.5} />}
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
||||
configuration.name.toLowerCase().includes(normalizedQuery) ||
|
||||
configuration.translatedName.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
.sort(
|
||||
.toSorted(
|
||||
(languageA, languageB) =>
|
||||
Math.min(
|
||||
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
|
||||
|
||||
@@ -15,14 +15,20 @@ import { userPreferenceDefinitions } from "@homarr/settings";
|
||||
import type { UserPreferenceKey } from "@homarr/settings";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { createChildrenOptions } from "../../../../lib/children";
|
||||
import type { createChildrenOptions } from "../../../../lib/children";
|
||||
import { languageChildrenOptions } from "../language";
|
||||
import { createBoardChildrenOptions } from "./board";
|
||||
import { colorSchemeChildrenOptions } from "./color-scheme";
|
||||
import { firstDayOfWeekChildrenOptions } from "./first-day-of-week";
|
||||
import { searchEngineChildrenOptions } from "./search-engine";
|
||||
|
||||
type ChildrenPreferenceKey = "colorScheme" | "locale" | "defaultSearchEngineId" | "firstDayOfWeek" | "homeBoardId" | "mobileHomeBoardId";
|
||||
type ChildrenPreferenceKey =
|
||||
| "colorScheme"
|
||||
| "locale"
|
||||
| "defaultSearchEngineId"
|
||||
| "firstDayOfWeek"
|
||||
| "homeBoardId"
|
||||
| "mobileHomeBoardId";
|
||||
|
||||
type PreferenceChildrenOptionsFactory = ReturnType<typeof createChildrenOptions<Record<string, unknown>>>;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ 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 { useUserPreferences } from "../../../../preferences/use-user-preference";
|
||||
import { preferenceChildrenOptionsByKey, preferenceIcons, visiblePreferenceDefinitions } from "./registry";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
@@ -25,7 +25,17 @@ 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 }) => (
|
||||
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} />
|
||||
@@ -41,7 +51,9 @@ const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIco
|
||||
<Icon stroke={1.5} size={20} />
|
||||
<Text>{label}</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">{valueLabel}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{valueLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -58,24 +70,28 @@ const useSettingsActions = (_: Record<string, unknown>, query: string): Settings
|
||||
const { data: session } = useSession();
|
||||
const isAuthenticated = Boolean(session?.user);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const preferences = useUserPreferences();
|
||||
|
||||
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 colorScheme = preferences.getPreference("colorScheme");
|
||||
const locale = preferences.getPreference("locale");
|
||||
const defaultSearchEngineId = preferences.getPreference("defaultSearchEngineId");
|
||||
const openSearchInNewTab = preferences.getPreference("openSearchInNewTab");
|
||||
const ddgBangs = preferences.getPreference("ddgBangs");
|
||||
const firstDayOfWeek = preferences.getPreference("firstDayOfWeek");
|
||||
const homeBoardId = preferences.getPreference("homeBoardId");
|
||||
const mobileHomeBoardId = preferences.getPreference("mobileHomeBoardId");
|
||||
const pingIconsEnabled = preferences.getPreference("pingIconsEnabled");
|
||||
|
||||
const boardsQuery = clientApi.board.getAllBoards.useQuery();
|
||||
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery();
|
||||
const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated });
|
||||
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated });
|
||||
|
||||
const boards = boardsQuery.data ?? [];
|
||||
const engines = searchEnginesQuery.data ?? [];
|
||||
|
||||
const prefState: Record<Exclude<UserPreferenceKey, "fullPreferencesPage">, { value: unknown; setValue: (v: never) => void; isPending: boolean }> = {
|
||||
const prefState: Record<
|
||||
Exclude<UserPreferenceKey, "fullPreferencesPage">,
|
||||
{ value: unknown; setValue: (v: never) => void; isPending: boolean }
|
||||
> = {
|
||||
colorScheme,
|
||||
locale,
|
||||
defaultSearchEngineId,
|
||||
@@ -90,7 +106,8 @@ const useSettingsActions = (_: Record<string, unknown>, query: string): Settings
|
||||
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"),
|
||||
defaultSearchEngineId:
|
||||
engines.find((e) => e.value === defaultSearchEngineId.value)?.label ?? t("searchEngine.none"),
|
||||
openSearchInNewTab: "",
|
||||
ddgBangs: "",
|
||||
firstDayOfWeek: weekDays[firstDayOfWeek.value as DayOfWeek] ?? String(firstDayOfWeek.value),
|
||||
@@ -105,37 +122,46 @@ const useSettingsActions = (_: Record<string, unknown>, query: string): Settings
|
||||
const label = t(`${definition.key}.label` as never);
|
||||
const Icon = preferenceIcons[definition.key];
|
||||
|
||||
if (!label.toLowerCase().includes(normalizedQuery)) return [];
|
||||
const matchesSearch = [label, ...definition.aliases].some((value) => value.toLowerCase().includes(normalizedQuery));
|
||||
if (!matchesSearch) return [];
|
||||
|
||||
if (definition.kind === "boolean") {
|
||||
const state = prefState[definition.key as keyof typeof prefState];
|
||||
const checked = state.value as boolean;
|
||||
return [{
|
||||
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,
|
||||
})),
|
||||
}];
|
||||
return [
|
||||
{
|
||||
key: definition.key,
|
||||
Component: () => <BooleanRow label={label} Icon={Icon} checked={checked} isPending={state.isPending} />,
|
||||
useInteraction: interaction.javaScript(() => ({
|
||||
onSelect: state.isPending ? () => undefined : () => 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`,
|
||||
})),
|
||||
}];
|
||||
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]),
|
||||
}];
|
||||
return [
|
||||
{
|
||||
key: definition.key,
|
||||
Component: () => (
|
||||
<SelectRow label={label} Icon={Icon} valueLabel={valueLabels[definition.key as keyof typeof valueLabels]} />
|
||||
),
|
||||
useInteraction: interaction.children(preferenceChildrenOptionsByKey[childrenKey]),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
@@ -146,9 +172,10 @@ export const settingsChildrenOptions = createChildrenOptions<Record<string, unkn
|
||||
return (
|
||||
<Stack mx="md" my="sm" gap="xs">
|
||||
<Text>{t("title")}</Text>
|
||||
<Text size="xs" c="dimmed">{t("children.detail.backHint")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("children.detail.backHint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,25 +1,84 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { userPreferenceDefinitions } from "@homarr/settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../command/children/preferences/registry";
|
||||
import { settingsChildrenOptions } from "../command/children/preferences/settings-children";
|
||||
|
||||
export const preferencesGroup = createGroup({
|
||||
const normalizeSearchValue = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
type PreferenceChildKey = Extract<keyof typeof preferenceChildrenOptionsByKey, string>;
|
||||
|
||||
type PreferencesGroupOption =
|
||||
| {
|
||||
key: "settings";
|
||||
preferenceKey: null;
|
||||
}
|
||||
| {
|
||||
key: PreferenceChildKey;
|
||||
preferenceKey: PreferenceChildKey;
|
||||
};
|
||||
|
||||
const settingsOption = { key: "settings", preferenceKey: null } satisfies PreferencesGroupOption;
|
||||
|
||||
export const preferencesGroup = createGroup<PreferencesGroupOption>({
|
||||
keyPath: "key",
|
||||
title: (t) => t("search.mode.command.group.preferences.title"),
|
||||
options: [{ key: "settings" }],
|
||||
Component: () => {
|
||||
useOptions(query) {
|
||||
const t = useI18n();
|
||||
const tColorScheme = useScopedI18n("common.colorScheme.options");
|
||||
const { data: session } = useSession();
|
||||
const isAuthenticated = Boolean(session?.user);
|
||||
const visibleDefinitions = userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated);
|
||||
const normalizedQuery = normalizeSearchValue(query);
|
||||
if (normalizedQuery.length === 0) return [settingsOption];
|
||||
|
||||
const settingsSearchValues = [
|
||||
t("search.mode.command.group.preferences.title"),
|
||||
...visibleDefinitions.flatMap((definition) => definition.aliases),
|
||||
];
|
||||
|
||||
const matchingPreferenceOptions = visibleDefinitions.flatMap((definition): PreferencesGroupOption[] => {
|
||||
const label = t(`search.mode.command.group.preferences.option.${definition.key}.label` as never);
|
||||
const searchValues = [
|
||||
label,
|
||||
...definition.aliases,
|
||||
...(definition.key === "colorScheme" ? colorSchemes.map((scheme) => tColorScheme(scheme)) : []),
|
||||
];
|
||||
if (!searchValues.some((value) => normalizeSearchValue(value).includes(normalizedQuery))) return [];
|
||||
if (!(definition.key in preferenceChildrenOptionsByKey)) return [];
|
||||
const preferenceKey = definition.key as PreferenceChildKey;
|
||||
|
||||
return [{ key: preferenceKey, preferenceKey }];
|
||||
});
|
||||
|
||||
if (matchingPreferenceOptions.length > 0) return matchingPreferenceOptions;
|
||||
|
||||
return settingsSearchValues.some((value) => normalizeSearchValue(value).includes(normalizedQuery))
|
||||
? [settingsOption]
|
||||
: [];
|
||||
},
|
||||
Component: (option) => {
|
||||
const t = useScopedI18n("search.mode.command.group.preferences");
|
||||
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
|
||||
const Icon = option.preferenceKey ? preferenceIcons[option.preferenceKey] : IconSettings;
|
||||
const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title");
|
||||
|
||||
return (
|
||||
<Group px="md" py="sm" wrap="nowrap" gap="sm">
|
||||
<IconSettings stroke={1.5} />
|
||||
<Text>{t("title")}</Text>
|
||||
<Icon stroke={1.5} />
|
||||
<Text>{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
filter: (_query, _option) => true,
|
||||
useInteraction: interaction.children(settingsChildrenOptions),
|
||||
filter: () => true,
|
||||
useInteraction: (option) => ({
|
||||
type: "children",
|
||||
...(option.preferenceKey ? preferenceChildrenOptionsByKey[option.preferenceKey]({}) : settingsChildrenOptions({})),
|
||||
}),
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMantineColorScheme } from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
@@ -8,10 +8,11 @@ import type { DayOfWeek } from "@mantine/dates";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { UserPreferenceKey } from "@homarr/settings";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
|
||||
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
const optimisticPreferenceKeys = new Set<UserPreferenceKey>([
|
||||
"defaultSearchEngineId",
|
||||
@@ -24,14 +25,36 @@ const optimisticPreferenceKeys = new Set<UserPreferenceKey>([
|
||||
]);
|
||||
|
||||
const optimisticPreferencesRef: { current: Partial<Record<UserPreferenceKey, unknown>> } = { current: {} };
|
||||
let optimisticPreferencesVersion = 0;
|
||||
const optimisticPreferenceListeners = new Set<() => void>();
|
||||
|
||||
const searchPreferenceKeys = ["defaultSearchEngineId", "openSearchInNewTab", "ddgBangs"] as const;
|
||||
const homeBoardPreferenceKeys = ["homeBoardId", "mobileHomeBoardId"] as const;
|
||||
|
||||
const notifyOptimisticPreferenceListeners = () => {
|
||||
optimisticPreferencesVersion += 1;
|
||||
optimisticPreferenceListeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const subscribeToOptimisticPreferenceChanges = (listener: () => void) => {
|
||||
optimisticPreferenceListeners.add(listener);
|
||||
return () => optimisticPreferenceListeners.delete(listener);
|
||||
};
|
||||
|
||||
const setOptimisticPreference = (preferenceKey: UserPreferenceKey, value: unknown) => {
|
||||
optimisticPreferencesRef.current[preferenceKey] = value;
|
||||
notifyOptimisticPreferenceListeners();
|
||||
};
|
||||
|
||||
const clearOptimisticPreferences = (keys: readonly UserPreferenceKey[]) => {
|
||||
let changed = false;
|
||||
keys.forEach((preferenceKey) => {
|
||||
changed ||= preferenceKey in optimisticPreferencesRef.current;
|
||||
delete optimisticPreferencesRef.current[preferenceKey];
|
||||
});
|
||||
if (!changed) return;
|
||||
|
||||
notifyOptimisticPreferenceListeners();
|
||||
};
|
||||
|
||||
const getEffectivePreferenceValue = (preferenceKey: UserPreferenceKey, settings: SettingsContextProps): unknown => {
|
||||
@@ -57,7 +80,7 @@ const refreshAfterMutation = (router: ReturnType<typeof useRouter>) => {
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
export const useUserPreference = (key: UserPreferenceKey) => {
|
||||
export const useUserPreferences = () => {
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const settingsRef = useRef(settings);
|
||||
@@ -67,141 +90,258 @@ export const useUserPreference = (key: UserPreferenceKey) => {
|
||||
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 t = useI18n();
|
||||
const pendingGroupsRef = useRef({
|
||||
search: false,
|
||||
homeBoards: false,
|
||||
firstDayOfWeek: false,
|
||||
pingIcons: false,
|
||||
});
|
||||
|
||||
const { mutate: mutateFirstDayOfWeek, isPending: isFirstDayOfWeekPending } =
|
||||
clientApi.user.changeFirstDayOfWeek.useMutation({
|
||||
useSyncExternalStore(
|
||||
subscribeToOptimisticPreferenceChanges,
|
||||
() => optimisticPreferencesVersion,
|
||||
() => optimisticPreferencesVersion,
|
||||
);
|
||||
|
||||
const showPreferenceErrorNotification = () => {
|
||||
showErrorNotification({
|
||||
message: t("search.mode.command.group.preferences.notification.error.message"),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutateAsync: mutateSearchPreferencesAsync, isPending: isSearchPreferencesPending } =
|
||||
clientApi.user.changeSearchPreferences.useMutation({
|
||||
onSuccess: () => refreshAfterMutation(router),
|
||||
onError: () => clearOptimisticPreferences(["firstDayOfWeek"]),
|
||||
});
|
||||
|
||||
const { mutate: mutatePingIconsEnabled, isPending: isPingIconsPending } =
|
||||
const { mutateAsync: mutateHomeBoardsAsync, isPending: isHomeBoardsPending } =
|
||||
clientApi.user.changeHomeBoards.useMutation({
|
||||
onSuccess: () => refreshAfterMutation(router),
|
||||
});
|
||||
|
||||
const { mutateAsync: mutateFirstDayOfWeekAsync, isPending: isFirstDayOfWeekPending } =
|
||||
clientApi.user.changeFirstDayOfWeek.useMutation({
|
||||
onSuccess: () => refreshAfterMutation(router),
|
||||
});
|
||||
|
||||
const { mutateAsync: mutatePingIconsEnabledAsync, isPending: isPingIconsPending } =
|
||||
clientApi.user.changePingIconsEnabled.useMutation({
|
||||
onSuccess: () => refreshAfterMutation(router),
|
||||
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 persistedValues = useMemo<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,
|
||||
}),
|
||||
[
|
||||
colorScheme,
|
||||
currentLocale,
|
||||
settings.defaultSearchEngineId,
|
||||
settings.openSearchInNewTab,
|
||||
settings.ddgBangs,
|
||||
settings.firstDayOfWeek,
|
||||
settings.homeBoardId,
|
||||
settings.mobileHomeBoardId,
|
||||
settings.pingIconsEnabled,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
const persistedValue = persistedValues[key];
|
||||
const getValue = (preferenceKey: UserPreferenceKey) =>
|
||||
preferenceKey in optimisticPreferencesRef.current
|
||||
? optimisticPreferencesRef.current[preferenceKey]
|
||||
: persistedValues[preferenceKey];
|
||||
|
||||
useEffect(() => {
|
||||
delete optimisticPreferencesRef.current[key];
|
||||
setOptimisticValue(undefined);
|
||||
}, [persistedValue, key]);
|
||||
clearOptimisticPreferences(
|
||||
Object.entries(persistedValues)
|
||||
.filter(
|
||||
([preferenceKey, value]) => optimisticPreferencesRef.current[preferenceKey as UserPreferenceKey] === value,
|
||||
)
|
||||
.map(([preferenceKey]) => preferenceKey as UserPreferenceKey),
|
||||
);
|
||||
}, [persistedValues]);
|
||||
|
||||
const setPersistedValue = (value: never) => {
|
||||
const setPersistedValueAsync = async (key: UserPreferenceKey, value: never) => {
|
||||
const currentSettings = settingsRef.current;
|
||||
|
||||
const setters: Record<UserPreferenceKey, (nextValue: never) => void> = {
|
||||
const setters: Record<UserPreferenceKey, (nextValue: never) => Promise<void> | void> = {
|
||||
colorScheme: (nextValue) => setColorScheme(nextValue as ColorScheme),
|
||||
locale: (nextValue) => changeLocale(nextValue as typeof currentLocale),
|
||||
defaultSearchEngineId: (nextValue) => {
|
||||
defaultSearchEngineId: async (nextValue) => {
|
||||
if (!userId) return;
|
||||
mutateSearchPreferences({
|
||||
userId,
|
||||
defaultSearchEngineId: nextValue as string | null,
|
||||
openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean,
|
||||
ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean,
|
||||
});
|
||||
if (pendingGroupsRef.current.search) return;
|
||||
pendingGroupsRef.current.search = true;
|
||||
try {
|
||||
await mutateSearchPreferencesAsync({
|
||||
userId,
|
||||
defaultSearchEngineId: nextValue as string | null,
|
||||
openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean,
|
||||
ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean,
|
||||
});
|
||||
} finally {
|
||||
pendingGroupsRef.current.search = false;
|
||||
}
|
||||
},
|
||||
openSearchInNewTab: (nextValue) => {
|
||||
openSearchInNewTab: async (nextValue) => {
|
||||
if (!userId) return;
|
||||
mutateSearchPreferences({
|
||||
userId,
|
||||
defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as string | null,
|
||||
openInNewTab: nextValue as boolean,
|
||||
ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean,
|
||||
});
|
||||
if (pendingGroupsRef.current.search) return;
|
||||
pendingGroupsRef.current.search = true;
|
||||
try {
|
||||
await mutateSearchPreferencesAsync({
|
||||
userId,
|
||||
defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as
|
||||
| string
|
||||
| null,
|
||||
openInNewTab: nextValue as boolean,
|
||||
ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean,
|
||||
});
|
||||
} finally {
|
||||
pendingGroupsRef.current.search = false;
|
||||
}
|
||||
},
|
||||
ddgBangs: (nextValue) => {
|
||||
ddgBangs: async (nextValue) => {
|
||||
if (!userId) return;
|
||||
mutateSearchPreferences({
|
||||
userId,
|
||||
defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as string | null,
|
||||
openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean,
|
||||
ddgBangsEnabled: nextValue as boolean,
|
||||
});
|
||||
if (pendingGroupsRef.current.search) return;
|
||||
pendingGroupsRef.current.search = true;
|
||||
try {
|
||||
await mutateSearchPreferencesAsync({
|
||||
userId,
|
||||
defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as
|
||||
| string
|
||||
| null,
|
||||
openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean,
|
||||
ddgBangsEnabled: nextValue as boolean,
|
||||
});
|
||||
} finally {
|
||||
pendingGroupsRef.current.search = false;
|
||||
}
|
||||
},
|
||||
firstDayOfWeek: (nextValue) => {
|
||||
firstDayOfWeek: async (nextValue) => {
|
||||
if (!userId) return;
|
||||
mutateFirstDayOfWeek({ id: userId, firstDayOfWeek: nextValue as DayOfWeek });
|
||||
if (pendingGroupsRef.current.firstDayOfWeek) return;
|
||||
pendingGroupsRef.current.firstDayOfWeek = true;
|
||||
try {
|
||||
await mutateFirstDayOfWeekAsync({ id: userId, firstDayOfWeek: nextValue as DayOfWeek });
|
||||
} finally {
|
||||
pendingGroupsRef.current.firstDayOfWeek = false;
|
||||
}
|
||||
},
|
||||
homeBoardId: (nextValue) => {
|
||||
homeBoardId: async (nextValue) => {
|
||||
if (!userId) return;
|
||||
mutateHomeBoards({
|
||||
userId,
|
||||
homeBoardId: nextValue as string | null,
|
||||
mobileHomeBoardId: getEffectivePreferenceValue("mobileHomeBoardId", currentSettings) as string | null,
|
||||
});
|
||||
if (pendingGroupsRef.current.homeBoards) return;
|
||||
pendingGroupsRef.current.homeBoards = true;
|
||||
try {
|
||||
await mutateHomeBoardsAsync({
|
||||
userId,
|
||||
homeBoardId: nextValue as string | null,
|
||||
mobileHomeBoardId: getEffectivePreferenceValue("mobileHomeBoardId", currentSettings) as string | null,
|
||||
});
|
||||
} finally {
|
||||
pendingGroupsRef.current.homeBoards = false;
|
||||
}
|
||||
},
|
||||
mobileHomeBoardId: (nextValue) => {
|
||||
mobileHomeBoardId: async (nextValue) => {
|
||||
if (!userId) return;
|
||||
mutateHomeBoards({
|
||||
userId,
|
||||
homeBoardId: getEffectivePreferenceValue("homeBoardId", currentSettings) as string | null,
|
||||
mobileHomeBoardId: nextValue as string | null,
|
||||
});
|
||||
if (pendingGroupsRef.current.homeBoards) return;
|
||||
pendingGroupsRef.current.homeBoards = true;
|
||||
try {
|
||||
await mutateHomeBoardsAsync({
|
||||
userId,
|
||||
homeBoardId: getEffectivePreferenceValue("homeBoardId", currentSettings) as string | null,
|
||||
mobileHomeBoardId: nextValue as string | null,
|
||||
});
|
||||
} finally {
|
||||
pendingGroupsRef.current.homeBoards = false;
|
||||
}
|
||||
},
|
||||
pingIconsEnabled: (nextValue) => {
|
||||
pingIconsEnabled: async (nextValue) => {
|
||||
if (!userId) return;
|
||||
mutatePingIconsEnabled({ id: userId, pingIconsEnabled: nextValue as boolean });
|
||||
if (pendingGroupsRef.current.pingIcons) return;
|
||||
pendingGroupsRef.current.pingIcons = true;
|
||||
try {
|
||||
await mutatePingIconsEnabledAsync({ id: userId, pingIconsEnabled: nextValue as boolean });
|
||||
} finally {
|
||||
pendingGroupsRef.current.pingIcons = false;
|
||||
}
|
||||
},
|
||||
fullPreferencesPage: () => undefined,
|
||||
};
|
||||
|
||||
setters[key](value);
|
||||
await setters[key](value);
|
||||
};
|
||||
|
||||
const setValue = (value: never) => {
|
||||
const isPreferencePending = (key: UserPreferenceKey) => {
|
||||
if (searchPreferenceKeys.includes(key as never))
|
||||
return isSearchPreferencesPending || pendingGroupsRef.current.search;
|
||||
if (homeBoardPreferenceKeys.includes(key as never))
|
||||
return isHomeBoardsPending || pendingGroupsRef.current.homeBoards;
|
||||
if (key === "firstDayOfWeek") return isFirstDayOfWeekPending || pendingGroupsRef.current.firstDayOfWeek;
|
||||
if (key === "pingIconsEnabled") return isPingIconsPending || pendingGroupsRef.current.pingIcons;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const setValueAsync = async (key: UserPreferenceKey, value: never) => {
|
||||
if (optimisticPreferenceKeys.has(key) && !userId) return;
|
||||
if (isPreferencePending(key)) return;
|
||||
|
||||
if (optimisticPreferenceKeys.has(key)) {
|
||||
optimisticPreferencesRef.current[key] = value;
|
||||
setOptimisticValue(value);
|
||||
setOptimisticPreference(key, value);
|
||||
}
|
||||
|
||||
setPersistedValue(value);
|
||||
try {
|
||||
await setPersistedValueAsync(key, value);
|
||||
} catch {
|
||||
if (searchPreferenceKeys.includes(key as never)) {
|
||||
clearOptimisticPreferences(searchPreferenceKeys);
|
||||
} else if (homeBoardPreferenceKeys.includes(key as never)) {
|
||||
clearOptimisticPreferences(homeBoardPreferenceKeys);
|
||||
} else {
|
||||
clearOptimisticPreferences([key]);
|
||||
}
|
||||
showPreferenceErrorNotification();
|
||||
}
|
||||
};
|
||||
|
||||
const pendingStates: Record<UserPreferenceKey, boolean> = {
|
||||
colorScheme: false,
|
||||
locale: false,
|
||||
defaultSearchEngineId: isSearchPreferencesPending,
|
||||
openSearchInNewTab: isSearchPreferencesPending,
|
||||
ddgBangs: isSearchPreferencesPending,
|
||||
firstDayOfWeek: isFirstDayOfWeekPending,
|
||||
homeBoardId: isHomeBoardsPending,
|
||||
mobileHomeBoardId: isHomeBoardsPending,
|
||||
pingIconsEnabled: isPingIconsPending,
|
||||
defaultSearchEngineId: isPreferencePending("defaultSearchEngineId"),
|
||||
openSearchInNewTab: isPreferencePending("openSearchInNewTab"),
|
||||
ddgBangs: isPreferencePending("ddgBangs"),
|
||||
firstDayOfWeek: isPreferencePending("firstDayOfWeek"),
|
||||
homeBoardId: isPreferencePending("homeBoardId"),
|
||||
mobileHomeBoardId: isPreferencePending("mobileHomeBoardId"),
|
||||
pingIconsEnabled: isPreferencePending("pingIconsEnabled"),
|
||||
fullPreferencesPage: false,
|
||||
};
|
||||
|
||||
return {
|
||||
value: optimisticPreferencesRef.current[key] ?? optimisticValue ?? persistedValue,
|
||||
setValue,
|
||||
isPending: pendingStates[key],
|
||||
getPreference(preferenceKey: UserPreferenceKey) {
|
||||
return {
|
||||
value: getValue(preferenceKey),
|
||||
setValue: (value: never) => setValueAsync(preferenceKey, value),
|
||||
isPending: pendingStates[preferenceKey],
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useUserPreference = (key: UserPreferenceKey) => {
|
||||
const preferences = useUserPreferences();
|
||||
|
||||
return preferences.getPreference(key);
|
||||
};
|
||||
@@ -4553,7 +4553,13 @@
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select a preference to change",
|
||||
"backHint": "Press Backspace to go back"
|
||||
"backHint": "Press Backspace or use the back button to go back",
|
||||
"backAction": "Go back"
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"error": {
|
||||
"message": "Unable to update preference"
|
||||
}
|
||||
},
|
||||
"option": {
|
||||
|
||||
Generated
+3
@@ -2144,6 +2144,9 @@ importers:
|
||||
'@homarr/modals-collection':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../modals-collection
|
||||
'@homarr/notifications':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../notifications
|
||||
'@homarr/settings':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../settings
|
||||
|
||||
Reference in new issue
Block a user