Merge pull request 'refactor: Enhance Profile component with user statistics and improved reading progress display' (#30) from refactor into main

Reviewed-on: #30
This commit is contained in:
rov 2026-04-05 11:53:25 -03:00
commit ee47a05f9f
3 changed files with 358 additions and 192 deletions

View File

@ -89,6 +89,28 @@ export interface RefreshTokenRequestDTO {
refreshToken: string; refreshToken: string;
} }
export interface DefaultResponseDTOUserStatisticsDTO {
timestamp?: string;
data?: UserStatisticsDTO;
message?: string;
}
export interface UserRecentActivityDTO {
mangaTitle?: string;
contentTitle?: string;
readAt?: string;
mangaId?: number;
}
export interface UserStatisticsDTO {
favoriteMangaCount?: number;
mangaReadingCount?: number;
chaptersReadCount?: number;
lastReadContent?: UserRecentActivityDTO;
hasReadingActivity?: boolean;
recentActivities?: UserRecentActivityDTO[];
}
export interface ContentProviderDTO { export interface ContentProviderDTO {
id?: number; id?: number;
/** @minLength 1 */ /** @minLength 1 */
@ -192,9 +214,9 @@ export interface PageMangaImportJobDTO {
number?: number; number?: number;
pageable?: PageableObject; pageable?: PageableObject;
numberOfElements?: number; numberOfElements?: number;
sort?: SortObject;
first?: boolean; first?: boolean;
last?: boolean; last?: boolean;
sort?: SortObject;
empty?: boolean; empty?: boolean;
} }
@ -254,9 +276,9 @@ export interface PageMangaListDTO {
number?: number; number?: number;
pageable?: PageableObject; pageable?: PageableObject;
numberOfElements?: number; numberOfElements?: number;
sort?: SortObject;
first?: boolean; first?: boolean;
last?: boolean; last?: boolean;
sort?: SortObject;
empty?: boolean; empty?: boolean;
} }

View File

@ -0,0 +1,125 @@
/**
* Generated by orval v7.17.0 🍺
* Do not edit manually.
* OpenAPI definition
* OpenAPI spec version: v0
*/
import {
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import type {
DefaultResponseDTOUserStatisticsDTO
} from '../api.schemas';
import { customInstance } from '../../api';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* Get statistics for the logged in user.
* @summary Get user statistics
*/
export const getUserStatistics = (
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
) => {
return customInstance<DefaultResponseDTOUserStatisticsDTO>(
{url: `/user/statistics`, method: 'GET', signal
},
options);
}
export const getGetUserStatisticsQueryKey = () => {
return [
`/user/statistics`
] as const;
}
export const getGetUserStatisticsQueryOptions = <TData = Awaited<ReturnType<typeof getUserStatistics>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUserStatistics>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetUserStatisticsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getUserStatistics>>> = ({ signal }) => getUserStatistics(requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getUserStatistics>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetUserStatisticsQueryResult = NonNullable<Awaited<ReturnType<typeof getUserStatistics>>>
export type GetUserStatisticsQueryError = unknown
export function useGetUserStatistics<TData = Awaited<ReturnType<typeof getUserStatistics>>, TError = unknown>(
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUserStatistics>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getUserStatistics>>,
TError,
Awaited<ReturnType<typeof getUserStatistics>>
> , 'initialData'
>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetUserStatistics<TData = Awaited<ReturnType<typeof getUserStatistics>>, TError = unknown>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUserStatistics>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getUserStatistics>>,
TError,
Awaited<ReturnType<typeof getUserStatistics>>
> , 'initialData'
>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetUserStatistics<TData = Awaited<ReturnType<typeof getUserStatistics>>, TError = unknown>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUserStatistics>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get user statistics
*/
export function useGetUserStatistics<TData = Awaited<ReturnType<typeof getUserStatistics>>, TError = unknown>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUserStatistics>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetUserStatisticsQueryOptions(options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}

View File

@ -1,210 +1,229 @@
import { LogOut, Settings, User } from "lucide-react"; import {LogOut, Settings, User} from "lucide-react";
import { useState } from "react"; import {useState} from "react";
import { useNavigate } from "react-router"; import {useNavigate} from "react-router";
import { Button } from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@/components/ui/tabs.tsx"; } from "@/components/ui/tabs.tsx";
import { useAuth } from "@/contexts/AuthContext.tsx"; import {useAuth} from "@/contexts/AuthContext.tsx";
import {useGetUserStatistics} from "@/api/generated/user-statistics/user-statistics.ts";
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
const Profile = () => { const Profile = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuth(); const {user, logout} = useAuth();
const [itemsPerPage, setItemsPerPage] = useState(12); const [itemsPerPage, setItemsPerPage] = useState(12);
if (!user) { const {data} = useGetUserStatistics({query: {enabled: !!user}});
return ( const statistics = data?.data;
<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 = () => { if (!user) {
logout(); return (
navigate("/"); <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 handleSavePreferences = () => { const handleLogout = () => {
// updatePreferences({ itemsPerPage }); logout();
}; navigate("/");
};
return ( const handleSavePreferences = () => {
<div className="min-h-screen bg-background"> // updatePreferences({ itemsPerPage });
<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"> return (
<TabsList className="grid w-full grid-cols-3"> <div className="min-h-screen bg-background">
<TabsTrigger value="account" className="gap-2"> <div className="mx-auto max-w-2xl px-4 py-8">
<User className="h-4 w-4" /> <div className="mb-8 flex items-center justify-between">
Account <h1 className="text-3xl font-bold">Profile</h1>
</TabsTrigger> <Button
<TabsTrigger value="preferences" className="gap-2"> variant="outline"
<Settings className="h-4 w-4" /> onClick={handleLogout}
Preferences className="gap-2 bg-transparent"
</TabsTrigger> >
<TabsTrigger value="stats">Stats</TabsTrigger> <LogOut className="h-4 w-4"/>
</TabsList> Logout
</Button>
</div>
<TabsContent value="account" className="space-y-4"> <Tabs defaultValue="account" className="space-y-6">
<Card> <TabsList className="grid w-full grid-cols-3">
<CardHeader> <TabsTrigger value="account" className="gap-2">
<CardTitle>Account Information</CardTitle> <User className="h-4 w-4"/>
<CardDescription>Your account details</CardDescription> Account
</CardHeader> </TabsTrigger>
<CardContent className="space-y-4"> <TabsTrigger value="preferences" className="gap-2">
<div className="space-y-2"> <Settings className="h-4 w-4"/>
<label className="text-sm font-medium">Username</label> Preferences
<Input value={user.name} disabled /> </TabsTrigger>
</div> <TabsTrigger value="stats">Stats</TabsTrigger>
<div className="space-y-2"> </TabsList>
<label className="text-sm font-medium">Email</label>
<Input value={user.email} disabled />
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="preferences" className="space-y-4"> <TabsContent value="account" className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Preferences</CardTitle> <CardTitle>Account Information</CardTitle>
<CardDescription>Customize your experience</CardDescription> <CardDescription>Your account details</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="itemsPerPage" className="text-sm font-medium"> <label className="text-sm font-medium">Username</label>
Items Per Page <Input value={user.name} disabled/>
</label> </div>
<Input <div className="space-y-2">
id="itemsPerPage" <label className="text-sm font-medium">Email</label>
type="number" <Input value={user.email} disabled/>
min="6" </div>
max="48" </CardContent>
step="6" </Card>
value={itemsPerPage} </TabsContent>
onChange={(e) =>
setItemsPerPage(Number.parseInt(e.target.value, 10))
}
/>
<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"> <TabsContent value="preferences" className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Reading Statistics</CardTitle> <CardTitle>Preferences</CardTitle>
<CardDescription>Your manga reading activity</CardDescription> <CardDescription>Customize your experience</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="space-y-2">
<div className="rounded-lg bg-card p-4"> <label htmlFor="itemsPerPage" className="text-sm font-medium">
<p className="text-sm text-muted-foreground"> Items Per Page
Favorite Manga </label>
</p> <Input
<p className="text-2xl font-bold"> id="itemsPerPage"
{/*{user.favorites.length}*/} type="number"
</p> min="6"
</div> max="48"
<div className="rounded-lg bg-card p-4"> step="6"
<p className="text-sm text-muted-foreground"> value={itemsPerPage}
Manga Reading onChange={(e) =>
</p> setItemsPerPage(Number.parseInt(e.target.value, 10))
<p className="text-2xl font-bold"> }
{/*{Object.keys(user.chaptersRead).length}*/} />
</p> <p className="text-xs text-muted-foreground">
</div> Number of manga to display per page (6-48)
</div> </p>
</div>
<Button onClick={handleSavePreferences}>
Save Preferences
</Button>
</CardContent>
</Card>
</TabsContent>
{/*{user.favorites.length > 0 && (*/} <TabsContent value="stats" className="space-y-4">
{/* <div className="space-y-2">*/} <div className="grid gap-4 md:grid-cols-2">
{/* <h3 className="font-semibold">Favorite Manga IDs</h3>*/} <Card>
{/* <div className="flex flex-wrap gap-2">*/} <CardHeader className="pb-3">
{/* {user.favorites.map((id) => (*/} <CardTitle className="text-sm font-medium">Favorite Manga</CardTitle>
{/* <span*/} </CardHeader>
{/* key={id}*/} <CardContent>
{/* className="rounded-full bg-primary/10 px-3 py-1 text-sm"*/} <div className="text-3xl font-bold">{statistics?.favoriteMangaCount}</div>
{/* >*/} <p className="text-xs text-muted-foreground mt-1">Manga in your favorites</p>
{/* #{id}*/} </CardContent>
{/* </span>*/} </Card>
{/* ))}*/}
{/* </div>*/}
{/* </div>*/}
{/*)}*/}
{/*{Object.keys(user.chaptersRead).length > 0 && (*/} <Card>
{/* <div className="space-y-2">*/} <CardHeader className="pb-3">
{/* <h3 className="font-semibold">Reading Progress</h3>*/} <CardTitle className="text-sm font-medium">Manga Reading</CardTitle>
{/* <div className="space-y-1 text-sm">*/} </CardHeader>
{/* {Object.entries(user.chaptersRead).map(*/} <CardContent>
{/* ([mangaId, chapter]) => (*/} <div className="text-3xl font-bold">{statistics?.mangaReadingCount}</div>
{/* <p key={mangaId} className="text-muted-foreground">*/} <p className="text-xs text-muted-foreground mt-1">Manga you've started reading</p>
{/* Manga #{mangaId}: Chapter {chapter}*/} </CardContent>
{/* </p>*/} </Card>
{/* ),*/}
{/* )}*/}
{/* </div>*/}
{/* </div>*/}
{/*)}*/}
{/*{user.favorites.length === 0 &&*/} <Card>
{/* Object.keys(user.chaptersRead).length === 0 && (*/} <CardHeader className="pb-3">
{/* <Alert>*/} <CardTitle className="text-sm font-medium">Chapters Read</CardTitle>
{/* <AlertDescription>*/} </CardHeader>
{/* No reading activity yet. Start adding favorites and*/} <CardContent>
{/* tracking chapters!*/} <div className="text-3xl font-bold">{statistics?.chaptersReadCount}</div>
{/* </AlertDescription>*/} <p className="text-xs text-muted-foreground mt-1">Total chapters completed</p>
{/* </Alert>*/} </CardContent>
{/* )}*/} </Card>
</CardContent>
</Card> <Card>
</TabsContent> <CardHeader className="pb-3">
</Tabs> <CardTitle className="text-sm font-medium">Last Read</CardTitle>
</div> </CardHeader>
</div> <CardContent>
); <div className="text-sm font-medium">
{statistics?.lastReadContent?.readAt
? new Date(statistics.lastReadContent.readAt).toLocaleDateString()
: "Never"}
</div>
{statistics?.lastReadContent && (
<div className="text-sm font-light">
{statistics.lastReadContent.mangaTitle}: {statistics.lastReadContent.contentTitle}
</div>)
}
<p className="text-xs text-muted-foreground mt-1">Your most recent reading
activity</p>
</CardContent>
</Card>
</div>
{statistics?.recentActivities && statistics?.recentActivities.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Reading Progress</CardTitle>
<CardDescription>Your progress across manga titles</CardDescription>
</CardHeader>
<CardContent>
{statistics.recentActivities.map((activity) => (
<div key={`${activity.mangaId}-${activity.readAt}`}
className="flex items-center justify-between rounded-lg bg-card p-3">
<span className="text-sm font-medium">
{activity.mangaTitle}
</span>
<span className="text-sm text-muted-foreground">
{activity.contentTitle}
</span>
</div>
))}
</CardContent>
</Card>
)}
{!statistics?.hasReadingActivity && (
<Alert>
<AlertDescription>
No reading activity yet. Start exploring manga and tracking your progress!
</AlertDescription>
</Alert>
)}
</TabsContent>
</Tabs>
</div>
</div>
);
}; };
export default Profile; export default Profile;