Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
114d9ce321 | ||
|
|
3d3e3059a9 | ||
|
|
9ae8cb2768 | ||
|
|
99346f288b | ||
|
|
41fe5928f1 | ||
|
|
6eb59d6f53 | ||
|
|
2c6b7a818e | ||
|
|
fd10788838 | ||
|
|
fe4598f722 | ||
|
|
a755692d81 | ||
|
|
3ff0d72ba1 | ||
|
|
a63a191203 | ||
|
|
1be906a0cd | ||
|
|
63d8fa8c26 | ||
|
|
b5dd6ae8ef | ||
|
|
d540ad374a | ||
|
|
6a971c1986 | ||
|
|
d86fe3ddf0 | ||
|
|
06db8960a7 | ||
|
|
a5719acf34 | ||
|
|
d1d597c8e1 | ||
|
|
3a1adab3e5 | ||
|
|
e67d603570 | ||
|
|
0449716805 | ||
|
|
657e42e4bb | ||
|
|
256ebcb864 | ||
|
|
72e86dee01 | ||
|
|
b7910b16e0 | ||
|
|
5a83bb6e39 | ||
|
|
433b66582d | ||
|
|
acbcb6818b | ||
|
|
07d991a0a1 | ||
|
|
92364e9574 | ||
|
|
e0a77948e2 | ||
|
|
2991777b0a | ||
|
|
6cc2ac56a9 | ||
|
|
92086e060f | ||
|
|
a2c4b29bfd | ||
|
|
5501ee6a7d | ||
|
|
f6592b27a1 | ||
|
|
970b92cd95 | ||
|
|
e182d28605 | ||
|
|
19d86daa87 | ||
|
|
2aa5ab95e6 | ||
|
|
1bdfc44ba1 | ||
|
|
650f761325 | ||
|
|
be61602576 | ||
|
|
0ab0be0aa4 | ||
|
|
d01834d975 | ||
|
|
4a67bf0ef3 | ||
|
|
cf998b474b | ||
|
|
737e2f8c42 | ||
|
|
4454e3fb7a | ||
|
|
c33b09c5d4 | ||
|
|
bc0627ca9c | ||
|
|
c4ab2c35ab | ||
|
|
d44e7e9b52 | ||
|
|
1340880fef | ||
|
|
e79efe06bb | ||
|
|
c16974b8db | ||
|
|
b0c5de82f4 | ||
|
|
d99b20d3ef | ||
|
|
d8eaef859a |
No files matched your search
@@ -7,7 +7,7 @@
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"start": "pnpm with-env next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -21,11 +21,8 @@ export const CustomMantineProvider = ({
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
colorSchemeManager={manager}
|
||||
theme={createTheme({
|
||||
primaryColor: "red",
|
||||
autoContrast: true,
|
||||
defaultRadius: "sm",
|
||||
})}
|
||||
theme={theme}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
@@ -33,7 +33,12 @@ export const BoardMantineProvider = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
theme={theme}
|
||||
colorSchemeManager={colorSchemeManager}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import { Button, Stack, TextInput } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput } from "@homarr/ui";
|
||||
import { userInitSchema } from "@homarr/validation/user";
|
||||
|
||||
export const InitUserForm = () => {
|
||||
@@ -59,12 +59,10 @@ export const InitUserForm = () => {
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
label={t("field.password.label")}
|
||||
{...form.getInputProps("password")}
|
||||
<UserCreatePasswordFields
|
||||
passwordInputProps={form.getInputProps("password")}
|
||||
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
|
||||
/>
|
||||
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
||||
<Button type="submit" fullWidth loading={isPending}>
|
||||
{t("action.create")}
|
||||
</Button>
|
||||
|
||||
@@ -185,7 +185,7 @@ export default async function AboutPage() {
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
|
||||
{Object.entries(dependencies)
|
||||
.filter(([, value]) => !value.includes("workspace:"))
|
||||
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
|
||||
.sort(([key1], [key2]) => key1.localeCompare(key2))
|
||||
.map(([name, version]) => (
|
||||
<UnstyledButton
|
||||
key={name}
|
||||
|
||||
@@ -148,7 +148,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
|
||||
const integrationCategories = integrationDefs[searchParams.kind].category.flat();
|
||||
const supportsSearchEngine =
|
||||
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||
|
||||
@@ -48,7 +48,7 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
|
||||
} as const;
|
||||
}
|
||||
})
|
||||
.toSorted((certA, certB) => {
|
||||
.sort((certA, certB) => {
|
||||
if (certA.isError) return -1;
|
||||
if (certB.isError) return 1;
|
||||
return certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime();
|
||||
|
||||
@@ -107,6 +107,23 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const relativeTime = useTimeAgo(data.timestamp);
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data: data.containers,
|
||||
enableDensityToggle: false,
|
||||
@@ -125,35 +142,11 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
},
|
||||
|
||||
initialState: { density: "xs", showGlobalFilter: true },
|
||||
renderTopToolbarCustomActions: () => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
rightSection={<IconRefresh size="1rem" />}
|
||||
onClick={() => mutate()}
|
||||
loading={isPending}
|
||||
>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
),
|
||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||
return (
|
||||
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { userChangeHomeBoardsSchema } from "@homarr/validation/user";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
|
||||
interface ChangeHomeBoardFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
}
|
||||
|
||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
homeBoardId: variables.homeBoardId,
|
||||
mobileHomeBoardId: variables.mobileHomeBoardId,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeHomeBoard.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("user.action.changeHomeBoard.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(userChangeHomeBoardsSchema, {
|
||||
initialValues: {
|
||||
homeBoardId: user.homeBoardId,
|
||||
mobileHomeBoardId: user.mobileHomeBoardId,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate({
|
||||
userId: user.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof userChangeHomeBoardsSchema>;
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Select, Stack, Switch } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
|
||||
|
||||
interface ChangeSearchPreferencesFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
searchEnginesData: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
defaultSearchEngineId: variables.defaultSearchEngineId,
|
||||
openInNewTab: variables.openInNewTab,
|
||||
ddgBangsEnabled: variables.ddgBangsEnabled,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeSearchPreferences.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("user.action.changeSearchPreferences.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(userChangeSearchPreferencesSchema, {
|
||||
initialValues: {
|
||||
defaultSearchEngineId: user.defaultSearchEngineId,
|
||||
openInNewTab: user.openSearchInNewTab,
|
||||
ddgBangsEnabled: user.ddgBangs,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate({
|
||||
userId: user.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={t("user.field.defaultSearchEngine.label")}
|
||||
w="100%"
|
||||
data={searchEnginesData}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof userChangeSearchPreferencesSchema>;
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Radio, Stack } from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import localeData from "dayjs/plugin/localeData";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { userFirstDayOfWeekSchema } from "@homarr/validation/user";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
|
||||
interface FirstDayOfWeekProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
const weekDays = dayjs.weekdays(false);
|
||||
|
||||
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
firstDayOfWeek: variables.firstDayOfWeek as DayOfWeek,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(userFirstDayOfWeekSchema, {
|
||||
initialValues: {
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate({
|
||||
id: user.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
const inputProps = form.getInputProps("firstDayOfWeek");
|
||||
const onChange = inputProps.onChange as (value: number) => void;
|
||||
const value = (inputProps.value as number).toString();
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Radio.Group {...inputProps} value={value} onChange={(nextValue: string) => onChange(parseInt(nextValue, 10))}>
|
||||
<Group mt="xs">
|
||||
<Radio value="1" label={weekDays[1]} />
|
||||
<Radio value="6" label={weekDays[6]} />
|
||||
<Radio value="0" label={weekDays[0]} />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof userFirstDayOfWeekSchema>;
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Radio,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import localeData from "dayjs/plugin/localeData";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
userChangeHomeBoardsSchema,
|
||||
userChangeSearchPreferencesSchema,
|
||||
userEditProfileSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userPingIconsEnabledSchema,
|
||||
} from "@homarr/validation/user";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
|
||||
const userGeneralSettingsSchema = z.object({
|
||||
name: userEditProfileSchema.shape.name,
|
||||
email: userEditProfileSchema.shape.email,
|
||||
homeBoardId: userChangeHomeBoardsSchema.shape.homeBoardId,
|
||||
mobileHomeBoardId: userChangeHomeBoardsSchema.shape.mobileHomeBoardId,
|
||||
defaultSearchEngineId: userChangeSearchPreferencesSchema.shape.defaultSearchEngineId,
|
||||
openInNewTab: userChangeSearchPreferencesSchema.shape.openInNewTab,
|
||||
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
|
||||
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
||||
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
||||
|
||||
const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
|
||||
{ value: 1, labelKey: 1 },
|
||||
{ value: 6, labelKey: 6 },
|
||||
{ value: 0, labelKey: 0 },
|
||||
];
|
||||
|
||||
interface UserGeneralSettingsFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
searchEnginesData: { value: string; label: string }[];
|
||||
showLanguageSelector?: boolean;
|
||||
}
|
||||
|
||||
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
|
||||
name: user.name ?? "",
|
||||
email: user.email ?? "",
|
||||
homeBoardId: user.homeBoardId,
|
||||
mobileHomeBoardId: user.mobileHomeBoardId,
|
||||
defaultSearchEngineId: user.defaultSearchEngineId,
|
||||
openInNewTab: user.openSearchInNewTab,
|
||||
ddgBangsEnabled: user.ddgBangs,
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
pingIconsEnabled: user.pingIconsEnabled,
|
||||
});
|
||||
|
||||
export const UserGeneralSettingsForm = ({
|
||||
user,
|
||||
boardsData,
|
||||
searchEnginesData,
|
||||
showLanguageSelector = false,
|
||||
}: UserGeneralSettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tGeneral = useScopedI18n("management.page.user.setting.general");
|
||||
const isCredentialsUser = user.provider === "credentials";
|
||||
|
||||
const editProfileMutation = clientApi.user.editProfile.useMutation();
|
||||
const changeHomeBoardsMutation = clientApi.user.changeHomeBoards.useMutation();
|
||||
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
|
||||
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
||||
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
||||
|
||||
const initialValues = buildInitialValues(user);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
|
||||
const form = useZodForm(userGeneralSettingsSchema, {
|
||||
initialValues,
|
||||
});
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = form.isDirty();
|
||||
|
||||
const weekDays = useMemo(() => dayjs.weekdays(false), []);
|
||||
|
||||
const mutations = [
|
||||
editProfileMutation,
|
||||
changeHomeBoardsMutation,
|
||||
changeSearchPreferencesMutation,
|
||||
changeFirstDayOfWeekMutation,
|
||||
changePingIconsEnabledMutation,
|
||||
];
|
||||
const isPending = mutations.some((m) => m.isPending);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return;
|
||||
if (isDirtyRef.current) e.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAsync = async (values: FormValues) => {
|
||||
const parsed = userGeneralSettingsSchema.safeParse(values);
|
||||
if (!parsed.success) return;
|
||||
|
||||
const defaults = initialValuesRef.current;
|
||||
const changed = <K extends keyof FormValues>(...fields: K[]) =>
|
||||
fields.some((f) => (f === "email" ? (values[f] || null) !== (defaults[f] || null) : values[f] !== defaults[f]));
|
||||
|
||||
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
|
||||
{
|
||||
when: isCredentialsUser && changed("name", "email"),
|
||||
action: () =>
|
||||
editProfileMutation.mutateAsync({ id: user.id, name: parsed.data.name, email: parsed.data.email ?? "" }),
|
||||
},
|
||||
{
|
||||
when: changed("homeBoardId", "mobileHomeBoardId"),
|
||||
action: () =>
|
||||
changeHomeBoardsMutation.mutateAsync({
|
||||
userId: user.id,
|
||||
homeBoardId: parsed.data.homeBoardId,
|
||||
mobileHomeBoardId: parsed.data.mobileHomeBoardId,
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("defaultSearchEngineId", "openInNewTab", "ddgBangsEnabled"),
|
||||
action: () =>
|
||||
changeSearchPreferencesMutation.mutateAsync({
|
||||
userId: user.id,
|
||||
defaultSearchEngineId: parsed.data.defaultSearchEngineId,
|
||||
openInNewTab: parsed.data.openInNewTab,
|
||||
ddgBangsEnabled: parsed.data.ddgBangsEnabled,
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("firstDayOfWeek"),
|
||||
action: () =>
|
||||
changeFirstDayOfWeekMutation.mutateAsync({ id: user.id, firstDayOfWeek: parsed.data.firstDayOfWeek }),
|
||||
},
|
||||
{
|
||||
when: changed("pingIconsEnabled"),
|
||||
action: () =>
|
||||
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
||||
if (promises.length === 0) return;
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
const newValues = { ...parsed.data, email: parsed.data.email ?? "" };
|
||||
initialValuesRef.current = newValues;
|
||||
form.setInitialValues(newValues);
|
||||
form.resetDirty();
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("common.notification.update.success"),
|
||||
});
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
form.setValues(initialValuesRef.current);
|
||||
form.resetDirty();
|
||||
};
|
||||
|
||||
const firstDayOfWeekInputProps = form.getInputProps("firstDayOfWeek");
|
||||
const firstDayOfWeekOnChange = firstDayOfWeekInputProps.onChange as (value: number) => void;
|
||||
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{t("user.name")}</Title>
|
||||
{!isCredentialsUser && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("management.page.user.fieldsDisabledExternalProvider")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider />
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={t("user.field.username.label")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={t("user.field.email.label")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.search")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Select
|
||||
label={t("user.field.defaultSearchEngine.label")}
|
||||
w="100%"
|
||||
data={searchEnginesData}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.language")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
||||
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<Radio.Group
|
||||
{...firstDayOfWeekInputProps}
|
||||
value={firstDayOfWeekValue}
|
||||
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
||||
>
|
||||
<Group mt="xs" wrap="wrap">
|
||||
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
||||
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
||||
))}
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
<Divider my="xs" />
|
||||
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
||||
<Switch
|
||||
label={t("user.field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
{form.isDirty() && (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { userPingIconsEnabledSchema } from "@homarr/validation/user";
|
||||
|
||||
interface PingIconsEnabledProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
pingIconsEnabled: variables.pingIconsEnabled,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changePingIconsEnabled.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("user.action.changePingIconsEnabled.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(userPingIconsEnabledSchema, {
|
||||
initialValues: {
|
||||
pingIconsEnabled: user.pingIconsEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate({
|
||||
id: user.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Switch
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
label={t("user.field.pingIconsEnabled.label")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof userPingIconsEnabledSchema>;
|
||||
+5
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useDisclosure, useMediaQuery } from "@mantine/hooks";
|
||||
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -25,6 +25,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||
},
|
||||
});
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n();
|
||||
const tManageAvatar = useScopedI18n("user.action.manageAvatar");
|
||||
@@ -94,11 +95,11 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start">
|
||||
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
|
||||
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow withinPortal>
|
||||
<Menu.Target>
|
||||
<UnstyledButton onClick={toggle}>
|
||||
<UserAvatar user={user} size={200} />
|
||||
<UserAvatar user={user} size={isMobile ? 120 : 200} />
|
||||
<Button
|
||||
component="div"
|
||||
pos="absolute"
|
||||
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { userEditProfileSchema } from "@homarr/validation/user";
|
||||
|
||||
interface UserProfileFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
export const UserProfileForm = ({ user }: UserProfileFormProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync("/manage/users");
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
// Reset form initial values to reset dirty state
|
||||
form.setInitialValues({
|
||||
name: variables.name,
|
||||
email: variables.email ?? "",
|
||||
});
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("user.action.editProfile.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError(error) {
|
||||
const message =
|
||||
error.data?.code === "CONFLICT"
|
||||
? t("user.error.usernameTaken")
|
||||
: t("user.action.editProfile.notification.error.message");
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message,
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(userEditProfileSchema.omit({ id: true }), {
|
||||
initialValues: {
|
||||
name: user.name ?? "",
|
||||
email: user.email ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
// Only credentials users can edit their profile
|
||||
const isProviderCredentials = user.provider === "credentials";
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: FormType) => {
|
||||
if (!isProviderCredentials) return;
|
||||
mutate({
|
||||
...values,
|
||||
id: user.id,
|
||||
});
|
||||
},
|
||||
[isProviderCredentials, mutate, user.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
disabled={!isProviderCredentials}
|
||||
label={t("user.field.username.label")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={!isProviderCredentials}
|
||||
label={t("user.field.email.label")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
|
||||
{isProviderCredentials && (
|
||||
<Group justify="end">
|
||||
<Button type="submit" disabled={!form.isDirty()} loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;
|
||||
@@ -6,18 +6,13 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||
import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
|
||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
||||
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
|
||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||
import { UserProfileForm } from "./_components/_profile-form";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{
|
||||
@@ -63,7 +58,7 @@ export default async function EditUserPage(props: Props) {
|
||||
|
||||
const boards = await api.board.getAllBoards();
|
||||
const searchEngines = await api.searchEngine.getSelectable();
|
||||
|
||||
const isSelf = session?.user.id === user.id;
|
||||
const isCredentialsUser = user.provider === "credentials";
|
||||
|
||||
return (
|
||||
@@ -73,51 +68,25 @@ export default async function EditUserPage(props: Props) {
|
||||
{t("management.page.user.fieldsDisabledExternalProvider")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Title>{tGeneral("title")}</Title>
|
||||
<Group gap="xl">
|
||||
<Box flex={1}>
|
||||
<UserProfileForm user={user} />
|
||||
<Group gap="xl" align="flex-start" wrap="wrap">
|
||||
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
||||
<UserGeneralSettingsForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
}))}
|
||||
searchEnginesData={searchEngines}
|
||||
showLanguageSelector={isSelf}
|
||||
/>
|
||||
</Box>
|
||||
<Box w={{ base: "100%", lg: 200 }}>
|
||||
<Box w={{ base: "100%", lg: 260 }}>
|
||||
<UserProfileAvatarForm user={user} />
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{session?.user.id === user.id && (
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.language")}</Title>
|
||||
<CurrentLanguageCombobox />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.board.title")}</Title>
|
||||
<ChangeHomeBoardForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.search")}</Title>
|
||||
<ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<FirstDayOfWeek user={user} />
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.accessibility")}</Title>
|
||||
<PingIconsEnabled user={user} />
|
||||
</Stack>
|
||||
|
||||
<DangerZoneRoot>
|
||||
<DangerZoneItem
|
||||
label={t("user.action.delete.label")}
|
||||
|
||||
+6
-26
@@ -1,20 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useCallback, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Stepper,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core";
|
||||
import { useListState } from "@mantine/hooks";
|
||||
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -24,10 +11,11 @@ import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
||||
import type { IsValid } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
|
||||
import { userPasswordSchema } from "@homarr/validation/user";
|
||||
|
||||
@@ -167,18 +155,10 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
<form>
|
||||
<Card p="xl">
|
||||
<Stack gap="md">
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
label={tUserField("password.label")}
|
||||
<UserCreatePasswordFields
|
||||
variant="filled"
|
||||
withAsterisk
|
||||
{...securityForm.getInputProps("password")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={tUserField("passwordConfirm.label")}
|
||||
variant="filled"
|
||||
withAsterisk
|
||||
{...securityForm.getInputProps("confirmPassword")}
|
||||
passwordInputProps={securityForm.getInputProps("password")}
|
||||
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
settings,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
|
||||
@@ -6,11 +6,20 @@ import { LanguageCombobox } from "./language-combobox";
|
||||
|
||||
interface CurrentLanguageComboboxProps {
|
||||
width?: string;
|
||||
withinPortal?: boolean;
|
||||
}
|
||||
|
||||
export const CurrentLanguageCombobox = ({ width }: CurrentLanguageComboboxProps) => {
|
||||
export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguageComboboxProps) => {
|
||||
const currentLocale = useCurrentLocale();
|
||||
const { changeLocale, isPending } = useChangeLocale();
|
||||
|
||||
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} width={width} />;
|
||||
return (
|
||||
<LanguageCombobox
|
||||
value={currentLocale}
|
||||
onChange={changeLocale}
|
||||
isPending={isPending}
|
||||
width={width}
|
||||
withinPortal={withinPortal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -16,9 +16,17 @@ interface LanguageComboboxProps {
|
||||
onChange: (value: SupportedLanguage) => void;
|
||||
isPending?: boolean;
|
||||
width?: string;
|
||||
withinPortal?: boolean;
|
||||
}
|
||||
|
||||
export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => {
|
||||
export const LanguageCombobox = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
isPending,
|
||||
width,
|
||||
withinPortal = false,
|
||||
}: LanguageComboboxProps) => {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
@@ -39,7 +47,7 @@ export const LanguageCombobox = ({ label, value, onChange, isPending, width }: L
|
||||
}, [combobox]);
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
|
||||
<Combobox store={combobox} withinPortal={withinPortal} onOptionSubmit={handleOnOptionSubmit}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=10.33.4"
|
||||
"pnpm": ">=10.34.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"packageManager": "pnpm@10.34.1",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3"
|
||||
|
||||
+8
-8
@@ -17,8 +17,8 @@
|
||||
"db:studio": "pnpm -F db studio",
|
||||
"dev": "pnpm -F nextjs dev",
|
||||
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
|
||||
"format": "turbo format --continue",
|
||||
"format:fix": "turbo format --continue -- --write",
|
||||
"format": "turbo format --continue -- --check",
|
||||
"format:fix": "turbo format --continue",
|
||||
"lint": "turbo lint --continue",
|
||||
"lint:fix": "turbo lint --continue -- --fix",
|
||||
"lint:ws": "pnpm dlx sherif@latest",
|
||||
@@ -60,9 +60,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=10.33.4"
|
||||
"pnpm": ">=10.34.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"packageManager": "pnpm@10.34.1",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@tree-sitter-grammars/tree-sitter-yaml",
|
||||
@@ -76,14 +76,14 @@
|
||||
"tree-sitter-json"
|
||||
],
|
||||
"overrides": {
|
||||
"@babel/helpers@<7.26.10": ">=7.29.2",
|
||||
"@babel/runtime@<7.26.10": ">=7.29.2",
|
||||
"@babel/helpers@<7.26.10": ">=7.29.7",
|
||||
"@babel/runtime@<7.26.10": ">=7.29.7",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
|
||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
|
||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
|
||||
"esbuild@<=0.24.2": ">=0.28.0",
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
|
||||
"hono@<4.6.5": ">=4.12.22",
|
||||
"hono@<4.6.5": ">=4.12.23",
|
||||
"linkifyjs@<4.3.2": ">=4.3.3",
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
@@ -93,7 +93,7 @@
|
||||
"sha.js@<=2.4.11": ">=2.4.12",
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||
"tmp@<=0.2.3": ">=0.2.5",
|
||||
"tmp@<=0.2.3": ">=0.2.7",
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -14,11 +16,9 @@ import {
|
||||
integrations,
|
||||
integrationSecrets,
|
||||
integrationUserPermissions,
|
||||
searchEngines,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import {
|
||||
getIconUrl,
|
||||
getIntegrationKindsByCategory,
|
||||
getPermissionsWithParents,
|
||||
integrationCategories,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
integrationSavePermissionsSchema,
|
||||
integrationUpdateSchema,
|
||||
} from "@homarr/validation/integration";
|
||||
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
@@ -41,6 +42,7 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn
|
||||
import { mapTestConnectionError } from "./map-test-connection-error";
|
||||
|
||||
const logger = createLogger({ module: "integrationRouter" });
|
||||
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
@@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
@@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
@@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
if (
|
||||
input.attemptSearchEngineCreation &&
|
||||
integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
|
||||
) {
|
||||
const icon = getIconUrl(input.kind);
|
||||
await ctx.db.insert(searchEngines).values({
|
||||
id: createId(),
|
||||
name: input.name,
|
||||
integrationId,
|
||||
type: "fromIntegration",
|
||||
iconUrl: icon,
|
||||
short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
|
||||
});
|
||||
}
|
||||
// Media request search is a first-class Spotlight action. Do not create a search engine for it.
|
||||
}),
|
||||
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
@@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
return {
|
||||
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
||||
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
users: userPermissions
|
||||
@@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
user,
|
||||
permission,
|
||||
}))
|
||||
.sort((permissionA, permissionB) => {
|
||||
.toSorted((permissionA, permissionB) => {
|
||||
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
||||
}),
|
||||
groups: dbGroupIntegrationPermission
|
||||
@@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
permission,
|
||||
}))
|
||||
.sort((permissionA, permissionB) => {
|
||||
.toSorted((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
};
|
||||
@@ -618,8 +607,149 @@ export const integrationRouter = createTRPCRouter({
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
return await integrationInstance.searchAsync(encodeURI(input.query));
|
||||
}),
|
||||
mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => {
|
||||
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx);
|
||||
|
||||
return integrationsWithAccess.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions: {
|
||||
hasInteractAccess: permissions.hasInteractAccess,
|
||||
},
|
||||
};
|
||||
});
|
||||
}),
|
||||
searchMediaRequests: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
integrationIds: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const query = input.query.trim();
|
||||
if (query.length === 0) return [];
|
||||
|
||||
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds);
|
||||
|
||||
const results = await Promise.all(
|
||||
integrationsWithAccess.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
kind: integration.kind as (typeof mediaRequestSearchKinds)[number],
|
||||
externalUrl: integration.app?.href ?? null,
|
||||
decryptedSecrets: integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
});
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) =>
|
||||
searchResults.map((result) => ({
|
||||
...result,
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions: {
|
||||
hasInteractAccess: permissions.hasInteractAccess,
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return results.flat();
|
||||
}),
|
||||
getMediaRequestOptions: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr"))
|
||||
.input(mediaRequestOptionsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
|
||||
}),
|
||||
requestMedia: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr"))
|
||||
.input(mediaRequestRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
}),
|
||||
});
|
||||
|
||||
interface IntegrationRouterContext {
|
||||
db: Database;
|
||||
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
|
||||
}
|
||||
|
||||
const getAccessibleMediaRequestSearchIntegrationsAsync = async (
|
||||
ctx: IntegrationRouterContext,
|
||||
integrationIds?: string[],
|
||||
) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
where:
|
||||
integrationIds && integrationIds.length > 0
|
||||
? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds))
|
||||
: inArray(integrations.kind, mediaRequestSearchKinds),
|
||||
orderBy: asc(integrations.name),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: true,
|
||||
},
|
||||
},
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (integrationIds && integrationsFromDb.length !== integrationIds.length) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "One or more media request search integrations were not found",
|
||||
});
|
||||
}
|
||||
|
||||
const integrationsWithAccess = await Promise.all(
|
||||
integrationsFromDb.map(async (integration) => ({
|
||||
integration,
|
||||
hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session),
|
||||
})),
|
||||
);
|
||||
|
||||
if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to query one or more media request search integrations",
|
||||
});
|
||||
}
|
||||
|
||||
return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration);
|
||||
};
|
||||
|
||||
interface UpdateSecretInput {
|
||||
integrationId: string;
|
||||
value: string;
|
||||
@@ -640,35 +770,6 @@ interface AddSecretInput {
|
||||
kind: IntegrationSecretKind;
|
||||
}
|
||||
|
||||
const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
|
||||
const searchEngines = await db.query.searchEngines.findMany({
|
||||
columns: {
|
||||
short: true,
|
||||
},
|
||||
});
|
||||
|
||||
const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
|
||||
const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
|
||||
|
||||
if (!usedShortNames.includes(nameByIntegrationName)) {
|
||||
return nameByIntegrationName;
|
||||
}
|
||||
|
||||
// 8 is max length constraint
|
||||
for (let i = 2; i < 9999999; i++) {
|
||||
const generatedName = `${nameByIntegrationName}${i}`;
|
||||
if (usedShortNames.includes(generatedName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return generatedName;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
|
||||
);
|
||||
};
|
||||
|
||||
const addSecretAsync = async (db: Database, input: AddSecretInput) => {
|
||||
await db.insert(integrationSecrets).values({
|
||||
kind: input.kind,
|
||||
|
||||
@@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { asc, eq, like } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { searchEngines, users } from "@homarr/db/schema";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
|
||||
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine";
|
||||
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "searchEngineRouter" });
|
||||
@@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
limit: input.limit,
|
||||
});
|
||||
}),
|
||||
getMediaRequestOptions: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
|
||||
.input(mediaRequestOptionsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
|
||||
}),
|
||||
requestMedia: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
|
||||
.input(mediaRequestRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
}),
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("search-engine-create")
|
||||
.input(searchEngineManageSchema)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { apps, integrations, integrationSecrets } from "@homarr/db/schema";
|
||||
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
@@ -59,6 +59,127 @@ describe("all should return all integrations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
|
||||
test("with integration use access should only return media request search integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-use-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin",
|
||||
url: "http://jellyfin.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.mediaRequestSearchTargets();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.kind).toBe("jellyseerr");
|
||||
});
|
||||
|
||||
test("without access should not return media request search integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
});
|
||||
|
||||
const result = await caller.mediaRequestSearchTargets();
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("with direct integration use access should return media request search integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
});
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.mediaRequestSearchTargets();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchMediaRequests should validate scoped integrations", () => {
|
||||
test("with a non media search integration should throw not found", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-use-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin",
|
||||
url: "http://jellyfin.local",
|
||||
});
|
||||
|
||||
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
|
||||
"One or more media request search integrations were not found",
|
||||
);
|
||||
});
|
||||
|
||||
test("without query access to a scoped media search integration should throw forbidden", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
});
|
||||
|
||||
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
|
||||
"User does not have permission to query one or more media request search integrations",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("byId should return an integration by id", () => {
|
||||
test("with full access should return an integration by id", async () => {
|
||||
const db = createDb();
|
||||
@@ -208,7 +329,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
});
|
||||
|
||||
test("with create integration access should create a new integration when creating search engine", async () => {
|
||||
test("with create integration access should not create a search engine for media request search integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
@@ -243,12 +364,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
|
||||
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSearchEngine!.short).toBe("j");
|
||||
expect(dbSearchEngine!.name).toBe(input.name);
|
||||
expect(dbSearchEngine!.iconUrl).toBe(
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
|
||||
);
|
||||
expect(dbSearchEngine).toBeUndefined();
|
||||
});
|
||||
|
||||
test("with create integration access should create a new integration with new linked app", async () => {
|
||||
|
||||
@@ -81,13 +81,7 @@ describe("initUser should initialize the first user", () => {
|
||||
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["aB2%"], // too short
|
||||
["abc123DEF"], // does not contain special characters
|
||||
["abcDEFghi+"], // does not contain numbers
|
||||
["ABC123+/-"], // does not contain lowercase
|
||||
["abc123+/-"], // does not contain uppercase
|
||||
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
|
||||
it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
@@ -103,7 +97,55 @@ describe("initUser should initialize the first user", () => {
|
||||
confirmPassword: password,
|
||||
});
|
||||
|
||||
await expect(actAsync()).rejects.toThrow("passwordRequirements");
|
||||
await expect(actAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should accept passwords without complexity requirements", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
username: "test",
|
||||
password: "abc123DEF",
|
||||
confirmPassword: "abc123DEF",
|
||||
});
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should accept passwords with special characters like LoveHomarr<3", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
username: "test",
|
||||
password: "LoveHomarr<3",
|
||||
confirmPassword: "LoveHomarr<3",
|
||||
});
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ export const env = createEnv({
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
|
||||
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
|
||||
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: z
|
||||
.enum(["client_secret_basic", "client_secret_post", "client_secret_jwt", "none"])
|
||||
.default("client_secret_basic"),
|
||||
}
|
||||
: {}),
|
||||
...(authProviders.includes("ldap")
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,9 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
|
||||
type: "oidc",
|
||||
clientId: env.AUTH_OIDC_CLIENT_ID,
|
||||
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
|
||||
client: {
|
||||
token_endpoint_auth_method: env.AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD,
|
||||
},
|
||||
issuer: env.AUTH_OIDC_ISSUER,
|
||||
allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING,
|
||||
authorization: {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"scripts": {
|
||||
"build": "esbuild src/index.ts --bundle --platform=node --outfile=cli.cjs --external:bcrypt --external:cpu-features --loader:.html=text --loader:.node=text",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"run": "pnpm with-env node cli.cjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { EVERY_HOUR } from "@homarr/cron-jobs-core/expressions";
|
||||
import { EVERY_DAY } from "@homarr/cron-jobs-core/expressions";
|
||||
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
|
||||
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
export const updateCheckerJob = createCronJob("updateChecker", EVERY_HOUR, {
|
||||
export const updateCheckerJob = createCronJob("updateChecker", EVERY_DAY, {
|
||||
runOnStart: true,
|
||||
}).withCallback(async () => {
|
||||
const handler = updateCheckerRequestHandler.handler({});
|
||||
await handler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: true,
|
||||
});
|
||||
await handler.getCachedOrUpdatedDataAsync({});
|
||||
});
|
||||
@@ -20,7 +20,7 @@
|
||||
"build:postgresql": "esbuild migrations/postgresql/migrate.ts --bundle --platform=node --outfile=migrations/postgresql/migrate.cjs",
|
||||
"build:sqlite": "esbuild migrations/sqlite/migrate.ts --bundle --platform=node --outfile=migrations/sqlite/migrate.cjs",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"migration:custom": "pnpm with-env tsx ./migrations/custom/run-custom.ts",
|
||||
"migration:mysql:drop": "pnpm with-env drizzle-kit drop --config ./configs/mysql.config.ts",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"postinstall": "tsx ./src/docs/codegen.ts",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -5,3 +5,5 @@ export * from "./icon-picker/icon-picker";
|
||||
export * from "./new-app/icon-matcher";
|
||||
|
||||
export * from "./upload-media/upload-media";
|
||||
|
||||
export * from "./user-create/user-create-password-fields";
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import type { z } from "zod/v4";
|
||||
@@ -17,6 +17,19 @@ import { findBestIconMatch } from "./icon-matcher";
|
||||
|
||||
type FormType = z.infer<typeof appManageSchema>;
|
||||
|
||||
const toFormValues = (values: FormType | undefined): FormType => ({
|
||||
name: values?.name ?? "",
|
||||
description: values?.description ?? "",
|
||||
iconUrl: values?.iconUrl ?? "",
|
||||
href: values?.href ?? "",
|
||||
pingUrl: values?.pingUrl ?? "",
|
||||
});
|
||||
|
||||
export interface AppFormHandle {
|
||||
submit: () => Promise<boolean>;
|
||||
isDirty: () => boolean;
|
||||
}
|
||||
|
||||
interface AppFormProps {
|
||||
showBackToOverview: boolean;
|
||||
buttonLabels: {
|
||||
@@ -24,8 +37,10 @@ interface AppFormProps {
|
||||
submitAndCreateAnother?: string;
|
||||
};
|
||||
initialValues?: FormType;
|
||||
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void;
|
||||
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void | Promise<void>;
|
||||
isPending: boolean;
|
||||
hideButtons?: boolean;
|
||||
formRef?: React.Ref<AppFormHandle>;
|
||||
}
|
||||
|
||||
export const AppForm = ({
|
||||
@@ -34,19 +49,31 @@ export const AppForm = ({
|
||||
handleSubmit: originalHandleSubmit,
|
||||
initialValues,
|
||||
isPending,
|
||||
hideButtons,
|
||||
formRef,
|
||||
}: AppFormProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
const form = useZodForm(appManageSchema, {
|
||||
initialValues: {
|
||||
name: initialValues?.name ?? "",
|
||||
description: initialValues?.description ?? "",
|
||||
iconUrl: initialValues?.iconUrl ?? "",
|
||||
href: initialValues?.href ?? "",
|
||||
pingUrl: initialValues?.pingUrl ?? "",
|
||||
},
|
||||
initialValues: toFormValues(initialValues),
|
||||
});
|
||||
|
||||
const initialValuesKey = [
|
||||
initialValues?.name,
|
||||
initialValues?.description,
|
||||
initialValues?.iconUrl,
|
||||
initialValues?.href,
|
||||
initialValues?.pingUrl,
|
||||
].join("\0");
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialValues || form.isDirty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.initialize(toFormValues(initialValues));
|
||||
}, [initialValuesKey, form, initialValues]);
|
||||
|
||||
// Debounce the name value with 200ms delay
|
||||
const [debouncedName] = useDebouncedValue(form.values.name, 200);
|
||||
|
||||
@@ -62,6 +89,29 @@ export const AppForm = ({
|
||||
originalHandleSubmit(values, redirect, afterSuccess);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
formRef,
|
||||
() => ({
|
||||
submit: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
form.onSubmit(
|
||||
async (values) => {
|
||||
try {
|
||||
await Promise.resolve(handleSubmit(values));
|
||||
form.initialize(values);
|
||||
resolve(true);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
() => resolve(false),
|
||||
)();
|
||||
}),
|
||||
isDirty: () => form.isDirty(),
|
||||
}),
|
||||
[form, handleSubmit],
|
||||
);
|
||||
|
||||
const [opened, { open, close }] = useDisclosure((initialValues?.pingUrl?.length ?? 0) > 0);
|
||||
|
||||
const handleClickDifferentUrlPing: ChangeEventHandler<HTMLInputElement> = () => {
|
||||
@@ -92,32 +142,32 @@ export const AppForm = ({
|
||||
}
|
||||
}, [debouncedName, iconsData]);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<Textarea
|
||||
{...form.getInputProps("description")}
|
||||
label={t("app.field.description.label")}
|
||||
autosize
|
||||
minRows={2}
|
||||
resize="vertical"
|
||||
/>
|
||||
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
|
||||
const formFields = (
|
||||
<Stack>
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<Textarea
|
||||
{...form.getInputProps("description")}
|
||||
label={t("app.field.description.label")}
|
||||
autosize
|
||||
minRows={2}
|
||||
resize="vertical"
|
||||
/>
|
||||
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
|
||||
|
||||
<Checkbox
|
||||
checked={opened}
|
||||
onChange={handleClickDifferentUrlPing}
|
||||
label={t("app.field.useDifferentUrlForPing.checkbox.label")}
|
||||
description={t("app.field.useDifferentUrlForPing.checkbox.description")}
|
||||
mt="md"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={opened}
|
||||
onChange={handleClickDifferentUrlPing}
|
||||
label={t("app.field.useDifferentUrlForPing.checkbox.label")}
|
||||
description={t("app.field.useDifferentUrlForPing.checkbox.description")}
|
||||
mt="md"
|
||||
/>
|
||||
|
||||
<Collapse expanded={opened}>
|
||||
<TextInput {...form.getInputProps("pingUrl")} />
|
||||
</Collapse>
|
||||
<Collapse expanded={opened}>
|
||||
<TextInput {...form.getInputProps("pingUrl")} />
|
||||
</Collapse>
|
||||
|
||||
{!hideButtons && (
|
||||
<Group justify="end">
|
||||
{showBackToOverview && (
|
||||
<Button variant="default" component={Link} href="/manage/apps">
|
||||
@@ -139,7 +189,13 @@ export const AppForm = ({
|
||||
{buttonLabels.submit}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (hideButtons) {
|
||||
return formFields;
|
||||
}
|
||||
|
||||
return <form onSubmit={form.onSubmit(handleSubmit)}>{formFields}</form>;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { PasswordInput, Stack } from "@mantine/core";
|
||||
import type { PasswordInputProps } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput } from "@homarr/ui";
|
||||
|
||||
interface UserCreatePasswordFieldsProps {
|
||||
passwordInputProps: PasswordInputProps;
|
||||
confirmPasswordInputProps: PasswordInputProps;
|
||||
variant?: PasswordInputProps["variant"];
|
||||
}
|
||||
|
||||
export const UserCreatePasswordFields = ({
|
||||
passwordInputProps,
|
||||
confirmPasswordInputProps,
|
||||
variant,
|
||||
}: UserCreatePasswordFieldsProps) => {
|
||||
const t = useScopedI18n("user.field");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
label={t("password.label")}
|
||||
variant={variant}
|
||||
{...passwordInputProps}
|
||||
/>
|
||||
<PasswordInput label={t("passwordConfirm.label")} variant={variant} {...confirmPasswordInputProps} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -12,12 +12,14 @@ interface SeriesInformation {
|
||||
overview: string;
|
||||
seasons: SerieSeason[];
|
||||
posterPath: string;
|
||||
requestedSeasons?: number[];
|
||||
}
|
||||
|
||||
interface MovieInformation {
|
||||
id: number;
|
||||
overview: string;
|
||||
posterPath: string;
|
||||
requestedSeasons?: number[];
|
||||
}
|
||||
|
||||
export type MediaInformation = SeriesInformation | MovieInformation;
|
||||
|
||||
@@ -60,6 +60,9 @@ export class OverseerrIntegration
|
||||
text: "overview" in result ? result.overview : undefined,
|
||||
type: result.mediaType,
|
||||
inLibrary: result.mediaInfo !== undefined,
|
||||
availability: result.mediaInfo
|
||||
? this.mapAvailability(result.mediaInfo.status as UpstreamMediaAvailability, false)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -70,7 +73,12 @@ export class OverseerrIntegration
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
return await mediaInformationSchema.parseAsync(await response.json());
|
||||
const data = await mediaInformationSchema.parseAsync(await response.json());
|
||||
const requestedSeasons = [
|
||||
...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []),
|
||||
];
|
||||
const { mediaInfo: _strip, ...rest } = data;
|
||||
return { ...rest, requestedSeasons };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,6 +349,22 @@ interface MovieInformation {
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
const mediaInfoRequestsSchema = z
|
||||
.object({
|
||||
requests: z
|
||||
.array(
|
||||
z.object({
|
||||
seasons: z.array(
|
||||
z.object({
|
||||
seasonNumber: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const mediaInformationSchema = z.union([
|
||||
z.object({
|
||||
id: z.number(),
|
||||
@@ -355,14 +379,22 @@ const mediaInformationSchema = z.union([
|
||||
),
|
||||
numberOfSeasons: z.number(),
|
||||
posterPath: z.string().startsWith("/"),
|
||||
mediaInfo: mediaInfoRequestsSchema,
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
overview: z.string(),
|
||||
posterPath: z.string().startsWith("/"),
|
||||
mediaInfo: mediaInfoRequestsSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
const searchMediaInfoSchema = z
|
||||
.object({
|
||||
status: z.number(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const searchSchema = z.object({
|
||||
results: z
|
||||
.array(
|
||||
@@ -373,7 +405,7 @@ const searchSchema = z.object({
|
||||
name: z.string(),
|
||||
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
overview: z.string(),
|
||||
mediaInfo: z.object({}).optional(),
|
||||
mediaInfo: searchMediaInfoSchema,
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
@@ -381,14 +413,14 @@ const searchSchema = z.object({
|
||||
title: z.string(),
|
||||
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
overview: z.string(),
|
||||
mediaInfo: z.object({}).optional(),
|
||||
mediaInfo: searchMediaInfoSchema,
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
mediaType: z.literal("person"),
|
||||
name: z.string(),
|
||||
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
mediaInfo: z.object({}).optional(),
|
||||
mediaInfo: searchMediaInfoSchema,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
|
||||
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
|
||||
import { MRT_Table } from "mantine-react-table";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -16,13 +16,13 @@ interface RequestMediaModalProps {
|
||||
}
|
||||
|
||||
export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => {
|
||||
const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({
|
||||
const { data, isPending: isPendingQuery } = clientApi.integration.getMediaRequestOptions.useQuery({
|
||||
integrationId: innerProps.integrationId,
|
||||
mediaId: innerProps.mediaId,
|
||||
mediaType: innerProps.mediaType,
|
||||
});
|
||||
|
||||
const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({
|
||||
const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
@@ -33,6 +33,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
|
||||
|
||||
const isPending = isPendingQuery || isPendingMutation;
|
||||
const t = useI18n();
|
||||
const requestedSeasons = new Set(data?.requestedSeasons ?? []);
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<Season>[]>(
|
||||
() => [
|
||||
@@ -44,8 +45,18 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
|
||||
accessorKey: "episodeCount",
|
||||
header: t("search.engine.media.request.modal.table.header.episodes"),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: t("search.engine.media.request.modal.table.header.status"),
|
||||
Cell: ({ row }) =>
|
||||
requestedSeasons.has(row.original.seasonNumber) ? (
|
||||
<Badge size="xs" color="violet" variant="light">
|
||||
{t("search.engine.media.request.modal.table.status.requested")}
|
||||
</Badge>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
[],
|
||||
[data?.requestedSeasons],
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
@@ -56,7 +67,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
|
||||
enablePagination: false,
|
||||
enableSorting: false,
|
||||
enableSelectAll: true,
|
||||
enableRowSelection: true,
|
||||
enableRowSelection: (row: MRT_Row<Season>) => !requestedSeasons.has(row.original.seasonNumber),
|
||||
mantineTableProps: {
|
||||
highlightOnHover: false,
|
||||
striped: "odd",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ const optionMapping: OptionMapping = {
|
||||
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
|
||||
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
|
||||
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
|
||||
withBorder: () => undefined,
|
||||
},
|
||||
calendar: {
|
||||
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { Duration } from "dayjs/plugin/duration";
|
||||
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { createChannelWithLatestAndEvents } from "@homarr/redis";
|
||||
|
||||
@@ -15,6 +16,7 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
options: Options<TData, TInput>,
|
||||
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
||||
cacheDuration: Duration;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
}
|
||||
|
||||
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
@@ -26,13 +28,33 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
|
||||
|
||||
return {
|
||||
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
|
||||
const channelData = await channel.getAsync();
|
||||
|
||||
const requestNewDataAsync = async () => {
|
||||
const data = await options.requestAsync(input);
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
return {
|
||||
data,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
try {
|
||||
const data = await options.requestAsync(input);
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
return {
|
||||
data,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (options.fallbackToStaleOnError && channelData) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Cached request handler using stale cache after fetch failure",
|
||||
{
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
return channelData;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (forceUpdate) {
|
||||
@@ -43,8 +65,6 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
|
||||
return await requestNewDataAsync();
|
||||
}
|
||||
|
||||
const channelData = await channel.getAsync();
|
||||
|
||||
const shouldRequestNewData =
|
||||
!channelData ||
|
||||
dayjs().diff(channelData.timestamp, "milliseconds") > options.cacheDuration.asMilliseconds();
|
||||
|
||||
@@ -111,6 +111,24 @@ describe("getAvailableUpdatesAsync", () => {
|
||||
// Assert
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should propagate GitHub API auth errors without blocking callers", async () => {
|
||||
const currentVersion = "v1.2.3";
|
||||
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
|
||||
listReleasesSpy.mockRejectedValue(new Error("Bad credentials"));
|
||||
|
||||
await expect(getAvailableUpdatesAsync(currentVersion)).rejects.toThrow("Bad credentials");
|
||||
});
|
||||
|
||||
test("should propagate GitHub API quota exhausted errors without blocking callers", async () => {
|
||||
const currentVersion = "v1.2.3";
|
||||
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
|
||||
listReleasesSpy.mockRejectedValue(
|
||||
new Error("Request quota exhausted for request GET /repos/{owner}/{repo}/releases"),
|
||||
);
|
||||
|
||||
await expect(getAvailableUpdatesAsync(currentVersion)).rejects.toThrow("quota exhausted");
|
||||
});
|
||||
});
|
||||
|
||||
const fakeReleases = (...inputs: [string, boolean][]) => inputs.map(fakeRelease);
|
||||
|
||||
@@ -15,7 +15,8 @@ const logger = createLogger({ module: "updateCheckerRequestHandler" });
|
||||
|
||||
export const updateCheckerRequestHandler = createCachedRequestHandler({
|
||||
queryKey: "homarr-update-checker",
|
||||
cacheDuration: dayjs.duration(1, "hour"),
|
||||
cacheDuration: dayjs.duration(1, "day"),
|
||||
fallbackToStaleOnError: true,
|
||||
async requestAsync(_) {
|
||||
return {
|
||||
availableUpdates: await getAvailableUpdatesAsync(packageJson.version),
|
||||
@@ -58,6 +59,7 @@ export const getAvailableUpdatesAsync = async (currentVersion: string) => {
|
||||
request: {
|
||||
fetch: fetchWithTrustedCertificatesAsync,
|
||||
},
|
||||
throttle: { enabled: false },
|
||||
});
|
||||
|
||||
const isCurrentPrerelease = isPrereleaseTag(currentVersion);
|
||||
@@ -100,7 +102,7 @@ export const getAvailableUpdatesAsync = async (currentVersion: string) => {
|
||||
const availableUpdates = semanticReleases
|
||||
.filter((release) => isCurrentPrerelease || !release.isPrerelease)
|
||||
.filter((release) => compareSemVer(release.tagName, currentVersion) > 0)
|
||||
.sort((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName));
|
||||
.toSorted((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName));
|
||||
|
||||
if (availableUpdates.length === 0) {
|
||||
logger.debug("No available updates found", { currentVersion });
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./src/context";
|
||||
export * from "./src/preferences/definitions";
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
@@ -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",
|
||||
|
||||
@@ -33,7 +33,10 @@ export const ChildrenActionItem = ({ childrenOptions, action, query, setChildren
|
||||
<Spotlight.Action
|
||||
renderRoot={renderRoot}
|
||||
onClick={onClick}
|
||||
closeSpotlightOnTrigger={interaction.type !== "children"}
|
||||
closeSpotlightOnTrigger={
|
||||
interaction.type !== "children" &&
|
||||
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
|
||||
}
|
||||
className={classes.spotlightAction}
|
||||
>
|
||||
<action.Component {...childrenOptions.option} />
|
||||
|
||||
@@ -44,6 +44,9 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
||||
setQuery(interaction.query);
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
} else if (interaction.type === "mode") {
|
||||
if (interaction.query !== undefined) {
|
||||
setQuery(interaction.query);
|
||||
}
|
||||
setMode(interaction.mode);
|
||||
} else if (interaction.type === "children") {
|
||||
setChildrenOptions(interaction);
|
||||
@@ -58,7 +61,8 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
||||
interaction.type !== "mode" &&
|
||||
interaction.type !== "children" &&
|
||||
interaction.type !== "none" &&
|
||||
interaction.type !== "setQuery"
|
||||
interaction.type !== "setQuery" &&
|
||||
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
|
||||
}
|
||||
className={classes.spotlightAction}
|
||||
>
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, 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 { useSetAtom } from "jotai";
|
||||
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { OpenMediaRequestSearchOptions } from "../index";
|
||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||
import type { SearchMode } from "../lib/mode";
|
||||
import { searchModes } from "../modes";
|
||||
import { useHomeEmptyGroups } from "../modes/help/home-empty-groups";
|
||||
import { selectAction, spotlightStore } from "../spotlight-store";
|
||||
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
|
||||
import {
|
||||
mediaRequestSearchEvent,
|
||||
mediaRequestSearchScopeAtom,
|
||||
selectAction,
|
||||
spotlightActions,
|
||||
spotlightStore,
|
||||
} from "../spotlight-store";
|
||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||
|
||||
type SearchModeKey = keyof TranslationObject["search"]["mode"];
|
||||
type ChildrenOptions = inferSearchInteractionOptions<"children">;
|
||||
|
||||
const defaultMode = "home";
|
||||
export const Spotlight = () => {
|
||||
@@ -31,7 +40,6 @@ export const Spotlight = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We use the "key" below to prevent the 'Different amounts of hooks' error
|
||||
return (
|
||||
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
|
||||
);
|
||||
@@ -46,13 +54,57 @@ interface SpotlightWithActiveModeProps {
|
||||
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
|
||||
const [query, setQuery] = queryState;
|
||||
const [mode, setMode] = modeState;
|
||||
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
||||
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
|
||||
const childrenOptions = childrenStack.at(-1) ?? null;
|
||||
const t = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Works as always the same amount of hooks are executed
|
||||
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
|
||||
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
||||
const groups = useGroups();
|
||||
const homeEmptyGroups = useHomeEmptyGroups();
|
||||
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
|
||||
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(() => {
|
||||
const handleMediaRequestSearch = (event: Event) => {
|
||||
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
|
||||
setMediaRequestSearchScope({ integrationIds });
|
||||
setMode("media");
|
||||
clearChildrenStack();
|
||||
setQuery(query ?? "");
|
||||
spotlightActions.open();
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
};
|
||||
|
||||
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||
return () => {
|
||||
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||
};
|
||||
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "media") {
|
||||
setMediaRequestSearchScope({});
|
||||
}
|
||||
}, [mode, setMediaRequestSearchScope]);
|
||||
|
||||
return (
|
||||
<MantineSpotlight.Root
|
||||
@@ -61,7 +113,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
|
||||
onSpotlightClose={() => {
|
||||
setMode(defaultMode);
|
||||
setChildrenOptions(null);
|
||||
clearChildrenStack();
|
||||
setQuery("");
|
||||
}}
|
||||
query={query}
|
||||
@@ -69,17 +121,14 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
|
||||
setQuery(sanitizedQuery);
|
||||
|
||||
// Only switch modes when a single trigger character is typed
|
||||
if (sanitizedQuery.length !== 1) return;
|
||||
|
||||
const modeToActivate = searchModes.find((mode) => mode.character === sanitizedQuery);
|
||||
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
|
||||
if (!modeToActivate) return;
|
||||
|
||||
setMode(modeToActivate.modeKey);
|
||||
setChildrenOptions(null);
|
||||
|
||||
clearChildrenStack();
|
||||
setQuery("");
|
||||
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}}
|
||||
store={spotlightStore}
|
||||
@@ -87,13 +136,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
<MantineSpotlight.Search
|
||||
placeholder={`${t("search.placeholder")}...`}
|
||||
ref={inputRef}
|
||||
leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48}
|
||||
leftSectionWidth={hasModeCharacter ? 80 : 48}
|
||||
leftSection={
|
||||
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
|
||||
<Center w={48} h="100%">
|
||||
<IconSearch stroke={1.5} />
|
||||
</Center>
|
||||
{activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||
</Group>
|
||||
}
|
||||
styles={{
|
||||
@@ -102,31 +151,53 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
},
|
||||
}}
|
||||
rightSection={
|
||||
mode === defaultMode ? null : (
|
||||
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();
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(defaultMode);
|
||||
setChildrenOptions(null);
|
||||
clearChildrenStack();
|
||||
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>
|
||||
)
|
||||
}
|
||||
value={query}
|
||||
onKeyDown={(event) => {
|
||||
if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") {
|
||||
if (query.length !== 0 || event.key !== "Backspace") return;
|
||||
|
||||
if (childrenStack.length > 0) {
|
||||
popChildrenOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== defaultMode) {
|
||||
setMode(defaultMode);
|
||||
setChildrenOptions(null);
|
||||
clearChildrenStack();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{childrenOptions ? (
|
||||
<Group>
|
||||
<Group key={childrenStack.length}>
|
||||
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
|
||||
</Group>
|
||||
) : null}
|
||||
@@ -134,25 +205,21 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
<MantineSpotlight.ActionsList>
|
||||
{childrenOptions ? (
|
||||
<SpotlightChildrenActions
|
||||
key={childrenStack.length}
|
||||
childrenOptions={childrenOptions}
|
||||
query={query}
|
||||
setChildrenOptions={setChildrenOptions}
|
||||
setChildrenOptions={pushChildrenOptions}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightActionGroups
|
||||
setQuery={setQuery}
|
||||
setMode={(mode) => {
|
||||
setMode(mode);
|
||||
setChildrenOptions(null);
|
||||
setMode={(nextMode) => {
|
||||
setMode(nextMode);
|
||||
clearChildrenStack();
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}}
|
||||
setChildrenOptions={(options) => {
|
||||
setChildrenOptions(options);
|
||||
|
||||
setTimeout(() => {
|
||||
setQuery("");
|
||||
selectAction(0, spotlightStore);
|
||||
});
|
||||
pushChildrenOptions(options);
|
||||
}}
|
||||
query={query}
|
||||
groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { spotlightActions } from "./spotlight-store";
|
||||
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
|
||||
|
||||
export { Spotlight } from "./components/spotlight";
|
||||
export { openSpotlight };
|
||||
export { openSpotlight, openMediaRequestSearch };
|
||||
export {
|
||||
SpotlightProvider,
|
||||
useRegisterSpotlightContextResults,
|
||||
@@ -11,3 +11,18 @@ export {
|
||||
} from "./modes/home/context";
|
||||
|
||||
const openSpotlight = spotlightActions.open;
|
||||
|
||||
export interface OpenMediaRequestSearchOptions {
|
||||
integrationIds?: string[];
|
||||
query?: string;
|
||||
}
|
||||
|
||||
const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
|
||||
}
|
||||
|
||||
spotlightActions.open();
|
||||
};
|
||||
|
||||
export { mediaRequestSearchEvent };
|
||||
@@ -10,9 +10,12 @@ const createSearchInteraction = <TType extends string>(type: TType) => ({
|
||||
// This is used to define search interactions with their options
|
||||
const searchInteractions = [
|
||||
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
|
||||
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
|
||||
createSearchInteraction("javaScript").optionsType<{
|
||||
onSelect: () => MaybePromise<void>;
|
||||
closeSpotlightOnTrigger?: boolean;
|
||||
}>(),
|
||||
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
|
||||
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(),
|
||||
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
|
||||
createSearchInteraction("children").optionsType<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
useActions: CreateChildrenOptionsProps<any>["useActions"];
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { Group, Text } from "@mantine/core";
|
||||
|
||||
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
||||
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
import { useUserPreference } from "../../../preferences/use-user-preference";
|
||||
import { LanguageIcon } from "@homarr/ui";
|
||||
|
||||
import { createChildrenOptions } from "../../../lib/children";
|
||||
import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./preferences/action-row";
|
||||
|
||||
export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({
|
||||
useActions: (_, query) => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const currentLocale = useCurrentLocale();
|
||||
const { value: currentLocale, setValue: setLocale, isPending } = useUserPreference("locale");
|
||||
|
||||
return supportedLanguages
|
||||
.map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
|
||||
.filter(
|
||||
@@ -18,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),
|
||||
@@ -29,38 +30,25 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
||||
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||
),
|
||||
)
|
||||
.map(({ localeKey, configuration }) => ({
|
||||
key: localeKey,
|
||||
Component() {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap">
|
||||
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{configuration.name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({configuration.translatedName})
|
||||
</Text>
|
||||
</Group>
|
||||
.map(({ localeKey, configuration }) =>
|
||||
createCheckmarkPreferenceAction({
|
||||
key: localeKey,
|
||||
isSelected: localeKey === currentLocale,
|
||||
onSelect: () => setLocale(localeKey as never),
|
||||
isPending,
|
||||
labelContent: (
|
||||
<Group wrap="nowrap">
|
||||
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{configuration.name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({configuration.translatedName})
|
||||
</Text>
|
||||
</Group>
|
||||
{localeKey === currentLocale && <IconCheck color="currentColor" size={24} />}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction() {
|
||||
const { changeLocale } = useChangeLocale();
|
||||
|
||||
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
|
||||
},
|
||||
}));
|
||||
},
|
||||
DetailComponent: () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack mx="md" my="sm">
|
||||
<Text>{t("search.mode.command.group.globalCommand.option.language.children.detail.title")}</Text>
|
||||
</Stack>
|
||||
);
|
||||
),
|
||||
}),
|
||||
);
|
||||
},
|
||||
DetailComponent: () => <PreferenceDetailHeader titleKey="locale.children.detail.title" />,
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Center, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { interaction } from "../../../../lib/interaction";
|
||||
|
||||
export const PreferenceDetailHeader = ({ titleKey }: { titleKey: string }) => {
|
||||
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
|
||||
const t = useScopedI18n("search.mode.command.group.preferences");
|
||||
|
||||
return (
|
||||
<Stack mx="md" my="sm" gap="xs">
|
||||
<Text>{tOption(titleKey as never)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("children.detail.backHint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const createCheckmarkPreferenceAction = ({
|
||||
key,
|
||||
label,
|
||||
labelContent,
|
||||
isSelected,
|
||||
onSelect,
|
||||
dimmed,
|
||||
isPending,
|
||||
}: {
|
||||
key: string;
|
||||
label?: string;
|
||||
labelContent?: ReactNode;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
dimmed?: boolean;
|
||||
isPending?: boolean;
|
||||
}) => ({
|
||||
key,
|
||||
Component() {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%" opacity={isPending ? 0.5 : 1}>
|
||||
{labelContent ?? <Text c={dimmed ? "dimmed" : undefined}>{label}</Text>}
|
||||
{isSelected && <IconCheck color="currentColor" size={24} />}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.javaScript(() => ({
|
||||
onSelect: isPending ? () => undefined : onSelect,
|
||||
closeSpotlightOnTrigger: false,
|
||||
})),
|
||||
});
|
||||
|
||||
export const createLoadingPreferenceAction = () => ({
|
||||
key: "loading",
|
||||
Component() {
|
||||
return (
|
||||
<Center mx="md" my="sm">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.javaScript(() => ({
|
||||
onSelect: () => undefined,
|
||||
closeSpotlightOnTrigger: false,
|
||||
})),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
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 {
|
||||
IconBox,
|
||||
IconCategoryPlus,
|
||||
IconDeviceDesktop,
|
||||
IconFileImport,
|
||||
IconLanguage,
|
||||
IconMailForward,
|
||||
IconMoon,
|
||||
IconPlug,
|
||||
IconSun,
|
||||
IconUserPlus,
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -22,7 +18,6 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
import { languageChildrenOptions } from "./children/language";
|
||||
import { newIntegrationChildrenOptions } from "./children/new-integration";
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
@@ -52,33 +47,9 @@ export const globalCommandGroup = createGroup<Command>({
|
||||
},
|
||||
useOptions() {
|
||||
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const computedColorScheme = useComputedColorScheme("light");
|
||||
const { data: session } = useSession();
|
||||
|
||||
const commands: (Command & { hidden?: boolean })[] = [
|
||||
{
|
||||
commandKey: "colorScheme",
|
||||
icon: colorScheme === "auto" ? IconDeviceDesktop : computedColorScheme === "dark" ? IconSun : IconMoon,
|
||||
name:
|
||||
colorScheme === "auto"
|
||||
? tOption("colorScheme.auto")
|
||||
: tOption(`colorScheme.${computedColorScheme === "dark" ? "light" : "dark"}`),
|
||||
useInteraction: () => {
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
onSelect: toggleColorScheme,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
commandKey: "language",
|
||||
icon: IconLanguage,
|
||||
name: tOption("language.label"),
|
||||
useInteraction: interaction.children(languageChildrenOptions),
|
||||
},
|
||||
{
|
||||
commandKey: "newBoard",
|
||||
icon: IconCategoryPlus,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { contextSpecificActionsSearchGroups } from "./context-specific-group";
|
||||
import { globalCommandGroup } from "./global-group";
|
||||
import { preferencesGroup } from "../preferences/groups";
|
||||
|
||||
export const commandMode = {
|
||||
modeKey: "command",
|
||||
character: ">",
|
||||
groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
|
||||
groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup],
|
||||
} satisfies SearchMode;
|
||||
@@ -64,6 +64,36 @@ type MediaRequestChildrenProps = {
|
||||
kind: IntegrationKind;
|
||||
url: string;
|
||||
id: string;
|
||||
permissions?: {
|
||||
hasInteractAccess: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const useMediaRequestSearchInteraction = (
|
||||
integration: MediaRequestChildrenProps["integration"],
|
||||
searchResult: FromIntegrationSearchResult,
|
||||
): inferSearchInteractionDefinition<"link" | "children"> => {
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
|
||||
const type = searchResult.type;
|
||||
if (type === "person") {
|
||||
return {
|
||||
type: "link",
|
||||
href: searchResult.link,
|
||||
newTab: openSearchInNewTab,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "children",
|
||||
...mediaRequestsChildrenOptions({
|
||||
result: {
|
||||
...searchResult,
|
||||
type,
|
||||
},
|
||||
integration,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -71,8 +101,6 @@ export const useFromIntegrationSearchInteraction = (
|
||||
searchEngine: SearchEngine,
|
||||
searchResult: FromIntegrationSearchResult,
|
||||
): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => {
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
|
||||
if (searchEngine.type !== "fromIntegration") {
|
||||
throw new Error("Invalid search engine type");
|
||||
}
|
||||
@@ -87,25 +115,7 @@ export const useFromIntegrationSearchInteraction = (
|
||||
) &&
|
||||
"type" in searchResult
|
||||
) {
|
||||
const type = searchResult.type;
|
||||
if (type === "person") {
|
||||
return {
|
||||
type: "link",
|
||||
href: searchResult.link,
|
||||
newTab: openSearchInNewTab,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "children",
|
||||
...mediaRequestsChildrenOptions({
|
||||
result: {
|
||||
...searchResult,
|
||||
type,
|
||||
},
|
||||
integration: searchEngine.integration,
|
||||
}),
|
||||
};
|
||||
return useMediaRequestSearchInteraction(searchEngine.integration, searchResult);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -115,13 +125,15 @@ export const useFromIntegrationSearchInteraction = (
|
||||
};
|
||||
};
|
||||
|
||||
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||
export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||
useActions() {
|
||||
const { openModal } = useModalAction(RequestMediaModal);
|
||||
return [
|
||||
{
|
||||
key: "request",
|
||||
hide: (option) => option.result.inLibrary,
|
||||
hide: (option) =>
|
||||
(option.result.type === "movie" && option.result.inLibrary) ||
|
||||
option.integration.permissions?.hasInteractAccess === false,
|
||||
Component(option) {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
return (
|
||||
|
||||
@@ -19,12 +19,13 @@ import type { SearchMode } from "../../lib/mode";
|
||||
import { appIntegrationBoardMode } from "../app-integration-board";
|
||||
import { commandMode } from "../command";
|
||||
import { externalMode } from "../external";
|
||||
import { mediaMode } from "../media";
|
||||
import { pageMode } from "../page";
|
||||
import { preferencesGroup } from "../preferences/groups";
|
||||
import { userGroupMode } from "../user-group";
|
||||
|
||||
// We need type to allow it as generic type for Record<string, unknown>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type QuickLinkOption = {
|
||||
key: string;
|
||||
icon: TablerIcon;
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -33,7 +34,7 @@ type QuickLinkOption = {
|
||||
export const useHomeEmptyGroups = () => {
|
||||
const { data: session } = useSession();
|
||||
const tPages = useScopedI18n("search.mode.page.group.page.option");
|
||||
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode];
|
||||
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode];
|
||||
|
||||
if (session?.user.permissions.includes("admin")) {
|
||||
visibleSearchModes.unshift(userGroupMode);
|
||||
@@ -41,19 +42,13 @@ export const useHomeEmptyGroups = () => {
|
||||
|
||||
return [
|
||||
createGroup({
|
||||
keyPath: "path",
|
||||
keyPath: "key",
|
||||
title: () => "Quick links",
|
||||
options: (() => {
|
||||
const quickLinks: QuickLinkOption[] = [];
|
||||
if (session?.user) {
|
||||
quickLinks.push({
|
||||
icon: IconSettings,
|
||||
path: `/manage/users/${session.user.id}/general`,
|
||||
name: tPages("preferences.label"),
|
||||
});
|
||||
}
|
||||
|
||||
quickLinks.push({
|
||||
key: "manageBoards",
|
||||
icon: IconLayoutDashboard,
|
||||
path: "/manage/boards",
|
||||
name: tPages("manageBoard.label"),
|
||||
@@ -61,6 +56,7 @@ export const useHomeEmptyGroups = () => {
|
||||
|
||||
if (session?.user.permissions.includes("admin")) {
|
||||
quickLinks.push({
|
||||
key: "manageSettings",
|
||||
icon: IconSettings,
|
||||
path: "/manage/settings",
|
||||
name: tPages("manageSettings.label"),
|
||||
@@ -69,6 +65,7 @@ export const useHomeEmptyGroups = () => {
|
||||
|
||||
if (session) {
|
||||
quickLinks.push({
|
||||
key: "manageSearchEngines",
|
||||
icon: IconSearch,
|
||||
path: "/manage/search-engines",
|
||||
name: tPages("manageSearchEngine.label"),
|
||||
@@ -87,7 +84,7 @@ export const useHomeEmptyGroups = () => {
|
||||
useInteraction: interaction.link(({ path }) => ({ href: path })),
|
||||
}),
|
||||
createGroup({
|
||||
keyPath: "character",
|
||||
keyPath: "modeKey",
|
||||
title: (t) => t("search.mode.help.group.mode.title"),
|
||||
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
|
||||
Component: ({ modeKey, character }) => {
|
||||
@@ -96,7 +93,7 @@ export const useHomeEmptyGroups = () => {
|
||||
return (
|
||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
|
||||
<Text>{t("help")}</Text>
|
||||
<Kbd size="sm">{character}</Kbd>
|
||||
{character && <Kbd size="sm">{character}</Kbd>}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -138,3 +135,5 @@ export const useHomeEmptyGroups = () => {
|
||||
}),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()];
|
||||
@@ -20,6 +20,7 @@ type GroupItem = {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon: TablerIcon | string;
|
||||
disabled?: boolean;
|
||||
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||
};
|
||||
|
||||
@@ -37,7 +38,7 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||
);
|
||||
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={item.disabled ? 0.55 : 1}>
|
||||
{icon}
|
||||
<Stack gap={0}>
|
||||
<Text>{item.name}</Text>
|
||||
@@ -91,9 +92,7 @@ const createDefaultSearchEntries = (
|
||||
query: string,
|
||||
t: TranslationFunction,
|
||||
): GroupItem[] => {
|
||||
if (!session?.user && !defaultSearchEngine) {
|
||||
return [];
|
||||
}
|
||||
if (!session?.user && !defaultSearchEngine) return [];
|
||||
|
||||
if (!defaultSearchEngine) {
|
||||
return [
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { preferencesGroup } from "../preferences/groups";
|
||||
import { contextSpecificSearchGroups } from "./context-specific-group";
|
||||
import { homeSearchEngineGroup } from "./home-search-engine-group";
|
||||
|
||||
export const homeMode = {
|
||||
character: undefined,
|
||||
modeKey: "home",
|
||||
groups: [homeSearchEngineGroup, contextSpecificSearchGroups],
|
||||
groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups],
|
||||
} satisfies SearchMode;
|
||||
@@ -2,6 +2,7 @@ import { appIntegrationBoardMode } from "./app-integration-board";
|
||||
import { commandMode } from "./command";
|
||||
import { externalMode } from "./external";
|
||||
import { homeMode } from "./home";
|
||||
import { mediaMode } from "./media";
|
||||
import { pageMode } from "./page";
|
||||
import { userGroupMode } from "./user-group";
|
||||
|
||||
@@ -11,5 +12,6 @@ export const searchModes = [
|
||||
externalMode,
|
||||
commandMode,
|
||||
pageMode,
|
||||
mediaMode,
|
||||
homeMode,
|
||||
] as const;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { mediaRequestSearchGroup } from "./media-request-search-group";
|
||||
|
||||
export const mediaMode = {
|
||||
modeKey: "media",
|
||||
character: undefined,
|
||||
groups: [mediaRequestSearchGroup],
|
||||
} satisfies SearchMode;
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Badge, Group, Image, Stack, Text } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconMovie, IconSearch } from "@tabler/icons-react";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import type { MediaAvailability } from "@homarr/integrations/types";
|
||||
import { mediaAvailabilityConfiguration } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
|
||||
import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group";
|
||||
|
||||
const availabilityLabelKeys = ["available", "partiallyAvailable", "processing", "requested", "pending"] as const;
|
||||
type AvailabilityWithLabel = (typeof availabilityLabelKeys)[number];
|
||||
|
||||
type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequests"][number];
|
||||
|
||||
type MediaRequestSearchOption =
|
||||
| {
|
||||
key: string;
|
||||
kind: "disabled" | "hint";
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
kind: "result";
|
||||
result: MediaRequestSearchResult;
|
||||
};
|
||||
|
||||
export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
||||
keyPath: "key",
|
||||
title: (t) => t("search.mode.media.group.title"),
|
||||
Component(option) {
|
||||
const tMedia = useScopedI18n("search.mode.media");
|
||||
|
||||
if (option.kind !== "result") {
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.kind === "disabled" ? 0.55 : 1}>
|
||||
{option.kind === "disabled" ? <IconMovie stroke={1.5} /> : <IconSearch stroke={1.5} />}
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
<Text size="xs" c="gray.6">
|
||||
{option.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = option;
|
||||
const hasAvailabilityLabel =
|
||||
result.availability && (availabilityLabelKeys as readonly string[]).includes(result.availability);
|
||||
const badgeLabel = hasAvailabilityLabel
|
||||
? tMedia(`availability.${result.availability as AvailabilityWithLabel}`)
|
||||
: undefined;
|
||||
const badgeColor = result.availability
|
||||
? mediaAvailabilityConfiguration[result.availability as MediaAvailability]?.color
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||
{result.image ? (
|
||||
<Image src={result.image} w={35} h={50} fit="cover" radius="md" />
|
||||
) : (
|
||||
<IconSearch stroke={1.5} size={35} />
|
||||
)}
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text>{result.name}</Text>
|
||||
{badgeLabel && (
|
||||
<Badge size="xs" color={badgeColor} variant="light">
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm" lineClamp={2}>
|
||||
{result.text ?? getIntegrationName(result.integration.kind)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction(option) {
|
||||
if (option.kind !== "result") {
|
||||
return {
|
||||
type: "none",
|
||||
};
|
||||
}
|
||||
|
||||
return useMediaRequestSearchInteraction(option.result.integration, option.result);
|
||||
},
|
||||
useQueryOptions(query) {
|
||||
const tMedia = useScopedI18n("search.mode.media");
|
||||
const scope = useAtomValue(mediaRequestSearchScopeAtom);
|
||||
const [debouncedQuery] = useDebouncedValue(query.trim(), 150);
|
||||
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery();
|
||||
|
||||
const scopedTargets = (targetsQuery.data ?? []).filter((target) =>
|
||||
scope.integrationIds ? scope.integrationIds.includes(target.id) : true,
|
||||
);
|
||||
const hasScopedTargets = scopedTargets.length > 0;
|
||||
const shouldSearch = debouncedQuery.length > 0 && hasScopedTargets;
|
||||
const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined;
|
||||
|
||||
const resultsQuery = clientApi.integration.searchMediaRequests.useQuery(
|
||||
{ query: debouncedQuery, integrationIds },
|
||||
{
|
||||
enabled: shouldSearch,
|
||||
placeholderData: keepPreviousData,
|
||||
select: (data) => data.slice(0, 10),
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading);
|
||||
const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError);
|
||||
|
||||
if (!targetsQuery.data) {
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasScopedTargets) {
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: [
|
||||
{
|
||||
key: "disabled",
|
||||
kind: "disabled",
|
||||
label: tMedia("action.search.label"),
|
||||
description: scope.integrationIds
|
||||
? tMedia("action.search.disabled.scoped")
|
||||
: tMedia("action.search.disabled.noIntegration"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (debouncedQuery.length === 0) {
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: [
|
||||
{
|
||||
key: "hint",
|
||||
kind: "hint",
|
||||
label: tMedia("action.search.label"),
|
||||
description: tMedia("action.search.description"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: (resultsQuery.data ?? []).map((result) => ({
|
||||
key: `result-${result.integration.id}-${result.type}-${result.id}`,
|
||||
kind: "result",
|
||||
result,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -3,9 +3,17 @@
|
||||
import { clamp } from "@mantine/hooks";
|
||||
import type { SpotlightStore } from "@mantine/spotlight";
|
||||
import { createSpotlight } from "@mantine/spotlight";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const [spotlightStore, spotlightActions] = createSpotlight();
|
||||
|
||||
export interface MediaRequestSearchScope {
|
||||
integrationIds?: string[];
|
||||
}
|
||||
|
||||
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
|
||||
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
|
||||
|
||||
export const setSelectedAction = (index: number, store: SpotlightStore) => {
|
||||
store.updateState((state) => ({ ...state, selected: index }));
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -132,7 +132,8 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Contrasenya",
|
||||
"requirement": {
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Inclou com a mínim 8 caràcters",
|
||||
"lowercase": "Inclou minúscules",
|
||||
"uppercase": "Inclou majúscules",
|
||||
@@ -1054,6 +1055,9 @@
|
||||
"open": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noResults": {
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1084,7 +1088,9 @@
|
||||
"checkoutDocs": "",
|
||||
"checkLogs": "",
|
||||
"tryAgain": "",
|
||||
"loading": ""
|
||||
"loading": "",
|
||||
"show": "",
|
||||
"hide": ""
|
||||
},
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
@@ -1249,7 +1255,8 @@
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "",
|
||||
"changePosition": ""
|
||||
"changePosition": "",
|
||||
"settings": ""
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
@@ -1399,6 +1406,9 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
@@ -3452,6 +3462,9 @@
|
||||
"public": "",
|
||||
"private": ""
|
||||
},
|
||||
"noResults": {
|
||||
"title": ""
|
||||
},
|
||||
"modal": {
|
||||
"createBoard": {
|
||||
"field": {
|
||||
|
||||
@@ -132,7 +132,8 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "密码",
|
||||
"requirement": {
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "至少包含 8 个字符",
|
||||
"lowercase": "包含小写字母",
|
||||
"uppercase": "包含大写字母",
|
||||
@@ -1054,6 +1055,9 @@
|
||||
"open": {
|
||||
"label": "打开媒体"
|
||||
}
|
||||
},
|
||||
"noResults": {
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1084,7 +1088,9 @@
|
||||
"checkoutDocs": "查阅文档",
|
||||
"checkLogs": "检查日志获取更多详情",
|
||||
"tryAgain": "重试",
|
||||
"loading": "正在加载"
|
||||
"loading": "正在加载",
|
||||
"show": "",
|
||||
"hide": ""
|
||||
},
|
||||
"here": "这里",
|
||||
"iconPicker": {
|
||||
@@ -1249,7 +1255,8 @@
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "新建分类",
|
||||
"changePosition": "换位"
|
||||
"changePosition": "换位",
|
||||
"settings": ""
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
@@ -1399,6 +1406,9 @@
|
||||
"openNewTab": {
|
||||
"label": "在新标签页中打开"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "书签",
|
||||
"add": "添加书签"
|
||||
@@ -3452,6 +3462,9 @@
|
||||
"public": "此面板是公开的",
|
||||
"private": "此面板是私有的"
|
||||
},
|
||||
"noResults": {
|
||||
"title": ""
|
||||
},
|
||||
"modal": {
|
||||
"createBoard": {
|
||||
"field": {
|
||||
|
||||
@@ -132,12 +132,13 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "crwdns26:0crwdne26:0",
|
||||
"requirement": {
|
||||
"length": "crwdns28:0crwdne28:0",
|
||||
"lowercase": "crwdns30:0crwdne30:0",
|
||||
"uppercase": "crwdns32:0crwdne32:0",
|
||||
"number": "crwdns34:0crwdne34:0",
|
||||
"special": "crwdns36:0crwdne36:0"
|
||||
"suggestionsTitle": "crwdns4261:0crwdne4261:0",
|
||||
"suggestion": {
|
||||
"length": "crwdns4263:0crwdne4263:0",
|
||||
"lowercase": "crwdns4265:0crwdne4265:0",
|
||||
"uppercase": "crwdns4267:0crwdne4267:0",
|
||||
"number": "crwdns4269:0crwdne4269:0",
|
||||
"special": "crwdns4271:0crwdne4271:0"
|
||||
}
|
||||
},
|
||||
"passwordConfirm": {
|
||||
@@ -1054,6 +1055,9 @@
|
||||
"open": {
|
||||
"label": "crwdns2680:0crwdne2680:0"
|
||||
}
|
||||
},
|
||||
"noResults": {
|
||||
"title": "crwdns4251:0crwdne4251:0"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1084,7 +1088,9 @@
|
||||
"checkoutDocs": "crwdns546:0crwdne546:0",
|
||||
"checkLogs": "crwdns2260:0crwdne2260:0",
|
||||
"tryAgain": "crwdns548:0crwdne548:0",
|
||||
"loading": "crwdns550:0crwdne550:0"
|
||||
"loading": "crwdns550:0crwdne550:0",
|
||||
"show": "crwdns4255:0crwdne4255:0",
|
||||
"hide": "crwdns4257:0crwdne4257:0"
|
||||
},
|
||||
"here": "crwdns552:0crwdne552:0",
|
||||
"iconPicker": {
|
||||
@@ -1249,7 +1255,8 @@
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "crwdns686:0crwdne686:0",
|
||||
"changePosition": "crwdns688:0crwdne688:0"
|
||||
"changePosition": "crwdns688:0crwdne688:0",
|
||||
"settings": "crwdns4259:0crwdne4259:0"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
@@ -1399,6 +1406,9 @@
|
||||
"openNewTab": {
|
||||
"label": "crwdns2686:0crwdne2686:0"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": "crwdns4273:0crwdne4273:0"
|
||||
},
|
||||
"items": {
|
||||
"label": "crwdns764:0crwdne764:0",
|
||||
"add": "crwdns766:0crwdne766:0"
|
||||
@@ -3452,6 +3462,9 @@
|
||||
"public": "crwdns1656:0crwdne1656:0",
|
||||
"private": "crwdns1658:0crwdne1658:0"
|
||||
},
|
||||
"noResults": {
|
||||
"title": "crwdns4253:0crwdne4253:0"
|
||||
},
|
||||
"modal": {
|
||||
"createBoard": {
|
||||
"field": {
|
||||
|
||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user