From a2f1b48b9aef22d5523b8b24005691d491c82048 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Wed, 29 Oct 2025 21:41:38 -0300 Subject: [PATCH] feature(auth): implement login and registration pages --- package-lock.json | 98 ++++++++++ package.json | 3 + src/components/AuthHeader.tsx | 79 +++++++++ src/components/Pagination.tsx | 2 - src/components/ui/alert.tsx | 66 +++++++ src/components/ui/avatar.tsx | 51 ++++++ src/components/ui/form.tsx | 167 ++++++++++++++++++ src/components/ui/label.tsx | 22 +++ src/contexts/AuthContext.tsx | 6 +- .../home/components/FilterSidebar.tsx | 2 - src/features/home/components/SortDropdown.tsx | 2 - src/pages/Home.tsx | 3 +- src/pages/Login.tsx | 117 ++++++++++++ src/pages/Register.tsx | 165 +++++++++++++++++ src/pages/Router.tsx | 10 ++ 15 files changed, 782 insertions(+), 11 deletions(-) create mode 100644 src/components/AuthHeader.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/pages/Login.tsx create mode 100644 src/pages/Register.tsx diff --git a/package-lock.json b/package-lock.json index b09e0fe..36eab9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", @@ -1086,6 +1089,18 @@ "@shikijs/vscode-textmate": "^10.0.2" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1462,6 +1477,33 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -1647,6 +1689,29 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menu": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", @@ -1961,6 +2026,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -2370,6 +2453,12 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@stoplight/json": { "version": "3.21.7", "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.21.7.tgz", @@ -8164,6 +8253,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utility-types": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", diff --git a/package.json b/package.json index 347b919..da5eeb8 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", diff --git a/src/components/AuthHeader.tsx b/src/components/AuthHeader.tsx new file mode 100644 index 0000000..e377b14 --- /dev/null +++ b/src/components/AuthHeader.tsx @@ -0,0 +1,79 @@ +import { LogIn, LogOut, Settings, User } from "lucide-react"; +import { Link, useNavigate } from "react-router"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar.tsx"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAuth } from "@/contexts/AuthContext.tsx"; + +export const AuthHeader = () => { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate("/"); + }; + + if (!user) { + return ( + + ); + } + + return ( + + + + + +
+
+

{user.name}

+

{user.email}

+
+
+ + + + + Profile + + + + + + Preferences + + + + + + Logout + +
+
+ ); +}; diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx index c90eb08..cfae130 100644 --- a/src/components/Pagination.tsx +++ b/src/components/Pagination.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..ecc8029 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..6812135 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..22c13ee --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client"; + +import type * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import * as React from "react"; +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext, + useFormState, +} from "react-hook-form"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId(); + + return ( + +
+ + ); +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField(); + + return ( +
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..8aa6ba0 --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,117 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useCallback } from "react"; +import { useForm } from "react-hook-form"; +import { Link } from "react-router"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form.tsx"; +import { Input } from "@/components/ui/input"; +import { useAuth } from "@/contexts/AuthContext.tsx"; + +const Login = () => { + const { login, isLoading } = useAuth(); + + const formSchema = z + .object({ + email: z.email(), + password: z.string().min(1, "Invalid password"), + }) + .required(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + const handleSubmit = useCallback( + async (values: z.infer) => { + await login(values.email, values.password); + }, + [formSchema, login], + ); + + return ( +
+ + + Welcome Back + Sign in to your MangaMochi account + + +
+ + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + +

+ Don't have an account?{" "} + + Create one + +

+ + +
+
+
+ ); +}; + +export default Login; diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx new file mode 100644 index 0000000..93b082e --- /dev/null +++ b/src/pages/Register.tsx @@ -0,0 +1,165 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useCallback } from "react"; +import { useForm } from "react-hook-form"; +import { Link } from "react-router"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form.tsx"; +import { Input } from "@/components/ui/input"; +import { useAuth } from "@/contexts/AuthContext.tsx"; + +const Register = () => { + const { register, isLoading } = useAuth(); + + const formSchema = z + .object({ + email: z.email(), + name: z.string().min(1, "Invalid name"), + password: z.string().min(1, "Invalid password"), + confirmPassword: z.string().min(1, "Invalid password"), + }) + .refine( + (data: { password: string; confirmPassword: string }) => + data.password === data.confirmPassword, + { + message: "Passwords don't match", + path: ["confirmPassword"], + }, + ); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + name: "", + password: "", + confirmPassword: "", + }, + }); + + const handleSubmit = useCallback( + async (data: z.infer) => { + await register(data.email, data.password, data.name); + }, + [register, formSchema], + ); + + return ( +
+ + + Create Account + + Join MangaMochi to read your favorite manga + + + +
+ + ( + + Email + + + + + + )} + /> + ( + + Name + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Confirm Password + + + + + + )} + /> + + +

+ Already have an account?{" "} + + Sign in + +

+ + +
+
+
+ ); +}; + +export default Register; diff --git a/src/pages/Router.tsx b/src/pages/Router.tsx index 53c5ee2..37ac28a 100644 --- a/src/pages/Router.tsx +++ b/src/pages/Router.tsx @@ -3,6 +3,8 @@ import { createBrowserRouter } from "react-router"; import { AppLayout } from "@/components/Layout/AppLayout.tsx"; const Home = lazy(() => import("./Home.tsx")); +const Login = lazy(() => import("./Login.tsx")); +const Register = lazy(() => import("./Register.tsx")); export const Router = createBrowserRouter([ { @@ -16,6 +18,14 @@ export const Router = createBrowserRouter([ index: true, element: , }, + { + path: "/login", + element: , + }, + { + path: "/register", + element: , + }, ], }, ]);