Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
114d9ce321 | ||
|
|
3a1adab3e5 | ||
|
|
e67d603570 | ||
|
|
0449716805 | ||
|
|
e0a77948e2 | ||
|
|
a2c4b29bfd | ||
|
|
5501ee6a7d | ||
|
|
f6592b27a1 | ||
|
|
970b92cd95 | ||
|
|
e182d28605 | ||
|
|
19d86daa87 | ||
|
|
1bdfc44ba1 | ||
|
|
0ab0be0aa4 | ||
|
|
cf998b474b | ||
|
|
737e2f8c42 | ||
|
|
c33b09c5d4 | ||
|
|
bc0627ca9c | ||
|
|
c4ab2c35ab | ||
|
|
d44e7e9b52 | ||
|
|
1340880fef | ||
|
|
e79efe06bb | ||
|
|
c16974b8db | ||
|
|
b0c5de82f4 | ||
|
|
d99b20d3ef | ||
|
|
d8eaef859a |
No files matched your search
@@ -542,7 +542,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
.set({
|
.set({
|
||||||
pingIconsEnabled: input.pingIconsEnabled,
|
pingIconsEnabled: input.pingIconsEnabled,
|
||||||
})
|
})
|
||||||
.where(eq(users.id, ctx.session.user.id));
|
.where(eq(users.id, input.id));
|
||||||
}),
|
}),
|
||||||
changeDdgBangs: protectedProcedure
|
changeDdgBangs: protectedProcedure
|
||||||
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
|
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
|
||||||
@@ -603,7 +603,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
.set({
|
.set({
|
||||||
firstDayOfWeek: input.firstDayOfWeek,
|
firstDayOfWeek: input.firstDayOfWeek,
|
||||||
})
|
})
|
||||||
.where(eq(users.id, ctx.session.user.id));
|
.where(eq(users.id, input.id));
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const optionMapping: OptionMapping = {
|
|||||||
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
|
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
|
||||||
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
|
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
|
||||||
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
|
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
|
||||||
|
withBorder: () => undefined,
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
|
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./src/context";
|
export * from "./src/context";
|
||||||
|
export * from "./src/preferences/definitions";
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
export const userPreferenceDefinitions = [
|
||||||
|
{
|
||||||
|
key: "colorScheme",
|
||||||
|
kind: "select",
|
||||||
|
guest: true,
|
||||||
|
mutationGroup: "colorScheme",
|
||||||
|
aliases: ["theme", "dark", "light", "auto"],
|
||||||
|
},
|
||||||
|
{ key: "locale", kind: "select", guest: true, mutationGroup: "locale", aliases: ["language", "translation"] },
|
||||||
|
{
|
||||||
|
key: "defaultSearchEngineId",
|
||||||
|
kind: "searchEngine",
|
||||||
|
guest: false,
|
||||||
|
mutationGroup: "searchPreferences",
|
||||||
|
aliases: ["search", "search engine"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "openSearchInNewTab",
|
||||||
|
kind: "boolean",
|
||||||
|
guest: false,
|
||||||
|
mutationGroup: "searchPreferences",
|
||||||
|
aliases: ["search", "new tab", "external"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ddgBangs",
|
||||||
|
kind: "boolean",
|
||||||
|
guest: false,
|
||||||
|
mutationGroup: "searchPreferences",
|
||||||
|
aliases: ["duckduckgo", "bangs", "search"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "firstDayOfWeek",
|
||||||
|
kind: "select",
|
||||||
|
guest: false,
|
||||||
|
mutationGroup: "firstDayOfWeek",
|
||||||
|
aliases: ["calendar", "week", "weekday"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "homeBoardId",
|
||||||
|
kind: "board",
|
||||||
|
guest: false,
|
||||||
|
mutationGroup: "homeBoards",
|
||||||
|
aliases: ["home", "board", "dashboard"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mobileHomeBoardId",
|
||||||
|
kind: "board",
|
||||||
|
guest: false,
|
||||||
|
mutationGroup: "homeBoards",
|
||||||
|
aliases: ["mobile", "home", "board", "dashboard"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pingIconsEnabled",
|
||||||
|
kind: "boolean",
|
||||||
|
guest: false,
|
||||||
|
mutationGroup: "pingIconsEnabled",
|
||||||
|
aliases: ["ping", "icon", "status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fullPreferencesPage",
|
||||||
|
kind: "link",
|
||||||
|
guest: false,
|
||||||
|
mutationGroup: null,
|
||||||
|
aliases: ["settings", "manage", "profile"],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number];
|
||||||
|
export type UserPreferenceKey = UserPreferenceDefinition["key"];
|
||||||
|
export type UserPreferenceKind = UserPreferenceDefinition["kind"];
|
||||||
|
export type UserPreferenceMutationGroup = Exclude<UserPreferenceDefinition["mutationGroup"], null>;
|
||||||
|
|
||||||
|
export const userPreferenceDefinitionByKey = Object.fromEntries(
|
||||||
|
userPreferenceDefinitions.map((definition) => [definition.key, definition]),
|
||||||
|
) as Record<UserPreferenceKey, UserPreferenceDefinition>;
|
||||||
|
|
||||||
|
export const visiblePreferenceDefinitions = (isAuthenticated: boolean) =>
|
||||||
|
userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated);
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"@homarr/integrations": "workspace:^0.1.0",
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
"@homarr/modals": "workspace:^0.1.0",
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||||
|
"@homarr/notifications": "workspace:^0.1.0",
|
||||||
"@homarr/settings": "workspace:^0.1.0",
|
"@homarr/settings": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ export const ChildrenActionItem = ({ childrenOptions, action, query, setChildren
|
|||||||
<Spotlight.Action
|
<Spotlight.Action
|
||||||
renderRoot={renderRoot}
|
renderRoot={renderRoot}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
closeSpotlightOnTrigger={interaction.type !== "children"}
|
closeSpotlightOnTrigger={
|
||||||
|
interaction.type !== "children" &&
|
||||||
|
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
|
||||||
|
}
|
||||||
className={classes.spotlightAction}
|
className={classes.spotlightAction}
|
||||||
>
|
>
|
||||||
<action.Component {...childrenOptions.option} />
|
<action.Component {...childrenOptions.option} />
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
|||||||
interaction.type !== "mode" &&
|
interaction.type !== "mode" &&
|
||||||
interaction.type !== "children" &&
|
interaction.type !== "children" &&
|
||||||
interaction.type !== "none" &&
|
interaction.type !== "none" &&
|
||||||
interaction.type !== "setQuery"
|
interaction.type !== "setQuery" &&
|
||||||
|
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
|
||||||
}
|
}
|
||||||
className={classes.spotlightAction}
|
className={classes.spotlightAction}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||||
import { IconSearch, IconX } from "@tabler/icons-react";
|
import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
|
|
||||||
import { hotkeys } from "@homarr/definitions";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
@@ -15,7 +15,7 @@ import type { OpenMediaRequestSearchOptions } from "../index";
|
|||||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||||
import type { SearchMode } from "../lib/mode";
|
import type { SearchMode } from "../lib/mode";
|
||||||
import { searchModes } from "../modes";
|
import { searchModes } from "../modes";
|
||||||
import { useHomeEmptyGroups } from "../modes/help/home-empty-groups";
|
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
|
||||||
import {
|
import {
|
||||||
mediaRequestSearchEvent,
|
mediaRequestSearchEvent,
|
||||||
mediaRequestSearchScopeAtom,
|
mediaRequestSearchScopeAtom,
|
||||||
@@ -27,6 +27,7 @@ import { SpotlightChildrenActions } from "./actions/children-actions";
|
|||||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||||
|
|
||||||
type SearchModeKey = keyof TranslationObject["search"]["mode"];
|
type SearchModeKey = keyof TranslationObject["search"]["mode"];
|
||||||
|
type ChildrenOptions = inferSearchInteractionOptions<"children">;
|
||||||
|
|
||||||
const defaultMode = "home";
|
const defaultMode = "home";
|
||||||
export const Spotlight = () => {
|
export const Spotlight = () => {
|
||||||
@@ -39,7 +40,6 @@ export const Spotlight = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We use the "key" below to prevent the 'Different amounts of hooks' error
|
|
||||||
return (
|
return (
|
||||||
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
|
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
|
||||||
);
|
);
|
||||||
@@ -54,22 +54,41 @@ interface SpotlightWithActiveModeProps {
|
|||||||
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
|
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
|
||||||
const [query, setQuery] = queryState;
|
const [query, setQuery] = queryState;
|
||||||
const [mode, setMode] = modeState;
|
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 t = useI18n();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
|
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
|
||||||
// Works as always the same amount of hooks are executed
|
|
||||||
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
||||||
const groups = useGroups();
|
const groups = useGroups();
|
||||||
const homeEmptyGroups = useHomeEmptyGroups();
|
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
|
||||||
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
|
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMediaRequestSearch = (event: Event) => {
|
const handleMediaRequestSearch = (event: Event) => {
|
||||||
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
|
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
|
||||||
setMediaRequestSearchScope({ integrationIds });
|
setMediaRequestSearchScope({ integrationIds });
|
||||||
setMode("media");
|
setMode("media");
|
||||||
setChildrenOptions(null);
|
clearChildrenStack();
|
||||||
setQuery(query ?? "");
|
setQuery(query ?? "");
|
||||||
spotlightActions.open();
|
spotlightActions.open();
|
||||||
setTimeout(() => selectAction(0, spotlightStore));
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
@@ -79,7 +98,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||||
};
|
};
|
||||||
}, [setMediaRequestSearchScope, setMode, setQuery]);
|
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "media") {
|
if (mode !== "media") {
|
||||||
@@ -94,7 +113,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
|||||||
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
|
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
|
||||||
onSpotlightClose={() => {
|
onSpotlightClose={() => {
|
||||||
setMode(defaultMode);
|
setMode(defaultMode);
|
||||||
setChildrenOptions(null);
|
clearChildrenStack();
|
||||||
setQuery("");
|
setQuery("");
|
||||||
}}
|
}}
|
||||||
query={query}
|
query={query}
|
||||||
@@ -102,17 +121,14 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
|||||||
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
|
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
|
||||||
setQuery(sanitizedQuery);
|
setQuery(sanitizedQuery);
|
||||||
|
|
||||||
// Only switch modes when a single trigger character is typed
|
|
||||||
if (sanitizedQuery.length !== 1) return;
|
if (sanitizedQuery.length !== 1) return;
|
||||||
|
|
||||||
const modeToActivate = searchModes.find((mode) => mode.character === sanitizedQuery);
|
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
|
||||||
if (!modeToActivate) return;
|
if (!modeToActivate) return;
|
||||||
|
|
||||||
setMode(modeToActivate.modeKey);
|
setMode(modeToActivate.modeKey);
|
||||||
setChildrenOptions(null);
|
clearChildrenStack();
|
||||||
|
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
|
||||||
setTimeout(() => selectAction(0, spotlightStore));
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
}}
|
}}
|
||||||
store={spotlightStore}
|
store={spotlightStore}
|
||||||
@@ -135,31 +151,53 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
rightSection={
|
rightSection={
|
||||||
mode === defaultMode ? null : (
|
mode === defaultMode && childrenStack.length === 0 ? null : (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
title={
|
||||||
|
childrenStack.length > 0
|
||||||
|
? t("search.mode.command.group.preferences.children.detail.backAction")
|
||||||
|
: t("common.action.close")
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (childrenStack.length > 0) {
|
||||||
|
popChildrenOptions();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setMode(defaultMode);
|
setMode(defaultMode);
|
||||||
setChildrenOptions(null);
|
clearChildrenStack();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
variant="subtle"
|
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>
|
</ActionIcon>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
value={query}
|
value={query}
|
||||||
onKeyDown={(event) => {
|
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);
|
setMode(defaultMode);
|
||||||
setChildrenOptions(null);
|
clearChildrenStack();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{childrenOptions ? (
|
{childrenOptions ? (
|
||||||
<Group>
|
<Group key={childrenStack.length}>
|
||||||
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
|
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
|
||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -167,25 +205,21 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
|||||||
<MantineSpotlight.ActionsList>
|
<MantineSpotlight.ActionsList>
|
||||||
{childrenOptions ? (
|
{childrenOptions ? (
|
||||||
<SpotlightChildrenActions
|
<SpotlightChildrenActions
|
||||||
|
key={childrenStack.length}
|
||||||
childrenOptions={childrenOptions}
|
childrenOptions={childrenOptions}
|
||||||
query={query}
|
query={query}
|
||||||
setChildrenOptions={setChildrenOptions}
|
setChildrenOptions={pushChildrenOptions}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SpotlightActionGroups
|
<SpotlightActionGroups
|
||||||
setQuery={setQuery}
|
setQuery={setQuery}
|
||||||
setMode={(mode) => {
|
setMode={(nextMode) => {
|
||||||
setMode(mode);
|
setMode(nextMode);
|
||||||
setChildrenOptions(null);
|
clearChildrenStack();
|
||||||
setTimeout(() => selectAction(0, spotlightStore));
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
}}
|
}}
|
||||||
setChildrenOptions={(options) => {
|
setChildrenOptions={(options) => {
|
||||||
setChildrenOptions(options);
|
pushChildrenOptions(options);
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setQuery("");
|
|
||||||
selectAction(0, spotlightStore);
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
query={query}
|
query={query}
|
||||||
groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups}
|
groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ const createSearchInteraction = <TType extends string>(type: TType) => ({
|
|||||||
// This is used to define search interactions with their options
|
// This is used to define search interactions with their options
|
||||||
const searchInteractions = [
|
const searchInteractions = [
|
||||||
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
|
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("setQuery").optionsType<{ query: string }>(),
|
||||||
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
|
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
|
||||||
createSearchInteraction("children").optionsType<{
|
createSearchInteraction("children").optionsType<{
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Group, Stack, Text } from "@mantine/core";
|
import { Group, Text } from "@mantine/core";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
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 { LanguageIcon } from "@homarr/ui";
|
||||||
|
|
||||||
import { createChildrenOptions } from "../../../lib/children";
|
import { createChildrenOptions } from "../../../lib/children";
|
||||||
|
import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./preferences/action-row";
|
||||||
|
|
||||||
export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({
|
export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({
|
||||||
useActions: (_, query) => {
|
useActions: (_, query) => {
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const currentLocale = useCurrentLocale();
|
const { value: currentLocale, setValue: setLocale, isPending } = useUserPreference("locale");
|
||||||
|
|
||||||
return supportedLanguages
|
return supportedLanguages
|
||||||
.map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
|
.map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
|
||||||
.filter(
|
.filter(
|
||||||
@@ -18,7 +19,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
|||||||
configuration.name.toLowerCase().includes(normalizedQuery) ||
|
configuration.name.toLowerCase().includes(normalizedQuery) ||
|
||||||
configuration.translatedName.toLowerCase().includes(normalizedQuery),
|
configuration.translatedName.toLowerCase().includes(normalizedQuery),
|
||||||
)
|
)
|
||||||
.sort(
|
.toSorted(
|
||||||
(languageA, languageB) =>
|
(languageA, languageB) =>
|
||||||
Math.min(
|
Math.min(
|
||||||
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
|
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
|
||||||
@@ -29,38 +30,25 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
|||||||
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
|
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map(({ localeKey, configuration }) => ({
|
.map(({ localeKey, configuration }) =>
|
||||||
key: localeKey,
|
createCheckmarkPreferenceAction({
|
||||||
Component() {
|
key: localeKey,
|
||||||
return (
|
isSelected: localeKey === currentLocale,
|
||||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
onSelect: () => setLocale(localeKey as never),
|
||||||
<Group wrap="nowrap">
|
isPending,
|
||||||
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
|
labelContent: (
|
||||||
<Group wrap="nowrap" gap="xs">
|
<Group wrap="nowrap">
|
||||||
<Text>{configuration.name}</Text>
|
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
|
||||||
<Text size="xs" c="dimmed" inherit>
|
<Group wrap="nowrap" gap="xs">
|
||||||
({configuration.translatedName})
|
<Text>{configuration.name}</Text>
|
||||||
</Text>
|
<Text size="xs" c="dimmed" inherit>
|
||||||
</Group>
|
({configuration.translatedName})
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{localeKey === currentLocale && <IconCheck color="currentColor" size={24} />}
|
|
||||||
</Group>
|
</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 @@
|
|||||||
|
export { settingsChildrenOptions } from "./settings-children";
|
||||||
@@ -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,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Group, Stack, Switch, Text } from "@mantine/core";
|
||||||
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import localeData from "dayjs/plugin/localeData";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
|
import { visiblePreferenceDefinitions } from "@homarr/settings";
|
||||||
|
import { localeConfigurations } from "@homarr/translation";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { createChildrenOptions } from "../../../../lib/children";
|
||||||
|
import type { ChildrenAction } from "../../../../lib/children";
|
||||||
|
import { interaction } from "../../../../lib/interaction";
|
||||||
|
import { useUserPreferences } from "../../../../preferences/use-user-preference";
|
||||||
|
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../../../preferences/preference-registry";
|
||||||
|
|
||||||
|
dayjs.extend(localeData);
|
||||||
|
|
||||||
|
type SettingsAction = ChildrenAction<Record<string, unknown>>;
|
||||||
|
|
||||||
|
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 SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
|
||||||
|
|
||||||
|
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 preferences = useUserPreferences();
|
||||||
|
|
||||||
|
const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated });
|
||||||
|
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated });
|
||||||
|
|
||||||
|
const valueLabelResolvers: Record<string, () => string> = {
|
||||||
|
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
|
||||||
|
locale: () => {
|
||||||
|
const val = preferences.getPreference("locale").value;
|
||||||
|
return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val);
|
||||||
|
},
|
||||||
|
defaultSearchEngineId: () =>
|
||||||
|
searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value)
|
||||||
|
?.label ?? t("searchEngine.none" as never),
|
||||||
|
firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "",
|
||||||
|
homeBoardId: () =>
|
||||||
|
boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ??
|
||||||
|
t("board.none" as never),
|
||||||
|
mobileHomeBoardId: () =>
|
||||||
|
boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ??
|
||||||
|
t("board.none" as never),
|
||||||
|
};
|
||||||
|
|
||||||
|
return visiblePreferenceDefinitions(isAuthenticated)
|
||||||
|
.map((definition): SettingsAction | null => {
|
||||||
|
const label = t(`${definition.key}.label` as never);
|
||||||
|
const Icon = preferenceIcons[definition.key];
|
||||||
|
|
||||||
|
const matchesSearch = [label, ...definition.aliases].some((v) => v.toLowerCase().includes(normalizedQuery));
|
||||||
|
if (!matchesSearch) return null;
|
||||||
|
|
||||||
|
if (definition.kind === "boolean") {
|
||||||
|
const pref = preferences.getPreference(definition.key);
|
||||||
|
return {
|
||||||
|
key: definition.key,
|
||||||
|
Component: () => (
|
||||||
|
<BooleanRow label={label} Icon={Icon} checked={pref.value as boolean} isPending={pref.isPending} />
|
||||||
|
),
|
||||||
|
useInteraction: interaction.javaScript(() => ({
|
||||||
|
onSelect: pref.isPending ? () => undefined : () => pref.setValue(!(pref.value as boolean) as never),
|
||||||
|
closeSpotlightOnTrigger: false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.kind === "link") {
|
||||||
|
return {
|
||||||
|
key: definition.key,
|
||||||
|
Component: () => <LinkRow label={label} Icon={Icon} />,
|
||||||
|
useInteraction: interaction.link(() => ({ href: `/manage/users/${session?.user.id ?? ""}/general` })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SELECT_KINDS.has(definition.kind)) {
|
||||||
|
const children = preferenceChildrenOptionsByKey[definition.key];
|
||||||
|
if (!children) return null;
|
||||||
|
return {
|
||||||
|
key: definition.key,
|
||||||
|
Component: () => (
|
||||||
|
<SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.() ?? ""} />
|
||||||
|
),
|
||||||
|
useInteraction: interaction.children(children),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((action): action is SettingsAction => action !== null);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
import {
|
||||||
IconBox,
|
IconBox,
|
||||||
IconCategoryPlus,
|
IconCategoryPlus,
|
||||||
IconDeviceDesktop,
|
|
||||||
IconFileImport,
|
IconFileImport,
|
||||||
IconLanguage,
|
|
||||||
IconMailForward,
|
IconMailForward,
|
||||||
IconMoon,
|
|
||||||
IconPlug,
|
IconPlug,
|
||||||
IconSun,
|
|
||||||
IconUserPlus,
|
IconUserPlus,
|
||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -22,7 +18,6 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
|
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
|
||||||
import { interaction } from "../../lib/interaction";
|
import { interaction } from "../../lib/interaction";
|
||||||
import { languageChildrenOptions } from "./children/language";
|
|
||||||
import { newIntegrationChildrenOptions } from "./children/new-integration";
|
import { newIntegrationChildrenOptions } from "./children/new-integration";
|
||||||
|
|
||||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||||
@@ -52,33 +47,9 @@ export const globalCommandGroup = createGroup<Command>({
|
|||||||
},
|
},
|
||||||
useOptions() {
|
useOptions() {
|
||||||
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
|
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
|
||||||
const { colorScheme } = useMantineColorScheme();
|
|
||||||
const computedColorScheme = useComputedColorScheme("light");
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const commands: (Command & { hidden?: boolean })[] = [
|
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",
|
commandKey: "newBoard",
|
||||||
icon: IconCategoryPlus,
|
icon: IconCategoryPlus,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { SearchMode } from "../../lib/mode";
|
import type { SearchMode } from "../../lib/mode";
|
||||||
import { contextSpecificActionsSearchGroups } from "./context-specific-group";
|
import { contextSpecificActionsSearchGroups } from "./context-specific-group";
|
||||||
import { globalCommandGroup } from "./global-group";
|
import { globalCommandGroup } from "./global-group";
|
||||||
|
import { preferencesGroup } from "../preferences/groups";
|
||||||
|
|
||||||
export const commandMode = {
|
export const commandMode = {
|
||||||
modeKey: "command",
|
modeKey: "command",
|
||||||
character: ">",
|
character: ">",
|
||||||
groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
|
groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup],
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
@@ -21,11 +21,11 @@ import { commandMode } from "../command";
|
|||||||
import { externalMode } from "../external";
|
import { externalMode } from "../external";
|
||||||
import { mediaMode } from "../media";
|
import { mediaMode } from "../media";
|
||||||
import { pageMode } from "../page";
|
import { pageMode } from "../page";
|
||||||
|
import { preferencesGroup } from "../preferences/groups";
|
||||||
import { userGroupMode } from "../user-group";
|
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 = {
|
type QuickLinkOption = {
|
||||||
|
key: string;
|
||||||
icon: TablerIcon;
|
icon: TablerIcon;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -42,19 +42,13 @@ export const useHomeEmptyGroups = () => {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
createGroup({
|
createGroup({
|
||||||
keyPath: "path",
|
keyPath: "key",
|
||||||
title: () => "Quick links",
|
title: () => "Quick links",
|
||||||
options: (() => {
|
options: (() => {
|
||||||
const quickLinks: QuickLinkOption[] = [];
|
const quickLinks: QuickLinkOption[] = [];
|
||||||
if (session?.user) {
|
|
||||||
quickLinks.push({
|
|
||||||
icon: IconSettings,
|
|
||||||
path: `/manage/users/${session.user.id}/general`,
|
|
||||||
name: tPages("preferences.label"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
quickLinks.push({
|
quickLinks.push({
|
||||||
|
key: "manageBoards",
|
||||||
icon: IconLayoutDashboard,
|
icon: IconLayoutDashboard,
|
||||||
path: "/manage/boards",
|
path: "/manage/boards",
|
||||||
name: tPages("manageBoard.label"),
|
name: tPages("manageBoard.label"),
|
||||||
@@ -62,6 +56,7 @@ export const useHomeEmptyGroups = () => {
|
|||||||
|
|
||||||
if (session?.user.permissions.includes("admin")) {
|
if (session?.user.permissions.includes("admin")) {
|
||||||
quickLinks.push({
|
quickLinks.push({
|
||||||
|
key: "manageSettings",
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
path: "/manage/settings",
|
path: "/manage/settings",
|
||||||
name: tPages("manageSettings.label"),
|
name: tPages("manageSettings.label"),
|
||||||
@@ -70,6 +65,7 @@ export const useHomeEmptyGroups = () => {
|
|||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
quickLinks.push({
|
quickLinks.push({
|
||||||
|
key: "manageSearchEngines",
|
||||||
icon: IconSearch,
|
icon: IconSearch,
|
||||||
path: "/manage/search-engines",
|
path: "/manage/search-engines",
|
||||||
name: tPages("manageSearchEngine.label"),
|
name: tPages("manageSearchEngine.label"),
|
||||||
@@ -139,3 +135,5 @@ export const useHomeEmptyGroups = () => {
|
|||||||
}),
|
}),
|
||||||
] as const;
|
] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()];
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { SearchMode } from "../../lib/mode";
|
import type { SearchMode } from "../../lib/mode";
|
||||||
|
import { preferencesGroup } from "../preferences/groups";
|
||||||
import { contextSpecificSearchGroups } from "./context-specific-group";
|
import { contextSpecificSearchGroups } from "./context-specific-group";
|
||||||
import { homeSearchEngineGroup } from "./home-search-engine-group";
|
import { homeSearchEngineGroup } from "./home-search-engine-group";
|
||||||
|
|
||||||
export const homeMode = {
|
export const homeMode = {
|
||||||
character: undefined,
|
character: undefined,
|
||||||
modeKey: "home",
|
modeKey: "home",
|
||||||
groups: [homeSearchEngineGroup, contextSpecificSearchGroups],
|
groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups],
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
@@ -55,8 +55,7 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
|||||||
|
|
||||||
const { result } = option;
|
const { result } = option;
|
||||||
const hasAvailabilityLabel =
|
const hasAvailabilityLabel =
|
||||||
result.availability &&
|
result.availability && (availabilityLabelKeys as readonly string[]).includes(result.availability);
|
||||||
(availabilityLabelKeys as readonly string[]).includes(result.availability);
|
|
||||||
const badgeLabel = hasAvailabilityLabel
|
const badgeLabel = hasAvailabilityLabel
|
||||||
? tMedia(`availability.${result.availability as AvailabilityWithLabel}`)
|
? tMedia(`availability.${result.availability as AvailabilityWithLabel}`)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Group, Text } from "@mantine/core";
|
||||||
|
import { IconSettings } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import { colorSchemes } from "@homarr/definitions";
|
||||||
|
import { visiblePreferenceDefinitions } from "@homarr/settings";
|
||||||
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { createGroup } from "../../lib/group";
|
||||||
|
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../preferences/preference-registry";
|
||||||
|
import { settingsChildrenOptions } from "../command/children/preferences/settings-children";
|
||||||
|
|
||||||
|
type PreferencesGroupOption = { key: "settings"; preferenceKey: null } | { key: string; preferenceKey: string };
|
||||||
|
|
||||||
|
const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null };
|
||||||
|
|
||||||
|
export const preferencesGroup = createGroup<PreferencesGroupOption>({
|
||||||
|
keyPath: "key",
|
||||||
|
title: (t) => t("search.mode.command.group.preferences.title"),
|
||||||
|
useOptions(query) {
|
||||||
|
const t = useI18n();
|
||||||
|
const tColorScheme = useScopedI18n("common.colorScheme.options");
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const definitions = visiblePreferenceDefinitions(Boolean(session?.user));
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (q.length === 0) return [settingsOption];
|
||||||
|
|
||||||
|
const matching = definitions.flatMap((def): PreferencesGroupOption[] => {
|
||||||
|
if (!preferenceChildrenOptionsByKey[def.key]) return [];
|
||||||
|
const label = t(`search.mode.command.group.preferences.option.${def.key}.label` as never);
|
||||||
|
const searchValues = [
|
||||||
|
label,
|
||||||
|
...def.aliases,
|
||||||
|
...(def.key === "colorScheme" ? colorSchemes.map((s) => tColorScheme(s)) : []),
|
||||||
|
];
|
||||||
|
if (!searchValues.some((v) => v.toLowerCase().includes(q))) return [];
|
||||||
|
return [{ key: def.key, preferenceKey: def.key }];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matching.length > 0) return matching;
|
||||||
|
|
||||||
|
const allSearchValues = [
|
||||||
|
t("search.mode.command.group.preferences.title"),
|
||||||
|
...definitions.flatMap((d) => d.aliases),
|
||||||
|
];
|
||||||
|
return allSearchValues.some((v) => v.toLowerCase().includes(q)) ? [settingsOption] : [];
|
||||||
|
},
|
||||||
|
Component: (option) => {
|
||||||
|
const t = useScopedI18n("search.mode.command.group.preferences");
|
||||||
|
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
|
||||||
|
const Icon = option.preferenceKey
|
||||||
|
? preferenceIcons[option.preferenceKey as keyof typeof preferenceIcons]
|
||||||
|
: IconSettings;
|
||||||
|
const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group px="md" py="sm" wrap="nowrap" gap="sm">
|
||||||
|
<Icon stroke={1.5} />
|
||||||
|
<Text>{label}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
filter: () => true,
|
||||||
|
useInteraction: (option) => ({
|
||||||
|
type: "children",
|
||||||
|
...(option.preferenceKey
|
||||||
|
? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ??
|
||||||
|
settingsChildrenOptions({}))
|
||||||
|
: settingsChildrenOptions({})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconActivity,
|
||||||
|
IconCalendarWeek,
|
||||||
|
IconDeviceDesktop,
|
||||||
|
IconExternalLink,
|
||||||
|
IconHome,
|
||||||
|
IconLanguage,
|
||||||
|
IconLayoutBoard,
|
||||||
|
IconSearch,
|
||||||
|
IconSettings,
|
||||||
|
IconWorldWww,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import localeData from "dayjs/plugin/localeData";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { colorSchemes } from "@homarr/definitions";
|
||||||
|
import type { UserPreferenceKey } from "@homarr/settings";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { createChildrenOptions } from "../lib/children";
|
||||||
|
import {
|
||||||
|
createCheckmarkPreferenceAction,
|
||||||
|
createLoadingPreferenceAction,
|
||||||
|
PreferenceDetailHeader,
|
||||||
|
} from "../modes/command/children/preferences/action-row";
|
||||||
|
import { createSelectablePreferenceActions } from "../modes/command/children/preferences/selectable-actions";
|
||||||
|
import { languageChildrenOptions } from "../modes/command/children/language";
|
||||||
|
import { useUserPreference } from "./use-user-preference";
|
||||||
|
|
||||||
|
dayjs.extend(localeData);
|
||||||
|
|
||||||
|
type ChildrenFactory = ReturnType<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,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createStaticChildren = (
|
||||||
|
preferenceKey: UserPreferenceKey,
|
||||||
|
useOptions: () => Array<{ key: string; label: string; value: unknown }>,
|
||||||
|
): ChildrenFactory =>
|
||||||
|
createChildrenOptions<Record<string, unknown>>({
|
||||||
|
useActions: () => {
|
||||||
|
const { value, setValue, isPending } = useUserPreference(preferenceKey);
|
||||||
|
return useOptions().map((opt) =>
|
||||||
|
createCheckmarkPreferenceAction({
|
||||||
|
key: opt.key,
|
||||||
|
label: opt.label,
|
||||||
|
isSelected: value === opt.value,
|
||||||
|
onSelect: () => setValue(opt.value as never),
|
||||||
|
isPending,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAsyncSelectableChildren = <TItem,>(
|
||||||
|
preferenceKey: UserPreferenceKey,
|
||||||
|
useQuery: () => { data: TItem[] | undefined; isLoading: boolean },
|
||||||
|
mapItem: (item: TItem) => { key: string; label: string; value: string },
|
||||||
|
noneKey: string,
|
||||||
|
unavailableKey: string,
|
||||||
|
): ChildrenFactory =>
|
||||||
|
createChildrenOptions<Record<string, unknown>>({
|
||||||
|
useActions: (_, query) => {
|
||||||
|
const { value, setValue, isPending } = useUserPreference(preferenceKey);
|
||||||
|
const result = useQuery();
|
||||||
|
const t = useScopedI18n("search.mode.command.group.preferences.option");
|
||||||
|
|
||||||
|
if (result.isLoading) return [createLoadingPreferenceAction()];
|
||||||
|
|
||||||
|
return createSelectablePreferenceActions({
|
||||||
|
query,
|
||||||
|
currentValue: value as string | null,
|
||||||
|
noneLabel: t(noneKey as never),
|
||||||
|
unavailableLabel: t(unavailableKey as never),
|
||||||
|
items: (result.data ?? []).map(mapItem),
|
||||||
|
onSelect: (v) => setValue(v as never),
|
||||||
|
isPending,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0];
|
||||||
|
|
||||||
|
export const preferenceChildrenOptionsByKey: Partial<Record<UserPreferenceKey, ChildrenFactory>> = {
|
||||||
|
colorScheme: createStaticChildren("colorScheme", () => {
|
||||||
|
const t = useScopedI18n("common.colorScheme.options");
|
||||||
|
return colorSchemes.map((s) => ({ key: s, label: t(s), value: s }));
|
||||||
|
}),
|
||||||
|
locale: languageChildrenOptions,
|
||||||
|
defaultSearchEngineId: createAsyncSelectableChildren(
|
||||||
|
"defaultSearchEngineId",
|
||||||
|
() => clientApi.searchEngine.getSelectable.useQuery(),
|
||||||
|
(e) => ({ key: e.value, label: e.label, value: e.value }),
|
||||||
|
"searchEngine.none",
|
||||||
|
"searchEngine.unavailable",
|
||||||
|
),
|
||||||
|
firstDayOfWeek: createStaticChildren("firstDayOfWeek", () =>
|
||||||
|
firstDayOfWeekOptions.map((day) => ({
|
||||||
|
key: day.toString(),
|
||||||
|
label: dayjs.weekdays(false)[day] ?? day.toString(),
|
||||||
|
value: day,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
homeBoardId: createAsyncSelectableChildren(
|
||||||
|
"homeBoardId",
|
||||||
|
() => clientApi.board.getAllBoards.useQuery(),
|
||||||
|
(b) => ({ key: b.id, label: b.name, value: b.id }),
|
||||||
|
"board.none",
|
||||||
|
"board.unavailable",
|
||||||
|
),
|
||||||
|
mobileHomeBoardId: createAsyncSelectableChildren(
|
||||||
|
"mobileHomeBoardId",
|
||||||
|
() => clientApi.board.getAllBoards.useQuery(),
|
||||||
|
(b) => ({ key: b.id, label: b.name, value: b.id }),
|
||||||
|
"board.none",
|
||||||
|
"board.unavailable",
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMantineColorScheme } from "@mantine/core";
|
||||||
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
|
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||||
|
import { userPreferenceDefinitions, userPreferenceDefinitionByKey } from "@homarr/settings";
|
||||||
|
import type { UserPreferenceKey } from "@homarr/settings";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
|
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
// --- Optimistic store (module-level, shared across all hook instances) ---
|
||||||
|
|
||||||
|
const optimisticRef: { current: Partial<Record<UserPreferenceKey, unknown>> } = { current: {} };
|
||||||
|
let optimisticVersion = 0;
|
||||||
|
const optimisticListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
const notifyListeners = () => {
|
||||||
|
optimisticVersion += 1;
|
||||||
|
optimisticListeners.forEach((fn) => fn());
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribeOptimistic = (listener: () => void) => {
|
||||||
|
optimisticListeners.add(listener);
|
||||||
|
return () => optimisticListeners.delete(listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setOptimistic = (key: UserPreferenceKey, value: unknown) => {
|
||||||
|
optimisticRef.current[key] = value;
|
||||||
|
notifyListeners();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearOptimistic = (keys: readonly UserPreferenceKey[]) => {
|
||||||
|
let changed = false;
|
||||||
|
for (const key of keys) {
|
||||||
|
changed ||= key in optimisticRef.current;
|
||||||
|
delete optimisticRef.current[key];
|
||||||
|
}
|
||||||
|
if (changed) notifyListeners();
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLIENT_ONLY_GROUPS = new Set(["colorScheme", "locale"]);
|
||||||
|
|
||||||
|
const isDbBacked = (key: UserPreferenceKey) => {
|
||||||
|
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||||
|
return group !== null && !CLIENT_ONLY_GROUPS.has(group);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroupKeys = (key: UserPreferenceKey): UserPreferenceKey[] => {
|
||||||
|
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||||
|
if (!group) return [key];
|
||||||
|
return userPreferenceDefinitions.filter((d) => d.mutationGroup === group).map((d) => d.key);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Hook ---
|
||||||
|
|
||||||
|
export const useUserPreferences = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const settings = useSettings();
|
||||||
|
const settingsRef = useRef(settings);
|
||||||
|
settingsRef.current = settings;
|
||||||
|
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
|
const currentLocale = useCurrentLocale();
|
||||||
|
const { changeLocale } = useChangeLocale();
|
||||||
|
const t = useI18n();
|
||||||
|
const userId = session?.user.id;
|
||||||
|
const pendingRef = useRef<Record<string, boolean>>({});
|
||||||
|
const prevUserIdRef = useRef(userId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevUserIdRef.current === userId) return;
|
||||||
|
prevUserIdRef.current = userId;
|
||||||
|
clearOptimistic(userPreferenceDefinitions.map((d) => d.key));
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useSyncExternalStore(
|
||||||
|
subscribeOptimistic,
|
||||||
|
() => optimisticVersion,
|
||||||
|
() => optimisticVersion,
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = () => router.refresh();
|
||||||
|
|
||||||
|
const searchMutation = clientApi.user.changeSearchPreferences.useMutation({ onSuccess: refresh });
|
||||||
|
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
|
||||||
|
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
|
||||||
|
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
|
||||||
|
|
||||||
|
const getEffective = (key: UserPreferenceKey): unknown =>
|
||||||
|
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
|
||||||
|
|
||||||
|
const pick = (field: UserPreferenceKey, changedKey: UserPreferenceKey, value: unknown) =>
|
||||||
|
field === changedKey ? value : getEffective(field);
|
||||||
|
|
||||||
|
const groupDispatchers: Record<string, (uid: string, key: UserPreferenceKey, value: unknown) => Promise<unknown>> = {
|
||||||
|
searchPreferences: (uid, key, value) =>
|
||||||
|
searchMutation.mutateAsync({
|
||||||
|
userId: uid,
|
||||||
|
defaultSearchEngineId: pick("defaultSearchEngineId", key, value) as string | null,
|
||||||
|
openInNewTab: pick("openSearchInNewTab", key, value) as boolean,
|
||||||
|
ddgBangsEnabled: pick("ddgBangs", key, value) as boolean,
|
||||||
|
}),
|
||||||
|
homeBoards: (uid, key, value) =>
|
||||||
|
homeBoardsMutation.mutateAsync({
|
||||||
|
userId: uid,
|
||||||
|
homeBoardId: pick("homeBoardId", key, value) as string | null,
|
||||||
|
mobileHomeBoardId: pick("mobileHomeBoardId", key, value) as string | null,
|
||||||
|
}),
|
||||||
|
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
|
||||||
|
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupPending: Record<string, boolean> = {
|
||||||
|
searchPreferences: searchMutation.isPending,
|
||||||
|
homeBoards: homeBoardsMutation.isPending,
|
||||||
|
firstDayOfWeek: firstDayMutation.isPending,
|
||||||
|
pingIconsEnabled: pingMutation.isPending,
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
|
||||||
|
const values: Record<string, unknown> = {
|
||||||
|
colorScheme: colorScheme as ColorScheme,
|
||||||
|
locale: currentLocale,
|
||||||
|
fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null,
|
||||||
|
};
|
||||||
|
for (const def of userPreferenceDefinitions) {
|
||||||
|
if (def.key in values) continue;
|
||||||
|
values[def.key] = settings[def.key as keyof SettingsContextProps];
|
||||||
|
}
|
||||||
|
return values as Record<UserPreferenceKey, unknown>;
|
||||||
|
}, [colorScheme, currentLocale, userId, settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const matchedKeys = Object.entries(persistedValues)
|
||||||
|
.filter(([key, value]) => optimisticRef.current[key as UserPreferenceKey] === value)
|
||||||
|
.map(([key]) => key as UserPreferenceKey);
|
||||||
|
clearOptimistic(matchedKeys);
|
||||||
|
}, [persistedValues]);
|
||||||
|
|
||||||
|
const getValue = (key: UserPreferenceKey) =>
|
||||||
|
key in optimisticRef.current ? optimisticRef.current[key] : persistedValues[key];
|
||||||
|
|
||||||
|
const isPreferencePending = (key: UserPreferenceKey): boolean => {
|
||||||
|
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||||
|
if (!group || CLIENT_ONLY_GROUPS.has(group)) return false;
|
||||||
|
return (groupPending[group] ?? false) || (pendingRef.current[group] ?? false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientSetters: Partial<Record<UserPreferenceKey, (value: unknown) => void>> = {
|
||||||
|
colorScheme: (value) => setColorScheme(value as ColorScheme),
|
||||||
|
locale: (value) => changeLocale(value as typeof currentLocale),
|
||||||
|
fullPreferencesPage: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const setValueAsync = async (key: UserPreferenceKey, value: unknown) => {
|
||||||
|
if (isDbBacked(key) && !userId) return;
|
||||||
|
if (isPreferencePending(key)) return;
|
||||||
|
|
||||||
|
const clientSetter = clientSetters[key];
|
||||||
|
if (clientSetter) {
|
||||||
|
clientSetter(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||||
|
if (!group) return;
|
||||||
|
const dispatcher = groupDispatchers[group];
|
||||||
|
if (!dispatcher || !userId) return;
|
||||||
|
|
||||||
|
setOptimistic(key, value);
|
||||||
|
|
||||||
|
pendingRef.current[group] = true;
|
||||||
|
try {
|
||||||
|
await dispatcher(userId, key, value);
|
||||||
|
} catch {
|
||||||
|
clearOptimistic(getGroupKeys(key));
|
||||||
|
showErrorNotification({
|
||||||
|
message: t("search.mode.command.group.preferences.notification.error.message"),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
pendingRef.current[group] = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getPreference(preferenceKey: UserPreferenceKey) {
|
||||||
|
return {
|
||||||
|
value: getValue(preferenceKey),
|
||||||
|
setValue: (value: never) => setValueAsync(preferenceKey, value),
|
||||||
|
isPending: isPreferencePending(preferenceKey),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUserPreference = (key: UserPreferenceKey) => useUserPreferences().getPreference(key);
|
||||||
@@ -4558,6 +4558,113 @@
|
|||||||
"label": "Create a new group"
|
"label": "Create a new group"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"preferences": {
|
||||||
|
"title": "Preferences",
|
||||||
|
"children": {
|
||||||
|
"detail": {
|
||||||
|
"title": "Select a preference to change",
|
||||||
|
"backHint": "Press Backspace or use the back button to go back",
|
||||||
|
"backAction": "Go back"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"error": {
|
||||||
|
"message": "Unable to update preference"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"option": {
|
||||||
|
"preferences": {
|
||||||
|
"label": "Open preferences"
|
||||||
|
},
|
||||||
|
"boolean": {
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled"
|
||||||
|
},
|
||||||
|
"board": {
|
||||||
|
"none": "None",
|
||||||
|
"unavailable": "Currently selected (unavailable)"
|
||||||
|
},
|
||||||
|
"searchEngine": {
|
||||||
|
"none": "None",
|
||||||
|
"unavailable": "Currently selected (unavailable)"
|
||||||
|
},
|
||||||
|
"colorScheme": {
|
||||||
|
"label": "Color scheme",
|
||||||
|
"children": {
|
||||||
|
"detail": {
|
||||||
|
"title": "Select your color scheme"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"children": {
|
||||||
|
"detail": {
|
||||||
|
"title": "Select your preferred language"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultSearchEngineId": {
|
||||||
|
"label": "Default search engine",
|
||||||
|
"children": {
|
||||||
|
"detail": {
|
||||||
|
"title": "Select your default search engine"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"openSearchInNewTab": {
|
||||||
|
"label": "Open search results in new tab",
|
||||||
|
"children": {
|
||||||
|
"detail": {
|
||||||
|
"title": "Open search results in new tab"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ddgBangs": {
|
||||||
|
"label": "Enable DuckDuckGo bangs",
|
||||||
|
"children": {
|
||||||
|
"detail": {
|
||||||
|
"title": "Enable DuckDuckGo bangs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"firstDayOfWeek": {
|
||||||
|
"label": "First day of the week",
|
||||||
|
"children": {
|
||||||
|
"detail": {
|
||||||
|
"title": "Select the first day of the week"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"homeBoardId": {
|
||||||
|
"label": "Home board",
|
||||||
|
"children": {
|
||||||
|
"detail": {
|
||||||
|
"title": "Select your home board"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mobileHomeBoardId": {
|
||||||
|
"label": "Mobile home board",
|
||||||
|
"children": {
|
||||||
|
"detail": {
|
||||||
|
"title": "Select your mobile home board"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pingIconsEnabled": {
|
||||||
|
"label": "Use icons for pings",
|
||||||
|
"children": {
|
||||||
|
"detail": {
|
||||||
|
"title": "Use icons for pings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fullPreferencesPage": {
|
||||||
|
"label": "Open full preferences page"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+3
@@ -2144,6 +2144,9 @@ importers:
|
|||||||
'@homarr/modals-collection':
|
'@homarr/modals-collection':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../modals-collection
|
version: link:../modals-collection
|
||||||
|
'@homarr/notifications':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../notifications
|
||||||
'@homarr/settings':
|
'@homarr/settings':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../settings
|
version: link:../settings
|
||||||
|
|||||||
Reference in new issue
Block a user