feature(profile): add profile page with user account and preferences
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful

This commit is contained in:
Rodrigo Verdiani 2025-10-30 16:25:07 -03:00
parent dd41f10234
commit 1fadfeb313
6 changed files with 312 additions and 1 deletions

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>MangaMochi</title>
</head>
<body>
<div id="root"></div>

31
package-lock.json generated
View File

@ -17,6 +17,7 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/react-query": "^5.90.5",
"axios": "^1.13.1",
@ -2024,6 +2025,36 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"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-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",

View File

@ -19,6 +19,7 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/react-query": "^5.90.5",
"axios": "^1.13.1",

View File

@ -0,0 +1,64 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

210
src/pages/Profile.tsx Normal file
View File

@ -0,0 +1,210 @@
import { LogOut, Settings, User } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { useAuth } from "@/contexts/AuthContext.tsx";
const Profile = () => {
const navigate = useNavigate();
const { user, logout } = useAuth();
const [itemsPerPage, setItemsPerPage] = useState(12);
if (!user) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Access Denied</CardTitle>
<CardDescription>
Please log in to view your profile
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => navigate("/login")} className="w-full">
Go to Login
</Button>
</CardContent>
</Card>
</div>
);
}
const handleLogout = () => {
logout();
navigate("/");
};
const handleSavePreferences = () => {
// updatePreferences({ itemsPerPage });
};
return (
<div className="min-h-screen bg-background">
<div className="mx-auto max-w-2xl px-4 py-8">
<div className="mb-8 flex items-center justify-between">
<h1 className="text-3xl font-bold">Profile</h1>
<Button
variant="outline"
onClick={handleLogout}
className="gap-2 bg-transparent"
>
<LogOut className="h-4 w-4" />
Logout
</Button>
</div>
<Tabs defaultValue="account" className="space-y-6">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="account" className="gap-2">
<User className="h-4 w-4" />
Account
</TabsTrigger>
<TabsTrigger value="preferences" className="gap-2">
<Settings className="h-4 w-4" />
Preferences
</TabsTrigger>
<TabsTrigger value="stats">Stats</TabsTrigger>
</TabsList>
<TabsContent value="account" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>Your account details</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Username</label>
<Input value={user.name} disabled />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Email</label>
<Input value={user.email} disabled />
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="preferences" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Preferences</CardTitle>
<CardDescription>Customize your experience</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="itemsPerPage" className="text-sm font-medium">
Items Per Page
</label>
<Input
id="itemsPerPage"
type="number"
min="6"
max="48"
step="6"
value={itemsPerPage}
onChange={(e) =>
setItemsPerPage(Number.parseInt(e.target.value))
}
/>
<p className="text-xs text-muted-foreground">
Number of manga to display per page (6-48)
</p>
</div>
<Button onClick={handleSavePreferences}>
Save Preferences
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="stats" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Reading Statistics</CardTitle>
<CardDescription>Your manga reading activity</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-card p-4">
<p className="text-sm text-muted-foreground">
Favorite Manga
</p>
<p className="text-2xl font-bold">
{/*{user.favorites.length}*/}
</p>
</div>
<div className="rounded-lg bg-card p-4">
<p className="text-sm text-muted-foreground">
Manga Reading
</p>
<p className="text-2xl font-bold">
{/*{Object.keys(user.chaptersRead).length}*/}
</p>
</div>
</div>
{/*{user.favorites.length > 0 && (*/}
{/* <div className="space-y-2">*/}
{/* <h3 className="font-semibold">Favorite Manga IDs</h3>*/}
{/* <div className="flex flex-wrap gap-2">*/}
{/* {user.favorites.map((id) => (*/}
{/* <span*/}
{/* key={id}*/}
{/* className="rounded-full bg-primary/10 px-3 py-1 text-sm"*/}
{/* >*/}
{/* #{id}*/}
{/* </span>*/}
{/* ))}*/}
{/* </div>*/}
{/* </div>*/}
{/*)}*/}
{/*{Object.keys(user.chaptersRead).length > 0 && (*/}
{/* <div className="space-y-2">*/}
{/* <h3 className="font-semibold">Reading Progress</h3>*/}
{/* <div className="space-y-1 text-sm">*/}
{/* {Object.entries(user.chaptersRead).map(*/}
{/* ([mangaId, chapter]) => (*/}
{/* <p key={mangaId} className="text-muted-foreground">*/}
{/* Manga #{mangaId}: Chapter {chapter}*/}
{/* </p>*/}
{/* ),*/}
{/* )}*/}
{/* </div>*/}
{/* </div>*/}
{/*)}*/}
{/*{user.favorites.length === 0 &&*/}
{/* Object.keys(user.chaptersRead).length === 0 && (*/}
{/* <Alert>*/}
{/* <AlertDescription>*/}
{/* No reading activity yet. Start adding favorites and*/}
{/* tracking chapters!*/}
{/* </AlertDescription>*/}
{/* </Alert>*/}
{/* )}*/}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);
};
export default Profile;

View File

@ -8,6 +8,7 @@ const Manga = lazy(() => import("./Manga.tsx"));
const Chapter = lazy(() => import("./Chapter.tsx"));
const Login = lazy(() => import("./Login.tsx"));
const Register = lazy(() => import("./Register.tsx"));
const Profile = lazy(() => import("./Profile.tsx"));
export const Router = createBrowserRouter([
{
@ -29,6 +30,10 @@ export const Router = createBrowserRouter([
path: "/register",
element: <Register />,
},
{
path: "/profile",
element: <Profile />,
},
{
path: "/import-review",
element: <ImportReview />,