From 8018d2101186f5c8c7b02eaed72c7eb069e4631a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 13 Aug 2025 06:46:56 +0300 Subject: [PATCH] [frontend]: add settings page --- frontend/package.json | 10 + .../app/(site)/dashboard/settings/page.tsx | 12 +- frontend/src/app/account/deactivate/page.tsx | 19 + frontend/src/app/layout.tsx | 4 + .../features/auth/forms/DeactivateForm.tsx | 179 ++++ .../components/features/user/UserSettings.tsx | 143 +++ .../features/user/account/ChangeEmailForm.tsx | 106 +++ .../user/account/ChangePasswordForm.tsx | 134 +++ .../features/user/account/DeactivateCard.tsx | 34 + .../user/account/totp/DisableTotp.tsx | 35 + .../features/user/account/totp/EnableTotp.tsx | 176 ++++ .../user/account/totp/WrapperTotp.tsx | 34 + .../user/appearance/ChangeColorForm.tsx | 47 + .../user/appearance/ChangeLanguageForm.tsx | 94 ++ .../user/appearance/ChangeThemeForm.tsx | 53 ++ .../ChangeNotificationsSettingsForm.tsx | 101 +++ .../user/profile/ChangeAvatarForm.tsx | 159 ++++ .../features/user/profile/ChangeInfoForm.tsx | 165 ++++ .../social-links-form/SocialLinkItem.tsx | 199 +++++ .../social-links-form/SocialLinksForm.tsx | 140 +++ .../social-links-form/SocialLinksList.tsx | 102 +++ .../features/user/sessions/SessionItem.tsx | 69 ++ .../features/user/sessions/SessionModal.tsx | 110 +++ .../features/user/sessions/SessionsList.tsx | 58 ++ .../src/components/ui/common/AlertDialog.tsx | 147 +++ frontend/src/components/ui/common/Dialog.tsx | 130 +++ frontend/src/components/ui/common/Select.tsx | 170 ++++ frontend/src/components/ui/common/Switch.tsx | 33 + frontend/src/components/ui/common/Tabs.tsx | 61 ++ .../src/components/ui/common/Textarea.tsx | 19 + .../components/ui/elements/CardContainer.tsx | 68 ++ .../components/ui/elements/ChannelAvatar.tsx | 12 +- .../components/ui/elements/ColorSwitcher.tsx | 23 + .../components/ui/elements/ConfirmModal.tsx | 62 ++ .../components/ui/elements/FormWrapper.tsx | 26 + .../src/components/ui/elements/Heading.tsx | 43 + .../src/components/ui/elements/ToggleCard.tsx | 34 + frontend/src/graphql/generated/output.ts | 837 +++++++++++++++++- .../mutations/auth/DeactivateAccount.graphql | 8 + .../mutations/user/ChangeEmail.graphql | 6 + .../user/ChangeNotificationsSettings.graphql | 9 + .../mutations/user/ChangePassword.graphql | 6 + .../user/ChangeProfileAvatar.graphql | 3 + .../mutations/user/ChangeProfileInfo.graphql | 7 + .../mutations/user/ClearSessionCookie.graphql | 3 + .../mutations/user/CreateSocialLink.graphql | 5 + .../mutations/user/DisableTotp.graphql | 3 + .../graphql/mutations/user/EnableTotp.graphql | 3 + .../user/RemoveProfileAvatar.graphql | 3 + .../mutations/user/RemoveSession.graphql | 3 + .../mutations/user/RemoveSocialLink.graphql | 3 + .../mutations/user/ReorderSocialLinks.graphql | 3 + .../mutations/user/UpdateSocialLink.graphql | 5 + .../FindChannelByUsername.graphql | 0 .../queries/user/FindCurrentSession.graphql | 19 + .../graphql/queries/user/FindProfile.graphql | 4 + .../queries/user/FindSessionsByUser.graphql | 19 + .../queries/user/FindSocialLinks.graphql | 8 + .../queries/user/GenerateTotpSecret.graphql | 6 + frontend/src/hooks/useConfig.ts | 11 + frontend/src/libs/apollo-client.ts | 34 +- .../src/libs/constants/colors.constants.ts | 36 + frontend/src/libs/constants/seo.constants.ts | 15 + frontend/src/libs/constants/url.constants.ts | 1 + .../src/schemas/auth/deactivate.schema.ts | 9 + frontend/src/schemas/upload-file.schema.ts | 14 + .../src/schemas/user/change-email.schema.ts | 7 + .../src/schemas/user/change-info.schema.ts | 12 + .../schemas/user/change-language.schema.ts | 9 + .../change-notifications-settings.schema.ts | 10 + .../schemas/user/change-password.schema.ts | 8 + .../src/schemas/user/change-theme.schema.ts | 7 + .../src/schemas/user/enable-totp.schema.ts | 7 + .../src/schemas/user/social-links.schema.ts | 8 + frontend/src/store/config/config.store.ts | 18 + frontend/src/store/config/config.types.ts | 6 + frontend/src/styles/themes.css | 113 +++ frontend/src/utils/format-date.ts | 40 + frontend/src/utils/get-browser-icon.ts | 32 + frontend/tailwind.config.ts | 48 +- frontend/yarn.lock | 218 ++++- 81 files changed, 4562 insertions(+), 75 deletions(-) create mode 100644 frontend/src/app/account/deactivate/page.tsx create mode 100644 frontend/src/components/features/auth/forms/DeactivateForm.tsx create mode 100644 frontend/src/components/features/user/UserSettings.tsx create mode 100644 frontend/src/components/features/user/account/ChangeEmailForm.tsx create mode 100644 frontend/src/components/features/user/account/ChangePasswordForm.tsx create mode 100644 frontend/src/components/features/user/account/DeactivateCard.tsx create mode 100644 frontend/src/components/features/user/account/totp/DisableTotp.tsx create mode 100644 frontend/src/components/features/user/account/totp/EnableTotp.tsx create mode 100644 frontend/src/components/features/user/account/totp/WrapperTotp.tsx create mode 100644 frontend/src/components/features/user/appearance/ChangeColorForm.tsx create mode 100644 frontend/src/components/features/user/appearance/ChangeLanguageForm.tsx create mode 100644 frontend/src/components/features/user/appearance/ChangeThemeForm.tsx create mode 100644 frontend/src/components/features/user/notifications/ChangeNotificationsSettingsForm.tsx create mode 100644 frontend/src/components/features/user/profile/ChangeAvatarForm.tsx create mode 100644 frontend/src/components/features/user/profile/ChangeInfoForm.tsx create mode 100644 frontend/src/components/features/user/profile/social-links-form/SocialLinkItem.tsx create mode 100644 frontend/src/components/features/user/profile/social-links-form/SocialLinksForm.tsx create mode 100644 frontend/src/components/features/user/profile/social-links-form/SocialLinksList.tsx create mode 100644 frontend/src/components/features/user/sessions/SessionItem.tsx create mode 100644 frontend/src/components/features/user/sessions/SessionModal.tsx create mode 100644 frontend/src/components/features/user/sessions/SessionsList.tsx create mode 100644 frontend/src/components/ui/common/AlertDialog.tsx create mode 100644 frontend/src/components/ui/common/Dialog.tsx create mode 100644 frontend/src/components/ui/common/Select.tsx create mode 100644 frontend/src/components/ui/common/Switch.tsx create mode 100644 frontend/src/components/ui/common/Tabs.tsx create mode 100644 frontend/src/components/ui/common/Textarea.tsx create mode 100644 frontend/src/components/ui/elements/CardContainer.tsx create mode 100644 frontend/src/components/ui/elements/ColorSwitcher.tsx create mode 100644 frontend/src/components/ui/elements/ConfirmModal.tsx create mode 100644 frontend/src/components/ui/elements/FormWrapper.tsx create mode 100644 frontend/src/components/ui/elements/Heading.tsx create mode 100644 frontend/src/components/ui/elements/ToggleCard.tsx create mode 100644 frontend/src/graphql/mutations/auth/DeactivateAccount.graphql create mode 100644 frontend/src/graphql/mutations/user/ChangeEmail.graphql create mode 100644 frontend/src/graphql/mutations/user/ChangeNotificationsSettings.graphql create mode 100644 frontend/src/graphql/mutations/user/ChangePassword.graphql create mode 100644 frontend/src/graphql/mutations/user/ChangeProfileAvatar.graphql create mode 100644 frontend/src/graphql/mutations/user/ChangeProfileInfo.graphql create mode 100644 frontend/src/graphql/mutations/user/ClearSessionCookie.graphql create mode 100644 frontend/src/graphql/mutations/user/CreateSocialLink.graphql create mode 100644 frontend/src/graphql/mutations/user/DisableTotp.graphql create mode 100644 frontend/src/graphql/mutations/user/EnableTotp.graphql create mode 100644 frontend/src/graphql/mutations/user/RemoveProfileAvatar.graphql create mode 100644 frontend/src/graphql/mutations/user/RemoveSession.graphql create mode 100644 frontend/src/graphql/mutations/user/RemoveSocialLink.graphql create mode 100644 frontend/src/graphql/mutations/user/ReorderSocialLinks.graphql create mode 100644 frontend/src/graphql/mutations/user/UpdateSocialLink.graphql rename frontend/src/graphql/queries/{user => channels}/FindChannelByUsername.graphql (100%) create mode 100644 frontend/src/graphql/queries/user/FindCurrentSession.graphql create mode 100644 frontend/src/graphql/queries/user/FindSessionsByUser.graphql create mode 100644 frontend/src/graphql/queries/user/FindSocialLinks.graphql create mode 100644 frontend/src/graphql/queries/user/GenerateTotpSecret.graphql create mode 100644 frontend/src/hooks/useConfig.ts create mode 100644 frontend/src/libs/constants/colors.constants.ts create mode 100644 frontend/src/libs/constants/seo.constants.ts create mode 100644 frontend/src/schemas/auth/deactivate.schema.ts create mode 100644 frontend/src/schemas/upload-file.schema.ts create mode 100644 frontend/src/schemas/user/change-email.schema.ts create mode 100644 frontend/src/schemas/user/change-info.schema.ts create mode 100644 frontend/src/schemas/user/change-language.schema.ts create mode 100644 frontend/src/schemas/user/change-notifications-settings.schema.ts create mode 100644 frontend/src/schemas/user/change-password.schema.ts create mode 100644 frontend/src/schemas/user/change-theme.schema.ts create mode 100644 frontend/src/schemas/user/enable-totp.schema.ts create mode 100644 frontend/src/schemas/user/social-links.schema.ts create mode 100644 frontend/src/store/config/config.store.ts create mode 100644 frontend/src/store/config/config.types.ts create mode 100644 frontend/src/styles/themes.css create mode 100644 frontend/src/utils/format-date.ts create mode 100644 frontend/src/utils/get-browser-icon.ts diff --git a/frontend/package.json b/frontend/package.json index 28cb974..7e50bd0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,14 +15,22 @@ "@graphql-codegen/typescript": "4.1.6", "@graphql-codegen/typescript-operations": "4.6.1", "@graphql-codegen/typescript-react-apollo": "4.3.3", + "@hello-pangea/dnd": "18.0.1", "@hookform/resolvers": "5.2.1", + "@pbe/react-yandex-maps": "1.2.5", + "@radix-ui/react-alert-dialog": "1.1.14", "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-select": "2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.5", + "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "1.2.7", + "apollo-upload-client": "18.0.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "graphql": "16.11.0", @@ -35,7 +43,9 @@ "react": "19.1.1", "react-dom": "19.1.1", "react-hook-form": "7.61.1", + "react-icons": "5.5.0", "sonner": "2.0.6", + "subscriptions-transport-ws": "0.11.0", "tailwind-merge": "3.3.1", "zod": "4.0.14", "zustand": "5.0.7" diff --git a/frontend/src/app/(site)/dashboard/settings/page.tsx b/frontend/src/app/(site)/dashboard/settings/page.tsx index f9178a0..abdb641 100644 --- a/frontend/src/app/(site)/dashboard/settings/page.tsx +++ b/frontend/src/app/(site)/dashboard/settings/page.tsx @@ -1,19 +1,21 @@ import { getTranslations } from 'next-intl/server'; +import { UserSettings } from '@/components/features/user/UserSettings'; + import type { Metadata } from 'next'; export async function generateMetadata(): Promise { - const t = await getTranslations('layout.header.headerMenu.profileMenu'); + const t = await getTranslations('dashboard.settings.header'); return { - title: t('dashboard'), + title: t('heading'), + description: t('description'), + robots: { index: false, follow: false }, }; } const DashboardSettings = () => ( -
- DashboardSettings page -
+ ); export default DashboardSettings; diff --git a/frontend/src/app/account/deactivate/page.tsx b/frontend/src/app/account/deactivate/page.tsx new file mode 100644 index 0000000..683d213 --- /dev/null +++ b/frontend/src/app/account/deactivate/page.tsx @@ -0,0 +1,19 @@ +import { getTranslations } from 'next-intl/server'; + +import { DeactivateForm } from '@/components/features/auth/forms/DeactivateForm'; +import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants'; + +import type { Metadata } from 'next'; + +export async function generateMetadata(): Promise { + const t = await getTranslations('auth.deactivate'); + + return { + title: t('heading'), + ...NO_INDEX_PAGE, + }; +} + +export default function DeactivatePage() { + return ; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index b676ca0..b66445b 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -2,11 +2,13 @@ import { Geist } from 'next/font/google'; import { NextIntlClientProvider } from 'next-intl'; import { getLocale, getMessages } from 'next-intl/server'; +import { ColorSwitcher } from '@/components/ui/elements/ColorSwitcher'; import ApolloClientProvider from '@/providers/ApolloClientProvider'; import { ThemeProvider } from '@/providers/ThemeProvider'; import { ToastProvider } from '@/providers/ToastProvider'; import '../styles/globals.css'; +import '../styles/themes.css'; import type { Metadata } from 'next'; import type { ReactNode } from 'react'; @@ -31,6 +33,8 @@ const RootLayout = async ({ return ( + + { + const t = useTranslations('auth.deactivate'); + + const { exit } = useAuth(); + + const router = useRouter(); + + const [isShowConfirm, setIsShowConfirm] = useState(false); + + const form = useForm({ + resolver: zodResolver(deactivateSchema), + defaultValues: { + email: '', + password: '', + }, + }); + + const [deactivate, { loading: isLoadingDeactivate }] = useDeactivateAccountMutation({ + onCompleted(data) { + if (data.deactivateAccount.message) { + setIsShowConfirm(true); + } else { + exit(); + toast.success(t('successMessage')); + router.push('/'); + } + }, + onError() { + toast.error(t('errorMessage')); + }, + }); + + const { isValid } = form.formState; + + function onSubmit(data: TypeDeactivateSchema) { + deactivate({ variables: { data } }); + } + + return ( + +
+ + {isShowConfirm + ? ( + ( + + + {t('pinLabel')} + + + + + + + + + + + + + + + + + + + + + + {t('pinDescription')} + + + )} + /> + ) + : ( + <> + ( + + + {t('emailLabel')} + + + + + + + + {t('emailDescription')} + + + )} + /> + + ( + + + {t('passwordLabel')} + + + + + + + + {t('passwordDescription')} + + + )} + /> + + )} + + + + +
+ ); +}; diff --git a/frontend/src/components/features/user/UserSettings.tsx b/frontend/src/components/features/user/UserSettings.tsx new file mode 100644 index 0000000..a27368b --- /dev/null +++ b/frontend/src/components/features/user/UserSettings.tsx @@ -0,0 +1,143 @@ +import { useTranslations } from 'next-intl'; + +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/common/Tabs'; +import { Heading } from '@/components/ui/elements/Heading'; + +import { ChangeEmailForm } from './account/ChangeEmailForm'; +import { ChangePasswordForm } from './account/ChangePasswordForm'; +import { DeactivateCard } from './account/DeactivateCard'; +import { WrapperTotp } from './account/totp/WrapperTotp'; +import { ChangeColorForm } from './appearance/ChangeColorForm'; +import { ChangeLanguageForm } from './appearance/ChangeLanguageForm'; +import { ChangeThemeForm } from './appearance/ChangeThemeForm'; +import { ChangeNotificationsSettingsForm } from './notifications/ChangeNotificationsSettingsForm'; +import { ChangeAvatarForm } from './profile/ChangeAvatarForm'; +import { ChangeInfoForm } from './profile/ChangeInfoForm'; +import { SocialLinksForm } from './profile/social-links-form/SocialLinksForm'; +import { SessionsList } from './sessions/SessionsList'; + +export const UserSettings = () => { + const t = useTranslations('dashboard.settings'); + + return ( +
+ + + + + + {t('header.profile')} + + + + {t('header.account')} + + + + {t('header.appearance')} + + + + {t('header.notifications')} + + + + {t('header.sessions')} + + + + +
+ + + + + + + +
+
+ + +
+ + + + + + + + + + + + + +
+
+ + +
+ + + + + + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+
+
+ ); +}; diff --git a/frontend/src/components/features/user/account/ChangeEmailForm.tsx b/frontend/src/components/features/user/account/ChangeEmailForm.tsx new file mode 100644 index 0000000..ca923c5 --- /dev/null +++ b/frontend/src/components/features/user/account/ChangeEmailForm.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/common/Button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from '@/components/ui/common/Form'; +import { Input } from '@/components/ui/common/Input'; +import { Separator } from '@/components/ui/common/Separator'; +import { Skeleton } from '@/components/ui/common/Skeleton'; +import { FormWrapper } from '@/components/ui/elements/FormWrapper'; +import { useChangeEmailMutation } from '@/graphql/generated/output'; +import { useCurrent } from '@/hooks/useCurrent'; +import { + type TypeChangeEmailSchema, + changeEmailSchema, +} from '@/schemas/user/change-email.schema'; + +export const ChangeEmailFormSkeleton = () => ; + +export const ChangeEmailForm = () => { + const t = useTranslations('dashboard.settings.account.email'); + + const { user, isLoadingProfile, refetch } = useCurrent(); + + const form = useForm({ + resolver: zodResolver(changeEmailSchema), + values: { + email: user?.email ?? '', + }, + }); + + const [update, { loading: isLoadingUpdate }] = useChangeEmailMutation({ + onCompleted() { + void refetch(); + toast.success(t('successMessage')); + }, + onError() { + toast.error(t('errorMessage')); + }, + }); + + const { isValid, isDirty } = form.formState; + + function onSubmit(data: TypeChangeEmailSchema) { + void update({ variables: { data } }); + } + + return isLoadingProfile + ? ( + + ) + : ( + +
+ + ( + + + {t('emailLabel')} + + + + + + + + {t('emailDescription')} + + + )} + /> + + + +
+ +
+ + +
+ ); +}; diff --git a/frontend/src/components/features/user/account/ChangePasswordForm.tsx b/frontend/src/components/features/user/account/ChangePasswordForm.tsx new file mode 100644 index 0000000..2510d86 --- /dev/null +++ b/frontend/src/components/features/user/account/ChangePasswordForm.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/common/Button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from '@/components/ui/common/Form'; +import { Input } from '@/components/ui/common/Input'; +import { Separator } from '@/components/ui/common/Separator'; +import { Skeleton } from '@/components/ui/common/Skeleton'; +import { FormWrapper } from '@/components/ui/elements/FormWrapper'; +import { useChangePasswordMutation } from '@/graphql/generated/output'; +import { useCurrent } from '@/hooks/useCurrent'; +import { + type TypeChangePasswordSchema, + changePasswordSchema, +} from '@/schemas/user/change-password.schema'; + +export const ChangePasswordFormSkeleton = () => ; + +export const ChangePasswordForm = () => { + const t = useTranslations('dashboard.settings.account.password'); + + const { isLoadingProfile, refetch } = useCurrent(); + + const form = useForm({ + resolver: zodResolver(changePasswordSchema), + values: { + oldPassword: '', + newPassword: '', + }, + }); + + const [update, { loading: isLoadingUpdate }] = useChangePasswordMutation({ + onCompleted() { + form.reset(); + void refetch(); + toast.success(t('successMessage')); + }, + onError() { + toast.error(t('errorMessage')); + }, + }); + + const { isValid } = form.formState; + + function onSubmit(data: TypeChangePasswordSchema) { + void update({ variables: { data } }); + } + + return isLoadingProfile + ? ( + + ) + : ( + +
+ + ( + + + {t('oldPasswordLabel')} + + + + + + + + {t('oldPasswordDescription')} + + + )} + /> + + + + ( + + + {t('newPasswordLabel')} + + + + + + + + {t('newPasswordDescription')} + + + )} + /> + + + +
+ +
+ + +
+ ); +}; diff --git a/frontend/src/components/features/user/account/DeactivateCard.tsx b/frontend/src/components/features/user/account/DeactivateCard.tsx new file mode 100644 index 0000000..9d9bd6d --- /dev/null +++ b/frontend/src/components/features/user/account/DeactivateCard.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; + +import { Button } from '@/components/ui/common/Button'; +import { CardContainer } from '@/components/ui/elements/CardContainer'; +import { ConfirmModal } from '@/components/ui/elements/ConfirmModal'; + +export const DeactivateCard = () => { + const t = useTranslations('dashboard.settings.account.deactivation'); + + const router = useRouter(); + + return ( + + { router.push('/account/deactivate'); }} + > + + + + )} + /> + ); +}; diff --git a/frontend/src/components/features/user/account/totp/DisableTotp.tsx b/frontend/src/components/features/user/account/totp/DisableTotp.tsx new file mode 100644 index 0000000..6582aca --- /dev/null +++ b/frontend/src/components/features/user/account/totp/DisableTotp.tsx @@ -0,0 +1,35 @@ +import { useTranslations } from 'next-intl'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/common/Button'; +import { ConfirmModal } from '@/components/ui/elements/ConfirmModal'; +import { useDisableTotpMutation } from '@/graphql/generated/output'; +import { useCurrent } from '@/hooks/useCurrent'; + +export const DisableTotp = () => { + const t = useTranslations('dashboard.settings.account.twoFactor.disable'); + + const { refetch } = useCurrent(); + + const [disable, { loading: isLoadingDisable }] = useDisableTotpMutation({ + onCompleted() { + void refetch(); + toast.success(t('successMessage')); + }, + onError() { + toast.error(t('errorMessage')); + }, + }); + + return ( + disable()} + > + + + ); +}; diff --git a/frontend/src/components/features/user/account/totp/EnableTotp.tsx b/frontend/src/components/features/user/account/totp/EnableTotp.tsx new file mode 100644 index 0000000..a7c6ddf --- /dev/null +++ b/frontend/src/components/features/user/account/totp/EnableTotp.tsx @@ -0,0 +1,176 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import Image from 'next/image'; +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/common/Button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/common/Dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from '@/components/ui/common/Form'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from '@/components/ui/common/InputOTP'; +import { + useEnableTotpMutation, + useGenerateTotpSecretQuery, +} from '@/graphql/generated/output'; +import { useCurrent } from '@/hooks/useCurrent'; +import { + type TypeEnableTotpSchema, + enableTotpSchema, +} from '@/schemas/user/enable-totp.schema'; + +export const EnableTotp = () => { + const t = useTranslations('dashboard.settings.account.twoFactor.enable'); + + const [isOpen, setIsOpen] = useState(false); + const { refetch } = useCurrent(); + + const { data, loading: isLoadingGenerate } = useGenerateTotpSecretQuery(); + const twoFactorAuth = data?.generateTotpSecret; + + const form = useForm({ + resolver: zodResolver(enableTotpSchema), + defaultValues: { + pin: '', + }, + }); + + const [enable, { loading: isLoadingEnable }] = useEnableTotpMutation({ + onCompleted() { + void refetch(); + setIsOpen(false); + toast.success(t('successMessage')); + }, + onError() { + toast.error(t('errorMessage')); + }, + }); + + const { isValid } = form.formState; + + function onSubmit(values: TypeEnableTotpSchema) { + void enable({ + variables: { + data: { + secret: twoFactorAuth?.secret ?? '', + pin: values.pin, + }, + }, + }); + } + + return ( + + + + + + + + + {t('heading')} + + + +
+ +
+ + {twoFactorAuth?.qrcodeUrl + ? t('qrInstructions') + : ''} + + + QR +
+ +
+ + {twoFactorAuth?.secret + ? t('secretCodeLabel') + + twoFactorAuth.secret + : ''} + +
+ + ( + + + {t('pinLabel')} + + + + + + + + + + + + + + + + + + + + + + {t('pinDescription')} + + + )} + /> + + + + + + +
+
+ ); +}; diff --git a/frontend/src/components/features/user/account/totp/WrapperTotp.tsx b/frontend/src/components/features/user/account/totp/WrapperTotp.tsx new file mode 100644 index 0000000..652ae48 --- /dev/null +++ b/frontend/src/components/features/user/account/totp/WrapperTotp.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +import { Skeleton } from '@/components/ui/common/Skeleton'; +import { CardContainer } from '@/components/ui/elements/CardContainer'; +import { useCurrent } from '@/hooks/useCurrent'; + +import { DisableTotp } from './DisableTotp'; +import { EnableTotp } from './EnableTotp'; + +export const WrapperTotpSkeleton = () => ; + +export const WrapperTotp = () => { + const t = useTranslations('dashboard.settings.account.twoFactor'); + + const { user, isLoadingProfile } = useCurrent(); + + return isLoadingProfile + ? ( + + ) + : ( + + {!user?.isTotpEnabled ? : } + + )} + /> + ); +}; diff --git a/frontend/src/components/features/user/appearance/ChangeColorForm.tsx b/frontend/src/components/features/user/appearance/ChangeColorForm.tsx new file mode 100644 index 0000000..7734a28 --- /dev/null +++ b/frontend/src/components/features/user/appearance/ChangeColorForm.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { Check } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +import { CardContainer } from '@/components/ui/elements/CardContainer'; +import { useConfig } from '@/hooks/useConfig'; +import { BASE_COLORS } from '@/libs/constants/colors.constants'; + +import type { CSSProperties } from 'react'; + +export const ChangeColorForm = () => { + const t = useTranslations('dashboard.settings.appearance.color'); + + const config = useConfig(); + + return ( + + {BASE_COLORS.map((theme) => { + const isActive = config.theme === theme.name; + + return ( + + ); + })} + + )} + /> + ); +}; diff --git a/frontend/src/components/features/user/appearance/ChangeLanguageForm.tsx b/frontend/src/components/features/user/appearance/ChangeLanguageForm.tsx new file mode 100644 index 0000000..ff8c66e --- /dev/null +++ b/frontend/src/components/features/user/appearance/ChangeLanguageForm.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLocale, useTranslations } from 'next-intl'; +import { useTransition } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; + +import { Form, FormField } from '@/components/ui/common/Form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/common/Select'; +import { CardContainer } from '@/components/ui/elements/CardContainer'; +import { setCurrentLanguage } from '@/libs/i18n/language'; +import { + changeLanguageSchema, +} from '@/schemas/user/change-language.schema'; + +import type { TypeChangeLanguageSchema } from '@/schemas/user/change-language.schema'; + +const languages = { + ru: 'Русский', + en: 'English', +}; + +export const ChangeLanguageForm = () => { + const t = useTranslations('dashboard.settings.appearance.language'); + + const [isPending, startTransition] = useTransition(); + const locale = useLocale(); + + const form = useForm({ + resolver: zodResolver(changeLanguageSchema), + values: { + language: locale, + }, + }); + + function onSubmit(data: TypeChangeLanguageSchema) { + startTransition(async () => { + try { + await setCurrentLanguage(data.language); + } catch (error) { + toast.success(t('successMessage')); + } + }); + } + + return ( + + ( + + )} + /> + + )} + /> + ); +}; diff --git a/frontend/src/components/features/user/appearance/ChangeThemeForm.tsx b/frontend/src/components/features/user/appearance/ChangeThemeForm.tsx new file mode 100644 index 0000000..32d2d68 --- /dev/null +++ b/frontend/src/components/features/user/appearance/ChangeThemeForm.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; +import { useTheme } from 'next-themes'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; + +import { Form, FormField } from '@/components/ui/common/Form'; +import { ToggleCard } from '@/components/ui/elements/ToggleCard'; +import { + changeThemeSchema, +} from '@/schemas/user/change-theme.schema'; + +import type { TypeChangeThemeSchema } from '@/schemas/user/change-theme.schema'; + +export const ChangeThemeForm = () => { + const t = useTranslations('dashboard.settings.appearance.theme'); + + const { theme, setTheme } = useTheme(); + + const form = useForm({ + resolver: zodResolver(changeThemeSchema), + values: { + theme: theme === 'dark' ? 'dark' : 'light', + }, + }); + + const onChange = (value: boolean) => { + const newTheme = value ? 'dark' : 'light'; + + setTheme(newTheme); + form.setValue('theme', newTheme); + toast.success(t('successMessage')); + }; + + return ( +
+ ( + + )} + /> + + ); +}; diff --git a/frontend/src/components/features/user/notifications/ChangeNotificationsSettingsForm.tsx b/frontend/src/components/features/user/notifications/ChangeNotificationsSettingsForm.tsx new file mode 100644 index 0000000..02f41cd --- /dev/null +++ b/frontend/src/components/features/user/notifications/ChangeNotificationsSettingsForm.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; + +import { Form, FormField } from '@/components/ui/common/Form'; +import { + ToggleCard, + ToggleCardSkeleton, +} from '@/components/ui/elements/ToggleCard'; +import { useChangeNotificationsSettingsMutation } from '@/graphql/generated/output'; +import { useCurrent } from '@/hooks/useCurrent'; +import { + type TypeChangeNotificationsSettingsSchema, + changeNotificationsSettingsSchema, +} from '@/schemas/user/change-notifications-settings.schema'; + +export const ChangeNotificationsSettingsForm = () => { + const t = useTranslations('dashboard.settings.notifications'); + + const { user, isLoadingProfile, refetch } = useCurrent(); + + const form = useForm({ + resolver: zodResolver(changeNotificationsSettingsSchema), + values: { + siteNotifications: + user?.notificationSettings?.siteNotifications ?? false, + telegramNotifications: + user?.notificationSettings?.telegramNotifications ?? false, + }, + }); + + const [update, { loading: isLoadingUpdate }] = useChangeNotificationsSettingsMutation({ + onCompleted(data) { + void refetch(); + toast.success(t('successMessage')); + + if (data.changeNotificationSettings.telegramAuthToken) { + window.open( + `https://t.me/ksv741_teastream_bot?start=${data.changeNotificationSettings.telegramAuthToken}`, + '_blank', + ); + } + }, + onError() { + toast.error(t('errorMessage')); + }, + }); + + function onChange( + field: keyof TypeChangeNotificationsSettingsSchema, + value: boolean, + ) { + form.setValue(field, value); + + void update({ + variables: { + data: { ...form.getValues(), [field]: value }, + }, + }); + } + + return isLoadingProfile + ? Array.from({ length: 2 }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + + )) + : ( +
+ ( + { onChange('siteNotifications', value); }} + /> + )} + /> + + ( + { onChange('telegramNotifications', value); }} + /> + )} + /> + + ); +}; diff --git a/frontend/src/components/features/user/profile/ChangeAvatarForm.tsx b/frontend/src/components/features/user/profile/ChangeAvatarForm.tsx new file mode 100644 index 0000000..7de6ae1 --- /dev/null +++ b/frontend/src/components/features/user/profile/ChangeAvatarForm.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trash } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { type ChangeEvent, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/common/Button'; +import { Form, FormField } from '@/components/ui/common/Form'; +import { Skeleton } from '@/components/ui/common/Skeleton'; +import { ChannelAvatar } from '@/components/ui/elements/ChannelAvatar'; +import { ConfirmModal } from '@/components/ui/elements/ConfirmModal'; +import { FormWrapper } from '@/components/ui/elements/FormWrapper'; +import { + useChangeProfileAvatarMutation, + useRemoveProfileAvatarMutation, +} from '@/graphql/generated/output'; +import { useCurrent } from '@/hooks/useCurrent'; +import { + type TypeUploadFileSchema, + uploadFileSchema, +} from '@/schemas/upload-file.schema'; + +export const ChangeAvatarFormSkeleton = () => ; + +export const ChangeAvatarForm = () => { + const t = useTranslations('dashboard.settings.profile.avatar'); + + const { user, isLoadingProfile, refetch } = useCurrent(); + + const inputRef = useRef(null); + + const form = useForm({ + resolver: zodResolver(uploadFileSchema), + values: { + file: user?.avatar ?? '', + }, + }); + + const [update, { loading: isLoadingUpdate }] = useChangeProfileAvatarMutation({ + onCompleted() { + void refetch(); + toast.success(t('successUpdateMessage')); + }, + onError() { + toast.error(t('errorUpdateMessage')); + }, + }); + + const [remove, { loading: isLoadingRemove }] = useRemoveProfileAvatarMutation({ + onCompleted() { + void refetch(); + toast.success(t('successRemoveMessage')); + }, + onError() { + toast.error(t('errorRemoveMessage')); + }, + }); + + function handleImageChange(event: ChangeEvent) { + const file = event.target.files?.[0]; + + if (file) { + void update({ variables: { avatar: file } }); + form.setValue('file', file); + } + } + + if (!user) { + return null; + } + + + return isLoadingProfile + ? ( + + ) + : ( + +
+ ( +
+
+ + +
+
+ + + + + {user?.avatar + ? ( + remove()} + > + + + ) + : null} +
+ +

+ {t('info')} +

+
+
+
+ )} + /> + +
+ ); +}; diff --git a/frontend/src/components/features/user/profile/ChangeInfoForm.tsx b/frontend/src/components/features/user/profile/ChangeInfoForm.tsx new file mode 100644 index 0000000..82f1b2a --- /dev/null +++ b/frontend/src/components/features/user/profile/ChangeInfoForm.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/common/Button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from '@/components/ui/common/Form'; +import { Input } from '@/components/ui/common/Input'; +import { Separator } from '@/components/ui/common/Separator'; +import { Skeleton } from '@/components/ui/common/Skeleton'; +import { Textarea } from '@/components/ui/common/Textarea'; +import { FormWrapper } from '@/components/ui/elements/FormWrapper'; +import { useChangeProfileInfoMutation } from '@/graphql/generated/output'; +import { useCurrent } from '@/hooks/useCurrent'; +import { + type TypeChangeInfoSchema, + changeInfoSchema, +} from '@/schemas/user/change-info.schema'; + +export const ChangeInfoFormSkeleton = () => ; + +export const ChangeInfoForm = () => { + const t = useTranslations('dashboard.settings.profile.info'); + + const { user, isLoadingProfile, refetch } = useCurrent(); + + const form = useForm({ + resolver: zodResolver(changeInfoSchema), + values: { + name: user?.name ?? '', + displayName: user?.displayName ?? '', + bio: user?.bio ?? '', + }, + }); + + const [update, { loading: isLoadingUpdate }] = useChangeProfileInfoMutation( + { + onCompleted() { + void refetch(); + toast.success(t('successMessage')); + }, + onError() { + toast.error(t('errorMessage')); + }, + }, + ); + + const { isValid, isDirty } = form.formState; + + function onSubmit(data: TypeChangeInfoSchema) { + void update({ variables: { data } }); + } + + return isLoadingProfile + ? ( + + ) + : ( + +
+ + ( + + + {t('usernameLabel')} + + + + + + + + {t('usernameDescription')} + + + )} + /> + + + + ( + + + {t('displayNameLabel')} + + + + + + + + {t('displayNameDescription')} + + + )} + /> + + + + ( + + + {t('bioLabel')} + + + +