feature(profile): add profile page with user account and preferences
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
This commit is contained in:
parent
dd41f10234
commit
1fadfeb313
@ -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
31
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
64
src/components/ui/tabs.tsx
Normal file
64
src/components/ui/tabs.tsx
Normal 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
210
src/pages/Profile.tsx
Normal 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;
|
||||
@ -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 />,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user