diff --git a/apps/frontend/app/auth/layout.tsx b/apps/frontend/app/auth/layout.tsx new file mode 100644 index 0000000..271b593 --- /dev/null +++ b/apps/frontend/app/auth/layout.tsx @@ -0,0 +1,7 @@ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx new file mode 100644 index 0000000..5099a8f --- /dev/null +++ b/apps/frontend/app/auth/login/page.tsx @@ -0,0 +1 @@ +export { LoginPage as default } from 'pages/login'; diff --git a/apps/frontend/app/auth/register/page.tsx b/apps/frontend/app/auth/register/page.tsx new file mode 100644 index 0000000..7210324 --- /dev/null +++ b/apps/frontend/app/auth/register/page.tsx @@ -0,0 +1 @@ +export { RegisterPage as default } from 'pages/register'; diff --git a/apps/frontend/next-env.d.ts b/apps/frontend/next-env.d.ts index c05d9f7..cdb6b7b 100644 --- a/apps/frontend/next-env.d.ts +++ b/apps/frontend/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import './.next/types/routes.d.ts'; +import './.next/dev/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/frontend/src/pages/login/index.ts b/apps/frontend/src/pages/login/index.ts new file mode 100644 index 0000000..270ae4d --- /dev/null +++ b/apps/frontend/src/pages/login/index.ts @@ -0,0 +1 @@ +export { default as LoginPage } from './ui/LoginPage'; diff --git a/apps/frontend/src/pages/login/model/loginSchema.ts b/apps/frontend/src/pages/login/model/loginSchema.ts new file mode 100644 index 0000000..079bc1c --- /dev/null +++ b/apps/frontend/src/pages/login/model/loginSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const formSchema = z.object({ + email: z.email('Неверный формат email'), + password: z.string().min(6, 'Минимум 6 символов').max(32, 'Слишком длинный пароль'), +}); + +export type FormState = z.infer; diff --git a/apps/frontend/src/pages/login/ui/LoginForm.tsx b/apps/frontend/src/pages/login/ui/LoginForm.tsx new file mode 100644 index 0000000..bf72783 --- /dev/null +++ b/apps/frontend/src/pages/login/ui/LoginForm.tsx @@ -0,0 +1,55 @@ +'use client'; +import Link from 'next/link'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { formSchema, FormState } from '../model/loginSchema'; +import { Field, FieldDescription, FieldLabel, Button, Input } from 'shared/ui'; + +export function LoginForm() { + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(formSchema), + mode: 'onChange', + }); + const onSubmit = (data: FormState): void => { + console.log(data); + reset(); + }; + return ( + + + + Почта + + {errors.email && ( + {errors.email.message} + )} + + + + Пароль + + Забыли пароль? + + + + + + {errors.password && ( + + {errors.password.message} + + )} + + + Войти + + ); +} diff --git a/apps/frontend/src/pages/login/ui/LoginPage.tsx b/apps/frontend/src/pages/login/ui/LoginPage.tsx new file mode 100644 index 0000000..120a3c9 --- /dev/null +++ b/apps/frontend/src/pages/login/ui/LoginPage.tsx @@ -0,0 +1,21 @@ +'use client'; +import { LoginForm } from './LoginForm'; +import Link from 'next/link'; +export default function LoginPage() { + return ( + + # Task-tracker + + С возвращением + + + + Нет аккаунта? + + Зарегистрироваться + + + + + ); +} diff --git a/apps/frontend/src/pages/register/index.ts b/apps/frontend/src/pages/register/index.ts new file mode 100644 index 0000000..4498c04 --- /dev/null +++ b/apps/frontend/src/pages/register/index.ts @@ -0,0 +1 @@ +export { default as RegisterPage } from './ui/RegisterPage'; diff --git a/apps/frontend/src/pages/register/model/registerSchema.ts b/apps/frontend/src/pages/register/model/registerSchema.ts new file mode 100644 index 0000000..7bed4e8 --- /dev/null +++ b/apps/frontend/src/pages/register/model/registerSchema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const formSchema = z.object({ + email: z.email('Неверный формат email'), + name: z.string().min(2, 'Слишком короткое имя').max(15, 'Слишком длинное имя'), + password: z.string().min(6, 'Минимум 6 символов').max(32, 'Слишком длинный пароль'), +}); + +export type FormState = z.infer; diff --git a/apps/frontend/src/pages/register/ui/RegisterForm.tsx b/apps/frontend/src/pages/register/ui/RegisterForm.tsx new file mode 100644 index 0000000..612438b --- /dev/null +++ b/apps/frontend/src/pages/register/ui/RegisterForm.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Input } from 'shared/ui/input'; +import { formSchema, FormState } from '../model/registerSchema'; +import { Field, FieldDescription, FieldLabel } from 'shared/ui/field'; +import { Button } from 'shared/ui'; + +export function RegisterForm() { + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(formSchema), + mode: 'onChange', + }); + const onSubmit = (data: FormState): void => { + console.log(data); + reset(); + }; + return ( + + + + Почта + + {errors.email && ( + {errors.email.message} + )} + + + Имя + + {errors.name && ( + + {errors.name.message} + + )} + + + Пароль + + {errors.password && ( + {errors.password.message} + )} + + + Зарегистрироваться + + ); +} diff --git a/apps/frontend/src/pages/register/ui/RegisterPage.tsx b/apps/frontend/src/pages/register/ui/RegisterPage.tsx new file mode 100644 index 0000000..8d3a162 --- /dev/null +++ b/apps/frontend/src/pages/register/ui/RegisterPage.tsx @@ -0,0 +1,12 @@ +'use client'; +import { RegisterForm } from './RegisterForm'; + +export default function RegisterPage() { + return ( + + # Task-tracker + С возвращением + + + ); +} diff --git a/apps/frontend/src/shared/ui/field.tsx b/apps/frontend/src/shared/ui/field.tsx new file mode 100644 index 0000000..4bef5e0 --- /dev/null +++ b/apps/frontend/src/shared/ui/field.tsx @@ -0,0 +1,222 @@ +import { useMemo } from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from 'shared/lib'; +import { Label } from 'shared/ui/label'; +import { Separator } from 'shared/ui/separator'; + +function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { + return ( + [data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', + className + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = 'legend', + ...props +}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +const fieldVariants = cva('group/field flex w-full gap-2 data-[invalid=true]:text-destructive', { + variants: { + orientation: { + vertical: 'flex-col *:w-full [&>.sr-only]:w-auto', + horizontal: + 'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + responsive: + 'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + }, + }, + defaultVariants: { + orientation: 'vertical', + }, +}); + +function Field({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( + + ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +function FieldLabel({ className, ...props }: React.ComponentProps) { + return ( + [data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10', + 'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col', + className + )} + {...props} + /> + ); +} + +function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( + a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', + className + )} + {...props} + /> + ); +} + +function FieldSeparator({ + children, + className, + ...props +}: React.ComponentProps<'div'> & { + children?: React.ReactNode; +}) { + return ( + + + {children && ( + + {children} + + )} + + ); +} + +function FieldError({ + className, + children, + errors, + ...props +}: React.ComponentProps<'div'> & { + errors?: Array<{ message?: string } | undefined>; +}) { + const content = useMemo(() => { + if (children) { + return children; + } + + if (!errors?.length) { + return null; + } + + const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()]; + + if (uniqueErrors?.length == 1) { + return uniqueErrors[0]?.message; + } + + return ( + + {uniqueErrors.map((error, index) => error?.message && {error.message})} + + ); + }, [children, errors]); + + if (!content) { + return null; + } + + return ( + + {content} + + ); +} + +export { + Field, + FieldLabel, + FieldDescription, + FieldError, + FieldGroup, + FieldLegend, + FieldSeparator, + FieldSet, + FieldContent, + FieldTitle, +}; diff --git a/apps/frontend/src/shared/ui/index.ts b/apps/frontend/src/shared/ui/index.ts index d1dd940..3293e46 100644 --- a/apps/frontend/src/shared/ui/index.ts +++ b/apps/frontend/src/shared/ui/index.ts @@ -1,3 +1,7 @@ export * from './alert-dialog'; export * from './button'; export * from './card'; +export * from './field'; +export * from './input'; +export * from './label'; +export * from './separator'; diff --git a/apps/frontend/src/shared/ui/input.tsx b/apps/frontend/src/shared/ui/input.tsx new file mode 100644 index 0000000..045827f --- /dev/null +++ b/apps/frontend/src/shared/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; + +import { cn } from 'shared/lib'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ); +} + +export { Input }; diff --git a/apps/frontend/src/shared/ui/label.tsx b/apps/frontend/src/shared/ui/label.tsx new file mode 100644 index 0000000..cba4f8b --- /dev/null +++ b/apps/frontend/src/shared/ui/label.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { Label as LabelPrimitive } from 'radix-ui'; + +import { cn } from 'shared/lib'; + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/apps/frontend/src/shared/ui/separator.tsx b/apps/frontend/src/shared/ui/separator.tsx new file mode 100644 index 0000000..21281a5 --- /dev/null +++ b/apps/frontend/src/shared/ui/separator.tsx @@ -0,0 +1,28 @@ +'use client'; + +import * as React from 'react'; +import { Separator as SeparatorPrimitive } from 'radix-ui'; + +import { cn } from 'shared/lib'; + +function Separator({ + className, + orientation = 'horizontal', + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator };
Нет аккаунта?
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', + className + )} + {...props} + /> + ); +} + +function FieldSeparator({ + children, + className, + ...props +}: React.ComponentProps<'div'> & { + children?: React.ReactNode; +}) { + return ( +