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" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>MangaMochi</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"axios": "^1.13.1",
|
"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": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"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-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"axios": "^1.13.1",
|
"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 Chapter = lazy(() => import("./Chapter.tsx"));
|
||||||
const Login = lazy(() => import("./Login.tsx"));
|
const Login = lazy(() => import("./Login.tsx"));
|
||||||
const Register = lazy(() => import("./Register.tsx"));
|
const Register = lazy(() => import("./Register.tsx"));
|
||||||
|
const Profile = lazy(() => import("./Profile.tsx"));
|
||||||
|
|
||||||
export const Router = createBrowserRouter([
|
export const Router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -29,6 +30,10 @@ export const Router = createBrowserRouter([
|
|||||||
path: "/register",
|
path: "/register",
|
||||||
element: <Register />,
|
element: <Register />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/profile",
|
||||||
|
element: <Profile />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/import-review",
|
path: "/import-review",
|
||||||
element: <ImportReview />,
|
element: <ImportReview />,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user