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;
}
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 {
id?: number;
/** @minLength 1 */
@ -192,9 +214,9 @@ export interface PageMangaImportJobDTO {
number?: number;
pageable?: PageableObject;
numberOfElements?: number;
sort?: SortObject;
first?: boolean;
last?: boolean;
sort?: SortObject;
empty?: boolean;
}
@ -254,9 +276,9 @@ export interface PageMangaListDTO {
number?: number;
pageable?: PageableObject;
numberOfElements?: number;
sort?: SortObject;
first?: boolean;
last?: boolean;
sort?: SortObject;
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 { useState } from "react";
import { useNavigate } from "react-router";
import { Button } from "@/components/ui/button";
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,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {Input} from "@/components/ui/input";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} 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 navigate = useNavigate();
const { user, logout } = useAuth();
const [itemsPerPage, setItemsPerPage] = useState(12);
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 {data} = useGetUserStatistics({query: {enabled: !!user}});
const statistics = data?.data;
const handleLogout = () => {
logout();
navigate("/");
};
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 handleSavePreferences = () => {
// updatePreferences({ itemsPerPage });
};
const handleLogout = () => {
logout();
navigate("/");
};
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>
const handleSavePreferences = () => {
// updatePreferences({ itemsPerPage });
};
<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>
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>
<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>
<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="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, 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="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="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>
<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, 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>
{/*{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>*/}
{/*)}*/}
<TabsContent value="stats" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Favorite Manga</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{statistics?.favoriteMangaCount}</div>
<p className="text-xs text-muted-foreground mt-1">Manga in your favorites</p>
</CardContent>
</Card>
{/*{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>*/}
{/*)}*/}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Manga Reading</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{statistics?.mangaReadingCount}</div>
<p className="text-xs text-muted-foreground mt-1">Manga you've started reading</p>
</CardContent>
</Card>
{/*{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>
);
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Chapters Read</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{statistics?.chaptersReadCount}</div>
<p className="text-xs text-muted-foreground mt-1">Total chapters completed</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Last Read</CardTitle>
</CardHeader>
<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;