From d8eaef859a27d239a8091a333f0b538c0a6edc66 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 25 Jan 2026 16:00:10 +0100 Subject: [PATCH 1/5] chore(gitignore): ignore Cursor project files --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0aa3ed4c7b..8562d42504 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,6 @@ e2e/shared/tmp apps/nextjs/public/images/background.png # next-intl -en.d.json.ts \ No newline at end of file +en.d.json.ts + +.cursor/ \ No newline at end of file From d99b20d3efd02eda029b7f2522b9aafbd65c5e7a Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 25 Jan 2026 16:00:21 +0100 Subject: [PATCH 2/5] feat(manage): consolidate user general settings form --- .../_components/_general-settings-form.tsx | 451 ++++++++++++++++++ .../manage/users/[userId]/general/page.tsx | 74 +-- 2 files changed, 470 insertions(+), 55 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx new file mode 100644 index 0000000000..1357b330e7 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx @@ -0,0 +1,451 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button, Card, Divider, Group, Radio, Select, SimpleGrid, Stack, Switch, Text, TextInput, Title } from "@mantine/core"; +import type { DayOfWeek } from "@mantine/dates"; +import dayjs from "dayjs"; +import localeData from "dayjs/plugin/localeData"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { env } from "@homarr/common/env"; +import { useZodForm } from "@homarr/form"; +import { useConfirmModal } from "@homarr/modals"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { + userChangeHomeBoardsSchema, + userChangeSearchPreferencesSchema, + userEditProfileSchema, + userFirstDayOfWeekSchema, + userPingIconsEnabledSchema, +} from "@homarr/validation/user"; +import { z } from "zod/v4"; + +import type { Board } from "~/app/[locale]/boards/_types"; +import { BoardSelect } from "~/components/board/board-select"; +import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; + +dayjs.extend(localeData); + +const anchorSelector = "a[href]:not([target='_blank'])"; + +const userGeneralSettingsSchema = z.object({ + name: userEditProfileSchema.shape.name, + email: userEditProfileSchema.shape.email, + homeBoardId: userChangeHomeBoardsSchema.shape.homeBoardId, + mobileHomeBoardId: userChangeHomeBoardsSchema.shape.mobileHomeBoardId, + defaultSearchEngineId: userChangeSearchPreferencesSchema.shape.defaultSearchEngineId, + openInNewTab: userChangeSearchPreferencesSchema.shape.openInNewTab, + firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek, + pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled, +}); + +type FormValues = z.infer; + +interface UserGeneralSettingsFormProps { + user: RouterOutputs["user"]["getById"]; + boardsData: Pick[]; + searchEnginesData: { value: string; label: string }[]; + showLanguageSelector?: boolean; +} + +const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({ + name: user.name ?? "", + email: user.email ?? "", + homeBoardId: user.homeBoardId, + mobileHomeBoardId: user.mobileHomeBoardId, + defaultSearchEngineId: user.defaultSearchEngineId, + openInNewTab: user.openSearchInNewTab, + firstDayOfWeek: user.firstDayOfWeek as DayOfWeek, + pingIconsEnabled: user.pingIconsEnabled, +}); + +const valuesEqual = (a: unknown, b: unknown) => a === b; + +export const UserGeneralSettingsForm = ({ + user, + boardsData, + searchEnginesData, + showLanguageSelector = false, +}: UserGeneralSettingsFormProps) => { + const t = useI18n(); + const tGeneral = useScopedI18n("management.page.user.setting.general"); + const { openConfirmModal } = useConfirmModal(); + const router = useRouter(); + + const isCredentialsUser = user.provider === "credentials"; + + const [savedValues, setSavedValues] = useState(() => buildInitialValues(user)); + const form = useZodForm(userGeneralSettingsSchema, { initialValues: savedValues }); + const allowLeaveRef = useRef(false); + const confirmOpenRef = useRef(false); + + const editProfileMutation = clientApi.user.editProfile.useMutation(); + const changeHomeBoardsMutation = clientApi.user.changeHomeBoards.useMutation(); + const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation(); + const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation(); + const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation(); + + const isPending = + editProfileMutation.isPending || + changeHomeBoardsMutation.isPending || + changeSearchPreferencesMutation.isPending || + changeFirstDayOfWeekMutation.isPending || + changePingIconsEnabledMutation.isPending; + + const weekDays = useMemo(() => dayjs.weekdays(false), []); + + useEffect(() => { + if (!form.isDirty() || allowLeaveRef.current) return; + + const handleClick = (event: Event) => { + if (allowLeaveRef.current) return; + + const target = (event.target as HTMLElement).closest("a"); + + if (!target) return; + + event.preventDefault(); + + if (confirmOpenRef.current) return; + confirmOpenRef.current = true; + + openConfirmModal({ + title: t("common.unsavedChanges"), + children: t("common.unsavedChanges"), + confirmProps: { + children: t("common.action.discard"), + }, + onCancel() { + confirmOpenRef.current = false; + }, + onConfirm() { + allowLeaveRef.current = true; + confirmOpenRef.current = false; + router.push(target.href); + }, + }); + }; + + const handlePopState = (event: Event) => { + if (allowLeaveRef.current) return; + + // Keep the user on this page and ask for confirmation. + window.history.pushState(null, document.title, window.location.href); + event.preventDefault(); + + if (confirmOpenRef.current) return; + confirmOpenRef.current = true; + + openConfirmModal({ + title: t("common.unsavedChanges"), + children: t("common.unsavedChanges"), + confirmProps: { + children: t("common.action.discard"), + }, + onCancel() { + confirmOpenRef.current = false; + }, + onConfirm() { + allowLeaveRef.current = true; + confirmOpenRef.current = false; + window.history.back(); + }, + }); + }; + + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (env.NODE_ENV === "development") return; // Allow to reload in development + + event.preventDefault(); + event.returnValue = true; + }; + + // Add a history entry so back triggers popstate for this page first. + window.history.pushState(null, document.title, window.location.href); + + const anchors = document.querySelectorAll(anchorSelector); + anchors.forEach((link) => link.addEventListener("click", handleClick)); + window.addEventListener("popstate", handlePopState); + window.addEventListener("beforeunload", handleBeforeUnload); + + return () => { + anchors.forEach((link) => link.removeEventListener("click", handleClick)); + window.removeEventListener("popstate", handlePopState); + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [form, openConfirmModal, router, t]); + + const handleSave = useCallback(async () => { + const values = form.getValues() as FormValues; + + const tasks: Array<{ key: keyof FormValues | "profile"; run: () => Promise }> = []; + + const profileChanged = + !valuesEqual(values.name, savedValues.name) || !valuesEqual(values.email ?? "", savedValues.email ?? ""); + if (isCredentialsUser && profileChanged) { + tasks.push({ + key: "profile", + run: () => + editProfileMutation.mutateAsync({ + id: user.id, + name: values.name, + email: values.email ?? "", + }), + }); + } + + const boardsChanged = + !valuesEqual(values.homeBoardId, savedValues.homeBoardId) || + !valuesEqual(values.mobileHomeBoardId, savedValues.mobileHomeBoardId); + if (boardsChanged) { + tasks.push({ + key: "homeBoardId", + run: () => + changeHomeBoardsMutation.mutateAsync({ + userId: user.id, + homeBoardId: values.homeBoardId, + mobileHomeBoardId: values.mobileHomeBoardId, + }), + }); + } + + const searchChanged = + !valuesEqual(values.defaultSearchEngineId, savedValues.defaultSearchEngineId) || + !valuesEqual(values.openInNewTab, savedValues.openInNewTab); + if (searchChanged) { + tasks.push({ + key: "defaultSearchEngineId", + run: () => + changeSearchPreferencesMutation.mutateAsync({ + userId: user.id, + defaultSearchEngineId: values.defaultSearchEngineId, + openInNewTab: values.openInNewTab, + }), + }); + } + + const firstDayChanged = !valuesEqual(values.firstDayOfWeek, savedValues.firstDayOfWeek); + if (firstDayChanged) { + tasks.push({ + key: "firstDayOfWeek", + run: () => + changeFirstDayOfWeekMutation.mutateAsync({ + id: user.id, + firstDayOfWeek: values.firstDayOfWeek, + }), + }); + } + + const pingChanged = !valuesEqual(values.pingIconsEnabled, savedValues.pingIconsEnabled); + if (pingChanged) { + tasks.push({ + key: "pingIconsEnabled", + run: () => + changePingIconsEnabledMutation.mutateAsync({ + id: user.id, + pingIconsEnabled: values.pingIconsEnabled, + }), + }); + } + + if (tasks.length === 0) return; + + const results = await Promise.allSettled(tasks.map((t) => t.run())); + + const hasError = results.some((r) => r.status === "rejected"); + + const nextSaved: FormValues = { + ...savedValues, + }; + + if (!hasError) { + nextSaved.name = values.name; + nextSaved.email = values.email ?? ""; + nextSaved.homeBoardId = values.homeBoardId; + nextSaved.mobileHomeBoardId = values.mobileHomeBoardId; + nextSaved.defaultSearchEngineId = values.defaultSearchEngineId; + nextSaved.openInNewTab = values.openInNewTab; + nextSaved.firstDayOfWeek = values.firstDayOfWeek; + nextSaved.pingIconsEnabled = values.pingIconsEnabled; + } else { + results.forEach((r, index) => { + if (r.status === "rejected") return; + const task = tasks[index]; + if (!task) return; + if (task.key === "profile") { + nextSaved.name = values.name; + nextSaved.email = values.email ?? ""; + } + if (task.key === "homeBoardId") { + nextSaved.homeBoardId = values.homeBoardId; + nextSaved.mobileHomeBoardId = values.mobileHomeBoardId; + } + if (task.key === "defaultSearchEngineId") { + nextSaved.defaultSearchEngineId = values.defaultSearchEngineId; + nextSaved.openInNewTab = values.openInNewTab; + } + if (task.key === "firstDayOfWeek") { + nextSaved.firstDayOfWeek = values.firstDayOfWeek; + } + if (task.key === "pingIconsEnabled") { + nextSaved.pingIconsEnabled = values.pingIconsEnabled; + } + }); + } + + setSavedValues(nextSaved); + form.setInitialValues(nextSaved); + + await revalidatePathActionAsync(`/manage/users/${user.id}`); + + if (hasError) { + showErrorNotification({ + title: t("common.notification.update.error"), + message: t("common.notification.update.error"), + }); + return; + } + + showSuccessNotification({ + title: t("common.notification.update.success"), + message: t("common.notification.update.success"), + }); + }, [ + changeFirstDayOfWeekMutation, + changeHomeBoardsMutation, + changePingIconsEnabledMutation, + changeSearchPreferencesMutation, + editProfileMutation, + form, + isCredentialsUser, + savedValues, + t, + user.id, + ]); + + const handleDiscard = useCallback(() => { + form.reset(); + }, [form]); + + const firstDayInputProps = form.getInputProps("firstDayOfWeek"); + const onFirstDayChange = firstDayInputProps.onChange as (value: number) => void; + const firstDayValue = (firstDayInputProps.value as number).toString(); + + return ( +
handleSave())}> + + + + + + {t("user.name")} + + {t("user.field.username.label")} · {t("user.field.email.label")} + + {!isCredentialsUser && ( + + {t("management.page.user.fieldsDisabledExternalProvider")} + + )} + + + + + + + + + + + {tGeneral("item.board.title")} + + + + + + + + + + + {tGeneral("item.search")} + + + - + + {({ state, handleChange, handleBlur }) => ( + - )} - - - {({ state, handleChange, handleBlur }) => ( - handleChange(e.currentTarget.checked)} - onBlur={handleBlur} - error={getErrorMessage(state.meta.errors)} - /> - )} - +