diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-home-board.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-home-board.tsx deleted file mode 100644 index bdad9cfea..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-home-board.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -import { Button, Group, Stack } from "@mantine/core"; -import type { z } from "zod/v4"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { userChangeHomeBoardsSchema } from "@homarr/validation/user"; - -import type { Board } from "~/app/[locale]/boards/_types"; -import { BoardSelect } from "~/components/board/board-select"; - -interface ChangeHomeBoardFormProps { - user: RouterOutputs["user"]["getById"]; - boardsData: Pick[]; -} - -export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({ - async onSettled() { - await revalidatePathActionAsync(`/manage/users/${user.id}`); - }, - onSuccess(_, variables) { - form.setInitialValues({ - homeBoardId: variables.homeBoardId, - mobileHomeBoardId: variables.mobileHomeBoardId, - }); - showSuccessNotification({ - message: t("user.action.changeHomeBoard.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: t("user.action.changeHomeBoard.notification.error.message"), - }); - }, - }); - const form = useZodForm(userChangeHomeBoardsSchema, { - initialValues: { - homeBoardId: user.homeBoardId, - mobileHomeBoardId: user.mobileHomeBoardId, - }, - }); - - const handleSubmit = (values: FormType) => { - mutate({ - userId: user.id, - ...values, - }); - }; - - return ( -
- - - - - - - - -
- ); -}; - -type FormType = z.infer; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-search-preferences.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-search-preferences.tsx deleted file mode 100644 index afae60dc1..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-search-preferences.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; - -import { Button, Group, Select, Stack, Switch } from "@mantine/core"; -import type { z } from "zod/v4"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { userChangeSearchPreferencesSchema } from "@homarr/validation/user"; - -interface ChangeSearchPreferencesFormProps { - user: RouterOutputs["user"]["getById"]; - searchEnginesData: { value: string; label: string }[]; -} - -export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({ - async onSettled() { - await revalidatePathActionAsync(`/manage/users/${user.id}`); - }, - onSuccess(_, variables) { - form.setInitialValues({ - defaultSearchEngineId: variables.defaultSearchEngineId, - openInNewTab: variables.openInNewTab, - ddgBangsEnabled: variables.ddgBangsEnabled, - }); - showSuccessNotification({ - message: t("user.action.changeSearchPreferences.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: t("user.action.changeSearchPreferences.notification.error.message"), - }); - }, - }); - const form = useZodForm(userChangeSearchPreferencesSchema, { - initialValues: { - defaultSearchEngineId: user.defaultSearchEngineId, - openInNewTab: user.openSearchInNewTab, - ddgBangsEnabled: user.ddgBangs, - }, - }); - - const handleSubmit = (values: FormType) => { - mutate({ - userId: user.id, - ...values, - }); - }; - - return ( -
- - + + + + + + + + + {tGeneral("item.language")} + + + {showLanguageSelector && } + {tGeneral("item.firstDayOfWeek")} + firstDayOfWeekOnChange(parseInt(nextValue, 10))} + > + + {FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => ( + + ))} + + + + {tGeneral("item.accessibility")} + + + + + +
+ {form.isDirty() && ( + + + {t("common.unsavedChanges")} + + + + + + + )} +
+ +
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_ping-icons-enabled.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_ping-icons-enabled.tsx deleted file mode 100644 index d218ac39a..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_ping-icons-enabled.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import { Button, Group, Stack, Switch } from "@mantine/core"; -import type { z } from "zod/v4"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { userPingIconsEnabledSchema } from "@homarr/validation/user"; - -interface PingIconsEnabledProps { - user: RouterOutputs["user"]["getById"]; -} - -export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({ - async onSettled() { - await revalidatePathActionAsync(`/manage/users/${user.id}`); - }, - onSuccess(_, variables) { - form.setInitialValues({ - pingIconsEnabled: variables.pingIconsEnabled, - }); - showSuccessNotification({ - message: t("user.action.changePingIconsEnabled.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: t("user.action.changePingIconsEnabled.notification.error.message"), - }); - }, - }); - const form = useZodForm(userPingIconsEnabledSchema, { - initialValues: { - pingIconsEnabled: user.pingIconsEnabled, - }, - }); - - const handleSubmit = (values: FormType) => { - mutate({ - id: user.id, - ...values, - }); - }; - - return ( -
- - - - - - - -
- ); -}; - -type FormType = z.infer; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx index 2137add34..809345a0f 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; +import { useDisclosure, useMediaQuery } from "@mantine/hooks"; import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; @@ -25,6 +25,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { }, }); const [opened, { toggle }] = useDisclosure(false); + const isMobile = useMediaQuery("(max-width: 48em)"); const { openConfirmModal } = useConfirmModal(); const t = useI18n(); const tManageAvatar = useScopedI18n("user.action.manageAvatar"); @@ -94,11 +95,11 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { }, [mutate, user.id, openConfirmModal, tManageAvatar]); return ( - - + + - + - - )} - - - ); -}; - -type FormType = Omit; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx index ee960b0f5..cf68c20e2 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx @@ -6,18 +6,13 @@ import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; -import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; import { createMetaTitle } from "~/metadata"; import { canAccessUserEditPage } from "../access"; -import { ChangeHomeBoardForm } from "./_components/_change-home-board"; -import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences"; import { DeleteUserButton } from "./_components/_delete-user-button"; -import { FirstDayOfWeek } from "./_components/_first-day-of-week"; -import { PingIconsEnabled } from "./_components/_ping-icons-enabled"; +import { UserGeneralSettingsForm } from "./_components/_general-settings-form"; import { UserProfileAvatarForm } from "./_components/_profile-avatar-form"; -import { UserProfileForm } from "./_components/_profile-form"; interface Props { params: Promise<{ @@ -63,7 +58,7 @@ export default async function EditUserPage(props: Props) { const boards = await api.board.getAllBoards(); const searchEngines = await api.searchEngine.getSelectable(); - + const isSelf = session?.user.id === user.id; const isCredentialsUser = user.provider === "credentials"; return ( @@ -73,51 +68,25 @@ export default async function EditUserPage(props: Props) { {t("management.page.user.fieldsDisabledExternalProvider")} )} - {tGeneral("title")} - - - + + + ({ + id: board.id, + name: board.name, + logoImageUrl: board.logoImageUrl, + }))} + searchEnginesData={searchEngines} + showLanguageSelector={isSelf} + /> - + - {session?.user.id === user.id && ( - - {tGeneral("item.language")} - - - )} - - - {tGeneral("item.board.title")} - ({ - id: board.id, - name: board.name, - logoImageUrl: board.logoImageUrl, - }))} - /> - - - - {tGeneral("item.search")} - - - - - {tGeneral("item.firstDayOfWeek")} - - - - - {tGeneral("item.accessibility")} - - - { +export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguageComboboxProps) => { const currentLocale = useCurrentLocale(); const { changeLocale, isPending } = useChangeLocale(); - return ; + return ( + + ); }; diff --git a/apps/nextjs/src/components/language/language-combobox.tsx b/apps/nextjs/src/components/language/language-combobox.tsx index 6f4b39b33..f6eaaac69 100644 --- a/apps/nextjs/src/components/language/language-combobox.tsx +++ b/apps/nextjs/src/components/language/language-combobox.tsx @@ -16,9 +16,17 @@ interface LanguageComboboxProps { onChange: (value: SupportedLanguage) => void; isPending?: boolean; width?: string; + withinPortal?: boolean; } -export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => { +export const LanguageCombobox = ({ + label, + value, + onChange, + isPending, + width, + withinPortal = false, +}: LanguageComboboxProps) => { const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption(), }); @@ -39,7 +47,7 @@ export const LanguageCombobox = ({ label, value, onChange, isPending, width }: L }, [combobox]); return ( - + onBlur?: (event: React.FocusEvent) => void; onFocus?: (event: React.FocusEvent) => void; w?: string; + withinPortal?: boolean; } type Props = SelectWithCustomItemsProps & { @@ -35,6 +36,7 @@ export const SelectWithCustomItems = ({ SelectOption, w, clearable, + withinPortal = false, ...props }: Props) => { const combobox = useCombobox({ @@ -71,7 +73,7 @@ export const SelectWithCustomItems = ({ const _clearable = clearable && Boolean(_value); return ( - + =18'} + + '@tanstack/form-core@1.27.7': + resolution: {integrity: sha512-nvogpyE98fhb0NDw1Bf2YaCH+L7ZIUgEpqO9TkHucDn6zg3ni521boUpv0i8HKIrmmFwDYjWZoCnrgY4HYWTkw==} + '@tanstack/match-sorter-utils@8.19.4': resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==} engines: {node: '>=12'} @@ -4899,6 +4906,12 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-store@0.8.0': + resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-table@8.20.5': resolution: {integrity: sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==} engines: {node: '>=12'} @@ -4912,6 +4925,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/store@0.7.7': + resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + + '@tanstack/store@0.8.0': + resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tanstack/table-core@8.20.5': resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==} engines: {node: '>=12'} @@ -12161,6 +12180,14 @@ snapshots: '@tabler/icons@3.44.0': {} + '@tanstack/devtools-event-client@0.4.0': {} + + '@tanstack/form-core@1.27.7': + dependencies: + '@tanstack/devtools-event-client': 0.4.0 + '@tanstack/pacer-lite': 0.1.1 + '@tanstack/store': 0.7.7 + '@tanstack/match-sorter-utils@8.19.4': dependencies: remove-accents: 0.5.0 @@ -12198,6 +12225,10 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + '@tanstack/store@0.7.7': {} + + '@tanstack/store@0.8.0': {} + '@tanstack/table-core@8.20.5': {} '@tanstack/virtual-core@3.11.2': {}