Merge remote-tracking branch 'origin/dev' into feat/pr-6510-polish

This commit is contained in:
ajnart committed 2026-08-22 10:47:15 +02:00
commit 3f9941f9c7
153 files changed
+31523 -27879

No files matched your search

@@ -19,130 +19,106 @@ import {
import { useDisclosure } from "@mantine/hooks";
import { IconX } from "@tabler/icons-react";
import { useZodForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import type { Board } from "../../_types";
import { SectionCard } from "~/components/manage/section-card";
import { generateColors } from "../../(content)/_theme";
import { useSavePartialSettingsMutation } from "./_shared";
import type { FormValues } from "./_settings-form";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
const hexRegex = /^#[0-9a-fA-F]{6}$/;
const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ board }: Props) => {
const form = useZodForm(boardSavePartialSettingsSchema, {
initialValues: {
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
iconColor: board.iconColor ?? "",
itemRadius: board.itemRadius,
},
});
export const ColorSettingsContent = ({ form }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n();
const theme = useMantineTheme();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<ColorInput
label={t("board.field.primaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("primaryColor")}
/>
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<SectionCard title={t("board.setting.section.appearance.title")}>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<ColorInput
label={t("board.field.secondaryColor.label")}
label={t("board.field.primaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("secondaryColor")}
{...form.getInputProps("primaryColor")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse expanded={showPreview}>
<Stack>
<ColorsPreview previewColor={form.values.primaryColor} />
<ColorsPreview previewColor={form.values.secondaryColor} />
</Stack>
</Collapse>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={t("board.field.opacity.label")}>
<Slider
my={6}
min={0}
max={100}
step={5}
label={progressPercentageLabel}
{...form.getInputProps("opacity")}
/>
</InputWrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end">
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
{...form.getInputProps("iconColor")}
/>
<Button
variant="subtle"
leftSection={<IconX />}
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<ColorInput
label={t("board.field.secondaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("secondaryColor")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse expanded={showPreview}>
<Stack>
<ColorsPreview previewColor={form.values.primaryColor} />
<ColorsPreview previewColor={form.values.secondaryColor} />
</Stack>
</Collapse>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={t("board.field.opacity.label")}>
<Slider
my={6}
min={0}
max={100}
step={5}
label={progressPercentageLabel}
{...form.getInputProps("opacity")}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
</InputWrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end">
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
{...form.getInputProps("iconColor")}
/>
<Button
type="button"
variant="subtle"
leftSection={<IconX />}
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
/>
</Grid.Col>
</Grid>
</SectionCard>
);
};
@@ -1,41 +1,32 @@
"use client";
import { startTransition } from "react";
import { ActionIcon, Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { UploadMedia } from "@homarr/forms-collection";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
import { SelectWithDescriptionBadge } from "@homarr/ui";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./_settings-form";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
export const BackgroundSettingsContent = ({ board }: Props) => {
export const BackgroundSettingsContent = ({ form }: Props) => {
const t = useI18n();
const { data: session } = useSession();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useZodForm(boardSavePartialSettingsSchema, {
initialValues: {
backgroundImageUrl: board.backgroundImageUrl ?? "",
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
},
});
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl, 200);
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
const medias = clientApi.media.getPaginated.useQuery({
page: 1,
pageSize: 10,
@@ -53,121 +44,106 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={12}>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<Autocomplete
flex={1}
leftSection={
form.values.backgroundImageUrl &&
form.values.backgroundImageUrl.trim().length >= 2 && (
<Popover width={300} withArrow>
<Popover.Target>
<Center h="100%">
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
</Center>
</Popover.Target>
<Popover.Dropdown>
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
</Popover.Dropdown>
</Popover>
)
}
// We filter it on the server
filter={({ options }) => options}
label={t("board.field.backgroundImageUrl.label")}
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => {
const current = imageMap.get(option.value);
if (!current) return null;
<SectionCard title={t("board.setting.section.background.title")}>
<Grid>
<Grid.Col span={12}>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<Autocomplete
flex={1}
leftSection={
form.values.backgroundImageUrl &&
form.values.backgroundImageUrl.trim().length >= 2 && (
<Popover width={300} withArrow>
<Popover.Target>
<Center h="100%">
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
</Center>
</Popover.Target>
<Popover.Dropdown>
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
</Popover.Dropdown>
</Popover>
)
}
// We filter it on the server
filter={({ options }) => options}
label={t("board.field.backgroundImageUrl.label")}
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => {
const current = imageMap.get(option.value);
if (!current) return null;
return (
<Group gap="sm">
<ImagePreview src={option.value} w={20} h={20} />
<Stack gap={0}>
<Text size="sm">{current.name}</Text>
<Text size="xs" c="dimmed">
{option.value}
</Text>
</Stack>
</Group>
);
return (
<Group gap="sm">
<ImagePreview src={option.value} w={20} h={20} />
<Stack gap={0}>
<Text size="sm">{current.name}</Text>
<Text size="xs" c="dimmed">
{option.value}
</Text>
</Stack>
</Group>
);
}}
data={[
{
group: t("board.field.backgroundImageUrl.group.your"),
items: images
.filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
{
group: t("board.field.backgroundImageUrl.group.other"),
items: images
.filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
]}
{...form.getInputProps("backgroundImageUrl")}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(uploadedMedias) => {
const first = uploadedMedias.at(0);
if (!first) return;
startTransition(() => {
form.setFieldValue("backgroundImageUrl", first.url);
});
}}
data={[
{
group: t("board.field.backgroundImageUrl.group.your"),
items: images
.filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
{
group: t("board.field.backgroundImageUrl.group.other"),
items: images
.filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
]}
{...form.getInputProps("backgroundImageUrl")}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
startTransition(() => {
form.setFieldValue("backgroundImageUrl", first.url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
</UploadMedia>
)}
</Group>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageSize.label")}
data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
</UploadMedia>
)}
</Group>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageSize.label")}
data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")}
/>
</Grid.Col>
</Grid>
</SectionCard>
);
};
@@ -1,48 +1,27 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import { Switch } from "@mantine/core";
import { useForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./_settings-form";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
export const BehaviorSettingsContent = ({ board }: Props) => {
export const BehaviorSettingsContent = ({ form }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
disableStatus: board.disableStatus,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Switch
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
<SectionCard title={t("board.setting.section.behavior.title")}>
<Switch
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
</SectionCard>
);
};
@@ -1,6 +1,6 @@
"use client";
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
import { Alert, Input } from "@mantine/core";
import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor";
@@ -8,59 +8,39 @@ import "~/styles/prismjs.scss";
import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./_settings-form";
import classes from "./customcss.module.css";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
export const CustomCssSettingsContent = ({ board }: Props) => {
export const CustomCssSettingsContent = ({ form }: Props) => {
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
customCss: board.customCss ?? "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<CustomCssInput {...form.getInputProps("customCss")} />
<SectionCard title={t("board.setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
</Alert>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
</Alert>
</SectionCard>
);
};
interface CustomCssInputProps {
value?: string;
onChange: (value: string) => void;
error?: React.ReactNode;
}
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss");
return (
@@ -70,6 +50,7 @@ const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
htmlFor: "custom-css",
}}
description={customCssT("description")}
error={error}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.codeEditorRoot}>
@@ -2,7 +2,7 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -10,7 +10,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import classes from "./danger.module.css";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
const board = useRequiredBoard();
@@ -82,60 +82,36 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
}, [board.id, deleteBoard, router, t, openConfirmModal]);
return (
<Stack gap="sm">
<Divider />
<DangerZoneRow
<DangerZoneRoot>
<DangerZoneItem
label={t("section.dangerZone.action.rename.label")}
description={t("section.dangerZone.action.rename.description")}
buttonText={t("section.dangerZone.action.rename.button")}
onClick={onRenameClick}
action={
<Button variant="subtle" color="red" onClick={onRenameClick}>
{t("section.dangerZone.action.rename.button")}
</Button>
}
/>
{hideVisibility ? null : (
<>
<Divider />
<DangerZoneRow
label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
onClick={onVisibilityClick}
isPending={isChangeVisibilityPending}
/>
</>
{!hideVisibility && (
<DangerZoneItem
label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
action={
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
</Button>
}
/>
)}
<Divider />
<DangerZoneRow
<DangerZoneItem
label={t("section.dangerZone.action.delete.label")}
description={t("section.dangerZone.action.delete.description")}
buttonText={t("section.dangerZone.action.delete.button")}
onClick={onDeleteClick}
isPending={isDeletePending}
action={
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
{t("section.dangerZone.action.delete.button")}
</Button>
}
/>
</Stack>
);
};
interface DangerZoneRowProps {
label: string;
description: string;
buttonText: string;
isPending?: boolean;
onClick: () => void;
}
const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
return (
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
<Stack gap={0}>
<Text fw="bold" size="sm">
{label}
</Text>
<Text size="sm">{description}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
{buttonText}
</Button>
</Group>
</Group>
</DangerZoneRoot>
);
};
@@ -1,128 +1,66 @@
"use client";
import { useEffect, useRef } from "react";
import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core";
import { useEffect } from "react";
import { Grid, Loader, TextInput } from "@mantine/core";
import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks";
import { useUpdateBoard } from "@homarr/boards/updater";
import { useZodForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { SectionCard } from "~/components/manage/section-card";
import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import type { FormValues } from "./_settings-form";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
export const GeneralSettingsContent = ({ board }: Props) => {
export const GeneralSettingsContent = ({ board, form }: Props) => {
const t = useI18n();
const ref = useRef({
pageTitle: board.pageTitle,
logoImageUrl: board.logoImageUrl,
});
const { updateBoard } = useUpdateBoard();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useZodForm(
boardSavePartialSettingsSchema
.pick({
pageTitle: true,
logoImageUrl: true,
metaTitle: true,
faviconImageUrl: true,
})
.required(),
{
initialValues: {
pageTitle: board.pageTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
metaTitle: board.metaTitle ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
},
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
},
);
useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
// Cleanup for not applied changes of the page title and logo image URL
useEffect(() => {
return () => {
updateBoard((previous) => ({
...previous,
pageTitle: ref.current.pageTitle,
logoImageUrl: ref.current.logoImageUrl,
}));
};
}, [updateBoard]);
return (
<form
onSubmit={form.onSubmit((values) => {
// Save the current values to the ref so that it does not reset if the form is submitted
ref.current = {
pageTitle: values.pageTitle,
logoImageUrl: values.logoImageUrl,
};
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.pageTitle.label")}
placeholder="Homarr"
{...form.getInputProps("pageTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.metaTitle.label")}
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("logoImageUrl")}
label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("faviconImageUrl")}
label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
<SectionCard title={t("board.setting.section.general.title")}>
<Grid>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.pageTitle.label")}
placeholder="Homarr"
{...form.getInputProps("pageTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.metaTitle.label")}
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("logoImageUrl")}
label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("faviconImageUrl")}
label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
</Grid>
</SectionCard>
);
};
@@ -146,14 +84,3 @@ const useMetaTitlePreview = (title: string | null) => {
isPending: (title ?? "") !== metaTitleDebounced,
};
};
// Previously we used the useFavicon hook from Mantine
// However if the user tried to click on a link to a different page
// it caused an error "Cannot read properties of null (reading 'removeChild')"
// Probably because the head element was null (https://github.com/mantinedev/mantine/blob/b90d9b81031f45f5d15e75f25138ed6477f65bce/packages/%40mantine/hooks/src/use-favicon/use-favicon.ts#L21)
// See https://github.com/homarr-labs/homarr/issues/4905
const updateFavicon = (url: string) => {
const link: HTMLLinkElement | null = document.querySelector('link[rel="icon"]');
if (!link) return;
link.href = url;
};
@@ -2,114 +2,79 @@
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { useZodForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
import type { Board } from "../../_types";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./_settings-form";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
export const LayoutSettingsContent = ({ board }: Props) => {
export const LayoutSettingsContent = ({ form }: Props) => {
const t = useI18n();
const utils = clientApi.useUtils();
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
initialValues: {
layouts: board.layouts,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
saveLayouts({
id: board.id,
...values,
});
})}
>
<Stack>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
<Button
variant="subtle"
onClick={() => {
form.setValues({
layouts: [
...form.values.layouts,
{
id: createId(),
name: "",
columnCount: 10,
breakpoint: 0,
},
],
});
}}
>
{t("board.setting.section.layout.responsive.action.add")}
</Button>
</Group>
{form.values.layouts.map((layout, index) => (
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
description={t("layout.field.breakpoint.description")}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
<Button
variant="subtle"
onClick={() => {
form.setValues((previous) =>
previous.layouts !== undefined && previous.layouts.length >= 2
? {
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}
: previous,
);
}}
>
{t("common.action.remove")}
</Button>
</Group>
)}
</Fieldset>
))}
</Stack>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
<SectionCard title={t("board.setting.section.layout.title")}>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
<Button
type="button"
variant="subtle"
onClick={() => {
form.setValues((previous) => ({
...previous,
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }],
}));
}}
>
{t("board.setting.section.layout.responsive.action.add")}
</Button>
</Group>
{form.values.layouts.map((layout, index) => (
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
description={t("layout.field.breakpoint.description")}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
<Button
type="button"
variant="subtle"
onClick={() => {
form.setValues((previous) => ({
...previous,
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}));
}}
>
{t("common.action.remove")}
</Button>
</Group>
)}
</Fieldset>
))}
</Stack>
</form>
</SectionCard>
);
};
@@ -0,0 +1,219 @@
"use client";
import { useEffect, useRef } from "react";
import { Button, Stack } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { useUpdateBoard } from "@homarr/boards/updater";
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 { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { SectionCard } from "~/components/manage/section-card";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import type { Board } from "../../_types";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access";
import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
const boardSettingsFormSchema = boardSavePartialSettingsSchema
.extend({
layouts: boardSaveLayoutsSchema.shape.layouts,
})
.required();
export type FormValues = z.infer<typeof boardSettingsFormSchema>;
const PARTIAL_FORM_KEYS = [
"pageTitle",
"metaTitle",
"logoImageUrl",
"faviconImageUrl",
"backgroundImageUrl",
"backgroundImageAttachment",
"backgroundImageRepeat",
"backgroundImageSize",
"primaryColor",
"secondaryColor",
"opacity",
"iconColor",
"itemRadius",
"customCss",
"disableStatus",
] as const;
const buildInitialValues = (board: Board): FormValues => ({
pageTitle: board.pageTitle ?? "",
metaTitle: board.metaTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
backgroundImageUrl: board.backgroundImageUrl ?? "",
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
iconColor: board.iconColor ?? "",
itemRadius: board.itemRadius,
customCss: board.customCss ?? "",
disableStatus: board.disableStatus,
layouts: board.layouts,
});
interface BoardSettingsFormProps {
board: Board;
permissions: RouterOutputs["board"]["getBoardPermissions"];
hasFullAccess: boolean;
hideVisibility: boolean;
}
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
const t = useI18n();
const tSection = useScopedI18n("board.setting.section");
const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board);
const form = useZodForm(boardSettingsFormSchema, {
initialValues: buildInitialValues(board),
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
});
const initialValuesRef = useRef(buildInitialValues(board));
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
const isDirtyRef = useRef(false);
isDirtyRef.current = form.isDirty();
useEffect(() => {
return () => {
updateBoard((previous) => ({
...previous,
pageTitle: lastSavedRef.current.pageTitle,
logoImageUrl: lastSavedRef.current.logoImageUrl,
}));
};
}, [updateBoard]);
useEffect(() => {
const handler = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
if (isDirtyRef.current) event.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, []);
const handleSubmitAsync = async (values: FormValues) => {
const defaults = initialValuesRef.current;
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
fields.some((field) => values[field] !== defaults[field]);
const { layouts, ...partialSettings } = values;
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
{
when: changed(...PARTIAL_FORM_KEYS),
action: () =>
savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
}),
},
{
when: changed("layouts"),
action: () => saveLayouts.mutateAsync({ id: board.id, layouts }),
},
];
const promises = saveActions.filter((saveAction) => saveAction.when).map((saveAction) => saveAction.action());
if (promises.length === 0) return;
try {
await Promise.all(promises);
lastSavedRef.current = { pageTitle: values.pageTitle, logoImageUrl: values.logoImageUrl };
initialValuesRef.current = values;
form.setInitialValues(values);
form.resetDirty();
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
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();
updateBoard((previous) => ({
...previous,
pageTitle: lastSavedRef.current.pageTitle,
logoImageUrl: lastSavedRef.current.logoImageUrl,
}));
};
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="xl">
<GeneralSettingsContent board={board} form={form} />
<LayoutSettingsContent form={form} />
<BackgroundSettingsContent form={form} />
<ColorSettingsContent form={form} />
<CustomCssSettingsContent form={form} />
<BehaviorSettingsContent form={form} />
{hasFullAccess && (
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
)}
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="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>
</UnsavedChangesBar>
)}
</Stack>
</form>
);
};
// Previously part of the general settings section; applied on the unified save
// https://github.com/homarr-labs/homarr/issues/4905
const updateFavicon = (url: string) => {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) return;
link.href = url;
};
@@ -11,3 +11,13 @@ export const useSavePartialSettingsMutation = (board: Board) => {
},
});
};
export const useSaveLayoutsMutation = (board: Board) => {
const utils = clientApi.useUtils();
return clientApi.board.saveLayouts.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
};
@@ -1,5 +0,0 @@
@media (min-width: 36em) {
.dangerZoneGroup {
--group-wrap: nowrap !important;
}
}
@@ -1,44 +1,20 @@
import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation";
import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
import {
IconAlertTriangle,
IconBrush,
IconClick,
IconFileTypeCss,
IconLayout,
IconPhoto,
IconSettings,
IconUser,
} from "@tabler/icons-react";
import { Container, Stack, Title } from "@mantine/core";
import { TRPCError } from "@trpc/server";
import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access";
import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
import { BoardSettingsForm } from "./_settings-form";
interface Props {
params: Promise<{
name: string;
}>;
searchParams: Promise<{
tab?: keyof TranslationObject["board"]["setting"]["section"];
}>;
}
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
@@ -57,11 +33,7 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
} catch (error) {
// Ignore not found errors and redirect to 404
// error is already logged in _layout-creator.tsx
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
notFound();
}
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
notFound();
}
@@ -70,7 +42,6 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
};
export default async function BoardSettingsPage(props: Props) {
const searchParams = await props.searchParams;
const params = await props.params;
const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board");
@@ -85,76 +56,13 @@ export default async function BoardSettingsPage(props: Props) {
<Container>
<Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
<AccordionItemFor value="general" icon={IconSettings}>
<GeneralSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="layout" icon={IconLayout}>
<LayoutSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="background" icon={IconPhoto}>
<BackgroundSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="appearance" icon={IconBrush}>
<ColorSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="behavior" icon={IconClick}>
<BehaviorSettingsContent board={board} />
</AccordionItemFor>
{hasFullAccess && (
<>
<AccordionItemFor value="access" icon={IconUser}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent
hideVisibility={
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
}
/>
</AccordionItemFor>
</>
)}
</ActiveTabAccordion>
<BoardSettingsForm
board={board}
permissions={permissions}
hasFullAccess={hasFullAccess}
hideVisibility={boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id}
/>
</Stack>
</Container>
);
}
type AccordionItemForProps = PropsWithChildren<{
value: keyof TranslationObject["board"]["setting"]["section"];
icon: TablerIcon;
danger?: boolean;
noPadding?: boolean;
}>;
const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => {
const t = await getScopedI18n("board.setting.section");
return (
<AccordionItem
value={value}
styles={
danger
? {
item: {
"--__item-border-color": "rgba(248,81,73,0.4)",
borderWidth: 4,
},
}
: undefined
}
>
<AccordionControl icon={<Icon />}>
<Text fw="bold" size="lg">
{t(`${value}.title`)}
</Text>
</AccordionControl>
<AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}>
{children}
</AccordionPanel>
</AccordionItem>
);
};
@@ -697,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<Stack gap="sm">
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
<IconPicker
withAsterisk={false}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
</Stack>
</Fieldset>
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/>
</Grid.Col>
</Grid>
<IconPicker {...form.getInputProps("iconUrl")} />
<IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl
@@ -1,53 +1,22 @@
"use client";
import React from "react";
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
import { SwitchSetting } from "./setting-switch";
interface AnalyticsSettingsProps {
initialData: typeof defaultServerSettings.analytics;
form: UseFormReturnType<FormValues>;
}
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.analytics");
const { instanceId: _, ...formDefaults } = initialData;
const form = useForm({
initialValues: formDefaults,
onValuesChange: (updatedValues, _prev) => {
if (!form.isValid()) {
return;
}
void mutateAsync({
settingsKey: "analytics",
value: updatedValues,
});
},
});
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/settings");
},
});
return (
<>
<Title order={2}>{t("title")}</Title>
<Card pos="relative">
<LoadingOverlay visible={isPending} />
<Stack>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
</Stack>
</Card>
</>
<SectionCard title={t("title")}>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
</SectionCard>
);
};
@@ -5,31 +5,32 @@ import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import type { ServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { CommonSettingsForm } from "./common-form";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => {
const tApperance = useScopedI18n("management.page.settings.section.appearance");
interface AppearanceSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => {
const tAppearance = useScopedI18n("management.page.settings.section.appearance");
return (
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
{(form) => (
<>
<SelectWithCustomItems
label={tApperance("defaultColorScheme.label")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tApperance(`defaultColorScheme.options.${scheme}`),
}))}
{...form.getInputProps("defaultColorScheme")}
SelectOption={ApperanceCustomOption}
/>
</>
)}
</CommonSettingsForm>
<SectionCard title={tAppearance("title")}>
<SelectWithCustomItems
label={tAppearance("defaultColorScheme.label")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tAppearance(`defaultColorScheme.options.${scheme}`),
}))}
{...form.getInputProps("defaultColorScheme")}
SelectOption={AppearanceCustomOption}
/>
</SectionCard>
);
};
@@ -39,7 +40,7 @@ const appearanceIcons = {
dark: IconMoon,
};
const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const AppearanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value];
return (
@@ -3,49 +3,50 @@
import { Switch, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardSelect } from "~/components/board/board-select";
import { CommonSettingsForm } from "./common-form";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
interface BoardSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
const tBoard = useScopedI18n("management.page.settings.section.board");
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
return (
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
{(form) => (
<>
<BoardSelect
label={tBoard("homeBoard.label")}
description={tBoard("homeBoard.description")}
clearable
boards={selectableBoards}
{...form.getInputProps("homeBoardId")}
/>
<SectionCard title={tBoard("title")}>
<BoardSelect
label={tBoard("homeBoard.label")}
description={tBoard("homeBoard.description")}
clearable
boards={selectableBoards}
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={tBoard("homeBoard.mobileLabel")}
description={tBoard("homeBoard.description")}
clearable
boards={selectableBoards}
{...form.getInputProps("mobileHomeBoardId")}
/>
<BoardSelect
label={tBoard("homeBoard.mobileLabel")}
description={tBoard("homeBoard.description")}
clearable
boards={selectableBoards}
{...form.getInputProps("mobileHomeBoardId")}
/>
<Text fw={500}>{tBoard("status.title")}</Text>
<Switch
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
label={tBoard("status.enableStatusByDefault.label")}
description={tBoard("status.enableStatusByDefault.description")}
/>
<Switch
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
label={tBoard("status.forceDisableStatus.label")}
description={tBoard("status.forceDisableStatus.description")}
/>
</>
)}
</CommonSettingsForm>
<Text fw={500}>{tBoard("status.title")}</Text>
<Switch
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
label={tBoard("status.enableStatusByDefault.label")}
description={tBoard("status.enableStatusByDefault.description")}
/>
<Switch
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
label={tBoard("status.forceDisableStatus.label")}
description={tBoard("status.forceDisableStatus.description")}
/>
</SectionCard>
);
};
@@ -1,57 +0,0 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings } from "@homarr/server-settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
settingKey,
defaultValues,
children,
}: {
settingKey: TKey;
defaultValues: ServerSettings[TKey];
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
}) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSuccess() {
showSuccessNotification({
message: tSettings("notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tSettings("notification.error.message"),
});
},
});
const form = useForm({
initialValues: defaultValues,
});
const handleSubmitAsync = async (values: ServerSettings[TKey]) => {
await mutateAsync({
settingsKey: settingKey,
value: values,
});
};
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="sm">
{children(form)}
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -1,68 +1,35 @@
"use client";
import React from "react";
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
import { Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
import { SwitchSetting } from "./setting-switch";
interface CrawlingAndIndexingSettingsProps {
initialData: typeof defaultServerSettings.crawlingAndIndexing;
form: UseFormReturnType<FormValues>;
}
export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => {
export const CrawlingAndIndexingSettings = ({ form }: CrawlingAndIndexingSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
const form = useForm({
initialValues: initialData,
onValuesChange: (updatedValues, _) => {
if (!form.isValid()) {
return;
}
void mutateAsync({
settingsKey: "crawlingAndIndexing",
value: updatedValues,
});
},
});
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/settings");
},
});
return (
<>
<Title order={2}>{t("title")}</Title>
<Card pos="relative">
<Text c={"dimmed"} mb={"lg"}>
{t("warning")}
</Text>
<LoadingOverlay visible={isPending} />
<Stack>
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
<SwitchSetting
form={form}
formKey="noTranslate"
title={t("noTranslate.title")}
text={t("noTranslate.text")}
/>
<SwitchSetting
form={form}
formKey="noSiteLinksSearchBox"
title={t("noSiteLinksSearchBox.title")}
text={t("noSiteLinksSearchBox.text")}
/>
</Stack>
</Card>
</>
<SectionCard title={t("title")}>
<Text c="dimmed" size="sm">
{t("warning")}
</Text>
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
<SwitchSetting form={form} formKey="noTranslate" title={t("noTranslate.title")} text={t("noTranslate.text")} />
<SwitchSetting
form={form}
formKey="noSiteLinksSearchBox"
title={t("noSiteLinksSearchBox.title")}
text={t("noSiteLinksSearchBox.text")}
/>
</SectionCard>
);
};
@@ -1,26 +1,28 @@
"use client";
import type { ServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import type { SupportedLanguage } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { LanguageCombobox } from "~/components/language/language-combobox";
import { CommonSettingsForm } from "./common-form";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => {
interface CultureSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
const tCulture = useScopedI18n("management.page.settings.section.culture");
const localeInputProps = form.getInputProps("defaultLocale");
return (
<CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
{(form) => (
<>
<LanguageCombobox
label={tCulture("defaultLocale.label")}
value={form.getInputProps("defaultLocale").value as SupportedLanguage}
{...form.getInputProps("defaultLocale")}
/>
</>
)}
</CommonSettingsForm>
<SectionCard title={tCulture("title")}>
<LanguageCombobox
label={tCulture("defaultLocale.label")}
{...localeInputProps}
value={localeInputProps.value as SupportedLanguage}
/>
</SectionCard>
);
};
@@ -3,27 +3,28 @@
import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
interface SearchSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return (
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
{(form) => (
<>
<Select
label={tSearch("defaultSearchEngine.label")}
description={tSearch("defaultSearchEngine.description")}
data={selectableSearchEngines}
{...form.getInputProps("defaultSearchEngineId")}
/>
</>
)}
</CommonSettingsForm>
<SectionCard title={tSearch("title")}>
<Select
label={tSearch("defaultSearchEngine.label")}
description={tSearch("defaultSearchEngine.description")}
data={selectableSearchEngines}
{...form.getInputProps("defaultSearchEngineId")}
/>
</SectionCard>
);
};
@@ -3,10 +3,13 @@ import React from "react";
import type { MantineSpacing } from "@mantine/core";
import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core";
import type { Modify } from "@homarr/common/types";
import type { UseFormReturnType } from "@homarr/form";
export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
type BooleanKeys<TFormValue> = {
[TKey in keyof TFormValue]: TFormValue[TKey] extends boolean ? TKey : never;
}[keyof TFormValue];
export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
form,
ms,
title,
@@ -14,13 +17,8 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
formKey,
disabled,
}: {
form: Modify<
UseFormReturnType<TFormValue, TFormValue>,
{
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
}
>;
formKey: keyof TFormValue;
form: UseFormReturnType<TFormValue, TFormValue>;
formKey: string & BooleanKeys<TFormValue>;
ms?: MantineSpacing;
title: string;
text: ReactNode;
@@ -31,7 +29,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
return;
}
form.setFieldValue(formKey, (previous) => !previous);
form.setFieldValue(formKey, (previous) => !previous as never);
}, [form, formKey, disabled]);
return (
@@ -44,7 +42,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
</Text>
</Stack>
</UnstyledButton>
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
<Switch disabled={disabled} onClick={handleClick} checked={Boolean(form.values[formKey])} />
</Group>
);
};
@@ -0,0 +1,193 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button, Stack } from "@mantine/core";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { AnalyticsSettings } from "./analytics.settings";
import { AppearanceSettingsForm } from "./appearance-settings-form";
import { BoardSettingsForm } from "./board-settings-form";
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
import { CultureSettingsForm } from "./culture-settings-form";
import { SearchSettingsForm } from "./search-settings-form";
import { UserSettingsForm } from "./user-settings-form";
const settingsFormSchema = z.object({
enableGeneral: z.boolean(),
noIndex: z.boolean(),
noFollow: z.boolean(),
noTranslate: z.boolean(),
noSiteLinksSearchBox: z.boolean(),
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
enableGravatar: z.boolean(),
defaultSearchEngineId: z.string().nullable(),
defaultColorScheme: z.enum(colorSchemes),
defaultLocale: z.string(),
});
export type FormValues = z.infer<typeof settingsFormSchema>;
const buildInitialValues = (initialData: ServerSettings): FormValues => ({
enableGeneral: initialData.analytics.enableGeneral,
noIndex: initialData.crawlingAndIndexing.noIndex,
noFollow: initialData.crawlingAndIndexing.noFollow,
noTranslate: initialData.crawlingAndIndexing.noTranslate,
noSiteLinksSearchBox: initialData.crawlingAndIndexing.noSiteLinksSearchBox,
homeBoardId: initialData.board.homeBoardId,
mobileHomeBoardId: initialData.board.mobileHomeBoardId,
enableStatusByDefault: initialData.board.enableStatusByDefault,
forceDisableStatus: initialData.board.forceDisableStatus,
enableGravatar: initialData.user.enableGravatar,
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
defaultColorScheme: initialData.appearance.defaultColorScheme,
defaultLocale: initialData.culture.defaultLocale,
});
interface SettingsFormProps {
initialData: ServerSettings;
}
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const initialValues = buildInitialValues(initialData);
const initialValuesRef = useRef(initialValues);
const form = useZodForm(settingsFormSchema, {
initialValues,
});
const isDirtyRef = useRef(false);
isDirtyRef.current = form.isDirty();
const [isSubmitting, setIsSubmitting] = useState(false);
const saveSettingsMutation = clientApi.serverSettings.saveSettings.useMutation({
onError(error) {
showErrorNotification({ title: t("common.notification.update.error"), message: error.message });
},
});
useEffect(() => {
const handler = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
if (isDirtyRef.current) event.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, []);
const handleSubmitAsync = async (values: FormValues) => {
const defaults = initialValuesRef.current;
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
fields.some((field) => values[field] !== defaults[field]);
const groups: {
settingsKey: (typeof defaultServerSettingsKeys)[number];
when: boolean;
value: Record<string, unknown>;
}[] = [
{ settingsKey: "analytics", when: changed("enableGeneral"), value: { enableGeneral: values.enableGeneral } },
{
settingsKey: "crawlingAndIndexing",
when: changed("noIndex", "noFollow", "noTranslate", "noSiteLinksSearchBox"),
value: {
noIndex: values.noIndex,
noFollow: values.noFollow,
noTranslate: values.noTranslate,
noSiteLinksSearchBox: values.noSiteLinksSearchBox,
},
},
{
settingsKey: "board",
when: changed("homeBoardId", "mobileHomeBoardId", "enableStatusByDefault", "forceDisableStatus"),
value: {
homeBoardId: values.homeBoardId,
mobileHomeBoardId: values.mobileHomeBoardId,
enableStatusByDefault: values.enableStatusByDefault,
forceDisableStatus: values.forceDisableStatus,
},
},
{ settingsKey: "user", when: changed("enableGravatar"), value: { enableGravatar: values.enableGravatar } },
{
settingsKey: "search",
when: changed("defaultSearchEngineId"),
value: { defaultSearchEngineId: values.defaultSearchEngineId },
},
{
settingsKey: "appearance",
when: changed("defaultColorScheme"),
value: { defaultColorScheme: values.defaultColorScheme },
},
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
];
const promises = groups
.filter((group) => group.when)
.map((group) => saveSettingsMutation.mutateAsync({ settingsKey: group.settingsKey, value: group.value }));
if (promises.length === 0) return;
setIsSubmitting(true);
try {
const results = await Promise.allSettled(promises);
if (!results.every((result) => result.status === "fulfilled")) {
return;
}
initialValuesRef.current = values;
form.setInitialValues(values);
form.resetDirty();
await revalidatePathActionAsync("/manage/settings");
showSuccessNotification({
title: t("common.notification.update.success"),
message: tSettings("notification.success.message"),
});
} finally {
setIsSubmitting(false);
}
};
const handleDiscard = () => {
form.setValues(initialValuesRef.current);
form.resetDirty();
};
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="xl">
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
<BoardSettingsForm form={form} />
<UserSettingsForm form={form} />
<SearchSettingsForm form={form} />
<AppearanceSettingsForm form={form} />
<CultureSettingsForm form={form} />
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isSubmitting} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
</Button>
<Button loading={isSubmitting} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
</form>
);
};
@@ -2,25 +2,26 @@
import { Switch } from "@mantine/core";
import type { ServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => {
interface UserSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const UserSettingsForm = ({ form }: UserSettingsFormProps) => {
const tUser = useScopedI18n("management.page.settings.section.user");
return (
<CommonSettingsForm settingKey="user" defaultValues={defaultValues}>
{(form) => (
<>
<Switch
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
label={tUser("enableGravatar.label")}
description={tUser("enableGravatar.description")}
/>
</>
)}
</CommonSettingsForm>
<SectionCard title={tUser("title")}>
<Switch
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
label={tUser("enableGravatar.label")}
description={tUser("enableGravatar.description")}
/>
</SectionCard>
);
};
@@ -0,0 +1,9 @@
import { Center, Loader } from "@mantine/core";
export default function SettingsLoading() {
return (
<Center h="40vh">
<Loader />
</Center>
);
}
@@ -5,14 +5,8 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AnalyticsSettings } from "./_components/analytics.settings";
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form";
import { UserSettingsForm } from "./_components/user-settings-form";
import { SettingsForm } from "./_components/settings-form";
export async function generateMetadata() {
const t = await getScopedI18n("management");
@@ -32,33 +26,13 @@ export default async function SettingsPage() {
const serverSettings = await api.serverSettings.getAll();
const tSettings = await getScopedI18n("management.page.settings");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tSettings("title")}</Title>
<AnalyticsSettings initialData={serverSettings.analytics} />
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
<Stack>
<Title order={2}>{tSettings("section.board.title")}</Title>
<BoardSettingsForm defaultValues={serverSettings.board} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.user.title")}</Title>
<UserSettingsForm defaultValues={serverSettings.user} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.search.title")}</Title>
<SearchSettingsForm defaultValues={serverSettings.search} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.appearance.title")}</Title>
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.culture.title")}</Title>
<CultureSettingsForm defaultValues={serverSettings.culture} />
</Stack>
<SettingsForm initialData={serverSettings} />
</Stack>
</>
);
@@ -1,38 +1,17 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Suspense, use, useEffect, useState } from "react";
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { ErrorBoundary } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update";
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
const loadUserAvatarMenu = () => {
if (userAvatarMenuPromise) return userAvatarMenuPromise;
const promise = import("~/components/user-avatar-menu");
userAvatarMenuPromise = promise;
void promise.catch(() => {
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
});
return promise;
};
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
const { UserAvatarMenu } = use(loadUserAvatarMenu());
return <UserAvatarMenu {...props} />;
};
interface UserButtonClientProps {
avatar: ReactNode;
isAdmin: boolean;
@@ -41,8 +20,6 @@ interface UserButtonClientProps {
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const [isMenuMounted, setIsMenuMounted] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const session = useSession();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -66,60 +43,14 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
staleTime: 60 * 60 * 1_000,
});
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
const openMenu = () => {
setIsMenuMounted(true);
setIsMenuOpen(true);
};
const renderButton = (onClick: () => void, isLoading = false) => (
<UnstyledButton
onClick={onClick}
onFocus={preloadUserAvatarMenu}
onMouseEnter={preloadUserAvatarMenu}
onPointerDown={preloadUserAvatarMenu}
aria-busy={isLoading || undefined}
disabled={isLoading}
>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
<Box pos="relative" style={{ display: "inline-flex" }}>
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
{isLoading && (
<Loader
aria-label="Loading user menu"
size={18}
pos="absolute"
top="50%"
left="50%"
style={{ transform: "translate(-50%, -50%)" }}
/>
)}
</Box>
</UpdateIndicator>
</UnstyledButton>
);
const button = renderButton(openMenu);
const loadingButton = renderButton(() => undefined, true);
if (!isMenuMounted) return button;
return (
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) =>
renderButton(() => {
resetErrorBoundary();
openMenu();
})
}
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
{avatar}
</UpdateIndicator>
</UnstyledButton>
</UserAvatarMenu>
);
};
@@ -1,19 +1,21 @@
"use client";
import { Fragment } from "react";
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
import { getI18n } from "@homarr/translation/server";
import { useScopedI18n } from "@homarr/translation/client";
interface DangerZoneRootProps {
children: React.ReactNode[] | React.ReactNode;
}
export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
const t = await getI18n();
export const DangerZoneRoot = ({ children }: DangerZoneRootProps) => {
const t = useScopedI18n("common");
return (
<Stack gap="sm">
<Title c="red.8" order={2}>
{t("common.dangerZone")}
{t("dangerZone")}
</Title>
<Card style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
<Stack gap="sm">
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { Card, Divider, Stack, Title } from "@mantine/core";
interface SectionCardProps {
title: ReactNode;
children: ReactNode;
}
export const SectionCard = ({ title, children }: SectionCardProps) => {
return (
<Card withBorder bg="transparent">
<Stack gap="md">
<Title order={3}>{title}</Title>
<Divider />
{children}
</Stack>
</Card>
);
};
@@ -0,0 +1,25 @@
"use client";
import type { ReactNode } from "react";
import { Card, Group, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
interface UnsavedChangesBarProps {
children: ReactNode;
}
export const UnsavedChangesBar = ({ children }: UnsavedChangesBarProps) => {
const t = useI18n();
return (
<div style={{ position: "sticky", bottom: 20 }}>
<Card withBorder>
<Group justify="space-between" wrap="wrap">
<Text fw={500}>{t("common.unsavedChanges")}</Text>
<Group>{children}</Group>
</Group>
</Card>
</div>
);
};
@@ -21,17 +21,9 @@ interface UserAvatarMenuProps {
children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean;
opened: boolean;
onOpenChange: (opened: boolean) => void;
}
export const UserAvatarMenu = ({
children,
availableUpdates,
isDockerEnabled,
opened,
onOpenChange,
}: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const session = useSession();
@@ -48,7 +40,7 @@ export const UserAvatarMenu = ({
return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>