diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 5076c3cba..7e8a70a44 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -36,13 +36,13 @@ Underscores and hyphen-minus are allowed. ### Column count -Homarr has a complex drag and drop system that can be used from the browser. +Homarr has a complex drag-and-drop system that can be used from the browser. The column count defines how many horizontal columns are available to drag and drop elements to. ### Public This will enable public access for everyone - regardless of whether they are logged in or not. -This can be really useful if you want to have public boards in your company or for family and fiends. +This can be really useful if you want to have public boards in your company or for family and friends. Modifications for public boards must be allowed explicitly using user groups and their permissions. ## Board settings @@ -55,6 +55,8 @@ Alternatively, you can also navigate from the board page itself using the cog ic ![](./img/navigate-to-settings-2.png) +All board settings are edited on a single page. Click the **Save changes** button in the save bar at the bottom to apply them, or **Discard** to restore the last saved values. The save bar only appears when you have unsaved changes. Access control and the Danger zone are independent sections and are applied immediately. + ### General #### Page title @@ -229,7 +231,7 @@ This action is irreversible and cannot be undone. The deletion of boards is permanent and cannot be undone. There are two ways to delete a board: -- Navigate to the settings of your board, open the "Danger zone" dropdown and click on the "delete this board" button. +- Navigate to the settings of your board, open the "Danger zone" section and click on the "delete this board" button. - Navigate to the list of boards in the management pages, click the three dots and click on "delete permanently". Both actions will execute the same deletion process. diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index 016d56d10..8507d2b4f 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -13,6 +13,8 @@ tags: The settings page applies options to the whole Homarr application. Some changes need a full page reload before they take effect. +All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes. + ## Analytics Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data. diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx index 4af4af15f..b03168912 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx @@ -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; } 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 ( -
{ - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - - - - color[6])} - {...form.getInputProps("primaryColor")} - /> - - - + + + + color[6])} - {...form.getInputProps("secondaryColor")} + {...form.getInputProps("primaryColor")} /> - - - {showPreview ? t("common.preview.hide") : t("common.preview.show")} - - - - - - - - - - - - - - - - - color[6])} - flex={1} - {...form.getInputProps("iconColor")} - /> - - - - - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx index 7b105fe9c..8a796560f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx @@ -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; } -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 ( - { - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - - - - = 2 && ( - - -
- -
-
- - - -
- ) - } - // 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; + + + + + = 2 && ( + + +
+ +
+
+ + + +
+ ) + } + // 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 ( - - - - {current.name} - - {option.value} - - - - ); + return ( + + + + {current.name} + + {option.value} + + + + ); + }} + 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") && ( + { + 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") && ( - { - const first = medias.at(0); - if (!first) return; - - startTransition(() => { - form.setFieldValue("backgroundImageUrl", first.url); - }); - }} - > - {({ onClick, loading }) => ( - - - - )} - - )} -
-
- - - - - - - - - -
- - - - -
- + > + {({ onClick, loading }) => ( + + + + )} + + )} + +
+ + + + + + + + + +
+ ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx index 9eded5ec4..563fb97eb 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx @@ -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; } -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 ( -
{ - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - - - - - - -
+ + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx index e0d8ec565..e5df59a9f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx @@ -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; } -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 ( -
{ - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - + + - }> - {customCssT("customClassesAlert.description")} - - - - - - -
+ }> + {customCssT("customClassesAlert.description")} + + ); }; 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"]} >
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx index 699da6003..92454f20c 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx @@ -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 ( - - - + + {t("section.dangerZone.action.rename.button")} + + } /> - {hideVisibility ? null : ( - <> - - - + {!hideVisibility && ( + + {t(`section.dangerZone.action.visibility.button.${visibility}`)} + + } + /> )} - - + {t("section.dangerZone.action.delete.button")} + + } /> - - ); -}; - -interface DangerZoneRowProps { - label: string; - description: string; - buttonText: string; - isPending?: boolean; - onClick: () => void; -} - -const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => { - return ( - - - - {label} - - {description} - - - - - + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx index 0bfc79173..2fcb119a0 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx @@ -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; } -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 ( -
{ - // 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, - }); - })} - > - - - - - - - } - {...form.getInputProps("metaTitle")} - /> - - - - - - - - - - - - -
+ + + + + + + } + {...form.getInputProps("metaTitle")} + /> + + + + + + + + + ); }; @@ -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; -}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx index e0b21a408..40ac0e563 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx @@ -44,7 +44,7 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr {layout.columnCount} {t("board.setting.section.layout.preview.columns")} - + {elements.length === 0 ? ( {t("board.setting.section.layout.preview.empty")} diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index 77158853a..d58cbce89 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -19,35 +19,30 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { createId } from "@homarr/common"; -import { useZodForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { boardSaveLayoutsSchema } from "@homarr/validation/board"; +import { SectionCard } from "~/components/manage/section-card"; import type { Board } from "../../_types"; import { LayoutPreview } from "./_layout-preview"; +import type { FormValues } from "./_settings-form"; interface Props { board: Board; + form: UseFormReturnType; + isSaving: boolean; + saveSettingsAsync: () => Promise; } -export const LayoutSettingsContent = ({ board }: Props) => { +export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => { const t = useI18n(); const router = useRouter(); const utils = clientApi.useUtils(); const { openConfirmModal } = useConfirmModal(); const [editingLayoutId, setEditingLayoutId] = useState(null); const [resettingLayoutId, setResettingLayoutId] = useState(null); - const { mutateAsync: saveLayouts, isPending: isSaving } = clientApi.board.saveLayouts.useMutation({ - onSettled() { - void utils.board.getBoardByName.invalidate({ name: board.name }); - void utils.board.getHomeBoard.invalidate(); - }, - }); const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation(); - const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), { - initialValues: { layouts: board.layouts }, - }); const appIds = useMemo( () => Array.from( @@ -61,21 +56,12 @@ export const LayoutSettingsContent = ({ board }: Props) => { ); const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 }); - const persistLayoutsAsync = async () => { - const savedLayouts = await saveLayouts({ id: board.id, layouts: form.values.layouts }); - form.setValues({ layouts: savedLayouts }); - form.resetDirty({ layouts: savedLayouts }); - return savedLayouts; - }; - - const openLayoutEditorAsync = async (layout: (typeof form.values.layouts)[number]) => { + const openLayoutEditorAsync = async (layout: FormValues["layouts"][number]) => { setEditingLayoutId(layout.id); try { - const isNew = !board.layouts.some((candidate) => candidate.id === layout.id); - const savedLayouts = form.isDirty() || isNew ? await persistLayoutsAsync() : form.values.layouts; - const canonicalLayout = - savedLayouts.find((candidate) => candidate.id === layout.id) ?? - savedLayouts.find((candidate) => candidate.breakpoint === layout.breakpoint && candidate.role === layout.role); + const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id); + const savedValues = form.isDirty() || !persistedLayout ? await saveSettingsAsync() : form.values; + const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined; if (!canonicalLayout) return; router.push(`/boards/${board.name}?layout=${encodeURIComponent(canonicalLayout.id)}&edit=true&returnTo=settings`); @@ -84,7 +70,7 @@ export const LayoutSettingsContent = ({ board }: Props) => { } }; - const confirmReset = (layout: (typeof form.values.layouts)[number]) => { + const confirmReset = (layout: FormValues["layouts"][number]) => { openConfirmModal({ title: t("board.setting.section.layout.reset.confirm.title", { layoutName: layout.name }), children: t("board.setting.section.layout.reset.confirm.message"), @@ -93,12 +79,8 @@ export const LayoutSettingsContent = ({ board }: Props) => { setResettingLayoutId(layout.id); void (async () => { try { - const savedLayouts = form.isDirty() ? await persistLayoutsAsync() : form.values.layouts; - const canonicalLayout = - savedLayouts.find((candidate) => candidate.id === layout.id) ?? - savedLayouts.find( - (candidate) => candidate.breakpoint === layout.breakpoint && candidate.role === layout.role, - ); + const savedValues = form.isDirty() ? await saveSettingsAsync() : form.values; + const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined; if (!canonicalLayout) return; await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); @@ -119,19 +101,16 @@ export const LayoutSettingsContent = ({ board }: Props) => { .toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint); return ( -
{ - await persistLayoutsAsync(); - })} - > + - - + + {t("board.setting.section.layout.responsive.title")} {t("board.setting.section.layout.responsive.description")} - - +
); }; +const findCanonicalLayout = (layouts: FormValues["layouts"], layout: FormValues["layouts"][number]) => + layouts.find((candidate) => candidate.id === layout.id) ?? + layouts.find((candidate) => candidate.breakpoint === layout.breakpoint && candidate.role === layout.role); + const getNextCustomBreakpoint = (layouts: Array<{ breakpoint: number }>) => { const sortedBreakpoints = layouts .map((layout) => layout.breakpoint) diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx new file mode 100644 index 000000000..f8e212b87 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx @@ -0,0 +1,222 @@ +"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; + +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 saveSettingsAsync = async (values: FormValues): Promise => { + const defaults = initialValuesRef.current; + const changed = (...fields: TKey[]) => + fields.some((field) => values[field] !== defaults[field]); + + const { layouts, ...partialSettings } = values; + const partialSettingsChanged = changed(...PARTIAL_FORM_KEYS); + const layoutsChanged = changed("layouts"); + if (!partialSettingsChanged && !layoutsChanged) return values; + + try { + const [, canonicalLayouts] = await Promise.all([ + partialSettingsChanged + ? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => { + updateFavicon(values.faviconImageUrl ?? homarrLogoPath); + }) + : Promise.resolve(), + layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null), + ]); + const canonicalValues = canonicalLayouts ? { ...values, layouts: canonicalLayouts } : values; + + lastSavedRef.current = { pageTitle: canonicalValues.pageTitle, logoImageUrl: canonicalValues.logoImageUrl }; + initialValuesRef.current = canonicalValues; + form.setValues(canonicalValues); + form.setInitialValues(canonicalValues); + form.resetDirty(canonicalValues); + await revalidatePathActionAsync(`/boards/${board.name}/settings`); + showSuccessNotification({ + title: t("common.notification.update.success"), + message: t("common.notification.update.success"), + }); + return canonicalValues; + } catch { + showErrorNotification({ + title: t("common.notification.update.error"), + message: t("common.notification.update.error"), + }); + return null; + } + }; + + 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 ( +
void saveSettingsAsync(values))}> + + + saveSettingsAsync(form.values)} + /> + + + + + + {hasFullAccess && ( + + + + )} + + {hasFullAccess && } + + {form.isDirty() && ( + + + + + )} + +
+ ); +}; + +// 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('link[rel="icon"]'); + if (!link) return; + link.href = url; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx index 9fa888be2..d0fedf522 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx @@ -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(); + }, + }); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css b/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css deleted file mode 100644 index 39a60b735..000000000 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css +++ /dev/null @@ -1,5 +0,0 @@ -@media (min-width: 36em) { - .dangerZoneGroup { - --group-wrap: nowrap !important; - } -} diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx index 33c3d7555..609428b2f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -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) => { @@ -57,11 +33,7 @@ const getBoardAndPermissionsAsync = async (params: Awaited) => } 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) => }; 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"); @@ -82,79 +53,16 @@ export default async function BoardSettingsPage(props: Props) { } return ( - + {t("title", { boardName: capitalize(board.name) })} - - - - - - - - - - - - - - - - - - - - {hasFullAccess && ( - <> - - - - - - - - )} - + ); } - -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 ( - - }> - - {t(`${value}.title`)} - - - - {children} - - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx index 9ad50b432..b68e28e90 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx @@ -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; } -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 ( - <> - {t("title")} - - - - - - - - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx index 900caf0e4..e33afe1b6 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx @@ -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; +} + +export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => { + const tAppearance = useScopedI18n("management.page.settings.section.appearance"); return ( - - {(form) => ( - <> - ({ - value: scheme, - label: tApperance(`defaultColorScheme.options.${scheme}`), - }))} - {...form.getInputProps("defaultColorScheme")} - SelectOption={ApperanceCustomOption} - /> - - )} - + + ({ + value: scheme, + label: tAppearance(`defaultColorScheme.options.${scheme}`), + }))} + {...form.getInputProps("defaultColorScheme")} + SelectOption={AppearanceCustomOption} + /> + ); }; @@ -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 ( diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx index 615c6ba38..801e0df9f 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx @@ -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; +} + +export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => { const tBoard = useScopedI18n("management.page.settings.section.board"); const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery(); return ( - - {(form) => ( - <> - + + - + - {tBoard("status.title")} - - - - )} - + {tBoard("status.title")} + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx deleted file mode 100644 index 8a932dee1..000000000 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx +++ /dev/null @@ -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 = ({ - settingKey, - defaultValues, - children, -}: { - settingKey: TKey; - defaultValues: ServerSettings[TKey]; - children: (form: ReturnType>) => 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 ( -
void handleSubmitAsync(values))}> - - {children(form)} - - - - -
- ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx index 29206a473..15852c274 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx @@ -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; } -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 ( - <> - {t("title")} - - - - {t("warning")} - - - - - - - - - - + + + {t("warning")} + + + + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx index 7d9dff99e..da34c7019 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx @@ -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; +} + +export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => { const tCulture = useScopedI18n("management.page.settings.section.culture"); + const localeInputProps = form.getInputProps("defaultLocale"); return ( - - {(form) => ( - <> - - - )} - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx index a39cab70d..904dd4b08 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx @@ -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; +} + +export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => { const tSearch = useScopedI18n("management.page.settings.section.search"); const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false }); return ( - - {(form) => ( - <> - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx index adec086ad..4e1194d42 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx @@ -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 = >({ +type BooleanKeys = { + [TKey in keyof TFormValue]: TFormValue[TKey] extends boolean ? TKey : never; +}[keyof TFormValue]; + +export const SwitchSetting = >({ form, ms, title, @@ -14,13 +17,8 @@ export const SwitchSetting = >({ formKey, disabled, }: { - form: Modify< - UseFormReturnType, - { - setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void; - } - >; - formKey: keyof TFormValue; + form: UseFormReturnType; + formKey: string & BooleanKeys; ms?: MantineSpacing; title: string; text: ReactNode; @@ -31,7 +29,7 @@ export const SwitchSetting = >({ return; } - form.setFieldValue(formKey, (previous) => !previous); + form.setFieldValue(formKey, (previous) => !previous as never); }, [form, formKey, disabled]); return ( @@ -44,7 +42,7 @@ export const SwitchSetting = >({
- + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx new file mode 100644 index 000000000..ce64a3404 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx @@ -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; + +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 = (...fields: TKey[]) => + fields.some((field) => values[field] !== defaults[field]); + + const groups: { + settingsKey: (typeof defaultServerSettingsKeys)[number]; + when: boolean; + value: Record; + }[] = [ + { 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 ( +
void handleSubmitAsync(values))}> + + + + + + + + + + {form.isDirty() && ( + + + + + )} + +
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx index 49b8f7219..41abb2882 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx @@ -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; +} + +export const UserSettingsForm = ({ form }: UserSettingsFormProps) => { const tUser = useScopedI18n("management.page.settings.section.user"); return ( - - {(form) => ( - <> - - - )} - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/loading.tsx b/apps/nextjs/src/app/[locale]/manage/settings/loading.tsx new file mode 100644 index 000000000..097406c3d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/loading.tsx @@ -0,0 +1,9 @@ +import { Center, Loader } from "@mantine/core"; + +export default function SettingsLoading() { + return ( +
+ +
+ ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx index 4a7ea9bc0..eee563069 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx @@ -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 ( <> {tSettings("title")} - - - - {tSettings("section.board.title")} - - - - {tSettings("section.user.title")} - - - - {tSettings("section.search.title")} - - - - {tSettings("section.appearance.title")} - - - - {tSettings("section.culture.title")} - - + ); diff --git a/apps/nextjs/src/components/manage/danger-zone.tsx b/apps/nextjs/src/components/manage/danger-zone.tsx index 38f63185b..480d38a0c 100644 --- a/apps/nextjs/src/components/manage/danger-zone.tsx +++ b/apps/nextjs/src/components/manage/danger-zone.tsx @@ -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 ( - {t("common.dangerZone")} + {t("dangerZone")} diff --git a/apps/nextjs/src/components/manage/section-card.tsx b/apps/nextjs/src/components/manage/section-card.tsx new file mode 100644 index 000000000..66eb396d4 --- /dev/null +++ b/apps/nextjs/src/components/manage/section-card.tsx @@ -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 ( + + + {title} + + {children} + + + ); +}; diff --git a/apps/nextjs/src/components/manage/unsaved-changes-bar.tsx b/apps/nextjs/src/components/manage/unsaved-changes-bar.tsx new file mode 100644 index 000000000..da0b91ad1 --- /dev/null +++ b/apps/nextjs/src/components/manage/unsaved-changes-bar.tsx @@ -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 ( +
+ + + {t("common.unsavedChanges")} + {children} + + +
+ ); +};