feat: add favorite manga functionality

This commit is contained in:
Rodrigo Verdiani 2025-10-21 22:31:12 -03:00
parent 4fa67fcc81
commit 05ad5330f5
6 changed files with 400 additions and 112 deletions

View File

@ -60,6 +60,7 @@ export interface MangaListDTO {
genres: string[]; genres: string[];
authors: string[]; authors: string[];
score: number; score: number;
favorite: boolean;
} }
export interface PageMangaListDTO { export interface PageMangaListDTO {
@ -69,9 +70,9 @@ export interface PageMangaListDTO {
content?: MangaListDTO[]; content?: MangaListDTO[];
number?: number; number?: number;
pageable?: PageableObject; pageable?: PageableObject;
sort?: SortObject;
first?: boolean; first?: boolean;
last?: boolean; last?: boolean;
sort?: SortObject;
numberOfElements?: number; numberOfElements?: number;
empty?: boolean; empty?: boolean;
} }
@ -154,6 +155,7 @@ export type GetMangasParams = {
searchQuery?: string; searchQuery?: string;
genreIds?: number[]; genreIds?: number[];
statuses?: string[]; statuses?: string[];
userFavorites?: boolean;
/** /**
* Zero-based page index (0..N) * Zero-based page index (0..N)
* @minimum 0 * @minimum 0
@ -260,6 +262,182 @@ export const useFetchMangaChapters = <TError = unknown, TContext = unknown>(
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
}; };
/**
* Remove a manga from favorites for the logged user.
* @summary Unfavorite a manga
*/
export const setUnfavorite = (
id: number,
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<void>(
{
url: `http://192.168.1.142:8080/mangas/${encodeURIComponent(String(id))}/unfavorite`,
method: "POST",
signal,
},
options,
);
};
export const getSetUnfavoriteMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof setUnfavorite>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customInstance>;
}): UseMutationOptions<
Awaited<ReturnType<typeof setUnfavorite>>,
TError,
{ id: number },
TContext
> => {
const mutationKey = ["setUnfavorite"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof setUnfavorite>>,
{ id: number }
> = (props) => {
const { id } = props ?? {};
return setUnfavorite(id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type SetUnfavoriteMutationResult = NonNullable<
Awaited<ReturnType<typeof setUnfavorite>>
>;
export type SetUnfavoriteMutationError = unknown;
/**
* @summary Unfavorite a manga
*/
export const useSetUnfavorite = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof setUnfavorite>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof setUnfavorite>>,
TError,
{ id: number },
TContext
> => {
const mutationOptions = getSetUnfavoriteMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* Set a manga as favorite for the logged user.
* @summary Favorite a manga
*/
export const setFavorite = (
id: number,
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<void>(
{
url: `http://192.168.1.142:8080/mangas/${encodeURIComponent(String(id))}/favorite`,
method: "POST",
signal,
},
options,
);
};
export const getSetFavoriteMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof setFavorite>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customInstance>;
}): UseMutationOptions<
Awaited<ReturnType<typeof setFavorite>>,
TError,
{ id: number },
TContext
> => {
const mutationKey = ["setFavorite"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof setFavorite>>,
{ id: number }
> = (props) => {
const { id } = props ?? {};
return setFavorite(id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type SetFavoriteMutationResult = NonNullable<
Awaited<ReturnType<typeof setFavorite>>
>;
export type SetFavoriteMutationError = unknown;
/**
* @summary Favorite a manga
*/
export const useSetFavorite = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof setFavorite>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof setFavorite>>,
TError,
{ id: number },
TContext
> => {
const mutationOptions = getSetFavoriteMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/** /**
* Mark a chapter as read by its ID. * Mark a chapter as read by its ID.
* @summary Mark a chapter as read * @summary Mark a chapter as read

View File

@ -19,18 +19,19 @@ export default function HomePage() {
const [selectedGenres, setSelectedGenres] = useState<number[]>([]); const [selectedGenres, setSelectedGenres] = useState<number[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string[]>([]); const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
const [minRating, setMinRating] = useState(0); const [minRating, setMinRating] = useState(0);
const { user } = useAuth(); const [userFavorites, setUserFavorites] = useState(false);
const mangas = useGetMangas({ const { data: mangas, queryKey } = useGetMangas({
page: currentPage - 1, page: currentPage - 1,
size: ITEMS_PER_PAGE, size: ITEMS_PER_PAGE,
sort: ["id"], sort: ["id"],
searchQuery: searchQuery, searchQuery: searchQuery,
statuses: selectedStatus, statuses: selectedStatus,
genreIds: selectedGenres, genreIds: selectedGenres,
userFavorites,
}); });
const totalPages = mangas?.data?.totalPages; const totalPages = mangas?.totalPages;
return ( return (
<div className="flex min-h-screen bg-background"> <div className="flex min-h-screen bg-background">
@ -38,9 +39,11 @@ export default function HomePage() {
selectedGenres={selectedGenres} selectedGenres={selectedGenres}
selectedStatus={selectedStatus} selectedStatus={selectedStatus}
minRating={minRating} minRating={minRating}
userFavorites={userFavorites}
onGenresChange={setSelectedGenres} onGenresChange={setSelectedGenres}
onStatusChange={setSelectedStatus} onStatusChange={setSelectedStatus}
onRatingChange={setMinRating} onRatingChange={setMinRating}
onUserFavoritesChange={setUserFavorites}
/> />
{/* Main Content */} {/* Main Content */}
@ -57,7 +60,7 @@ export default function HomePage() {
MangaMochi MangaMochi
</h1> </h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{mangas?.data?.totalElements} titles available {mangas?.totalElements} titles available
</p> </p>
</div> </div>
</div> </div>
@ -86,9 +89,9 @@ export default function HomePage() {
</header> </header>
<main className="px-8 py-8"> <main className="px-8 py-8">
{mangas?.data?.content && mangas.data.content.length > 0 ? ( {mangas?.content && mangas.content.length > 0 ? (
<> <>
<MangaGrid manga={mangas?.data?.content} /> <MangaGrid manga={mangas?.content} queryKey={queryKey} />
{totalPages && totalPages > 1 && ( {totalPages && totalPages > 1 && (
<div className="mt-12"> <div className="mt-12">

View File

@ -18,10 +18,8 @@ import { useState } from "react";
export default function ProfilePage() { export default function ProfilePage() {
const router = useRouter(); const router = useRouter();
const { user, logout, updatePreferences } = useAuth(); const { user, logout } = useAuth();
const [itemsPerPage, setItemsPerPage] = useState( const [itemsPerPage, setItemsPerPage] = useState(12);
user?.preferences.itemsPerPage || 12,
);
if (!user) { if (!user) {
return ( return (
@ -49,7 +47,7 @@ export default function ProfilePage() {
}; };
const handleSavePreferences = () => { const handleSavePreferences = () => {
updatePreferences({ itemsPerPage }); // updatePreferences({ itemsPerPage });
}; };
return ( return (
@ -89,20 +87,12 @@ export default function ProfilePage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Username</label> <label className="text-sm font-medium">Username</label>
<Input value={user.username} disabled /> <Input value={user.name} disabled />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Email</label> <label className="text-sm font-medium">Email</label>
<Input value={user.email} disabled /> <Input value={user.email} disabled />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium">User ID</label>
<Input
value={user.id}
disabled
className="font-mono text-xs"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@ -153,7 +143,7 @@ export default function ProfilePage() {
Favorite Manga Favorite Manga
</p> </p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
{user.favorites.length} {/*{user.favorites.length}*/}
</p> </p>
</div> </div>
<div className="rounded-lg bg-card p-4"> <div className="rounded-lg bg-card p-4">
@ -161,51 +151,51 @@ export default function ProfilePage() {
Manga Reading Manga Reading
</p> </p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
{Object.keys(user.chaptersRead).length} {/*{Object.keys(user.chaptersRead).length}*/}
</p> </p>
</div> </div>
</div> </div>
{user.favorites.length > 0 && ( {/*{user.favorites.length > 0 && (*/}
<div className="space-y-2"> {/* <div className="space-y-2">*/}
<h3 className="font-semibold">Favorite Manga IDs</h3> {/* <h3 className="font-semibold">Favorite Manga IDs</h3>*/}
<div className="flex flex-wrap gap-2"> {/* <div className="flex flex-wrap gap-2">*/}
{user.favorites.map((id) => ( {/* {user.favorites.map((id) => (*/}
<span {/* <span*/}
key={id} {/* key={id}*/}
className="rounded-full bg-primary/10 px-3 py-1 text-sm" {/* className="rounded-full bg-primary/10 px-3 py-1 text-sm"*/}
> {/* >*/}
#{id} {/* #{id}*/}
</span> {/* </span>*/}
))} {/* ))}*/}
</div> {/* </div>*/}
</div> {/* </div>*/}
)} {/*)}*/}
{Object.keys(user.chaptersRead).length > 0 && ( {/*{Object.keys(user.chaptersRead).length > 0 && (*/}
<div className="space-y-2"> {/* <div className="space-y-2">*/}
<h3 className="font-semibold">Reading Progress</h3> {/* <h3 className="font-semibold">Reading Progress</h3>*/}
<div className="space-y-1 text-sm"> {/* <div className="space-y-1 text-sm">*/}
{Object.entries(user.chaptersRead).map( {/* {Object.entries(user.chaptersRead).map(*/}
([mangaId, chapter]) => ( {/* ([mangaId, chapter]) => (*/}
<p key={mangaId} className="text-muted-foreground"> {/* <p key={mangaId} className="text-muted-foreground">*/}
Manga #{mangaId}: Chapter {chapter} {/* Manga #{mangaId}: Chapter {chapter}*/}
</p> {/* </p>*/}
), {/* ),*/}
)} {/* )}*/}
</div> {/* </div>*/}
</div> {/* </div>*/}
)} {/*)}*/}
{user.favorites.length === 0 && {/*{user.favorites.length === 0 &&*/}
Object.keys(user.chaptersRead).length === 0 && ( {/* Object.keys(user.chaptersRead).length === 0 && (*/}
<Alert> {/* <Alert>*/}
<AlertDescription> {/* <AlertDescription>*/}
No reading activity yet. Start adding favorites and {/* No reading activity yet. Start adding favorites and*/}
tracking chapters! {/* tracking chapters!*/}
</AlertDescription> {/* </AlertDescription>*/}
</Alert> {/* </Alert>*/}
)} {/* )}*/}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>

View File

@ -5,14 +5,18 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Star, X } from "lucide-react"; import { Star, X } from "lucide-react";
import { useGetGenres } from "@/api/mangamochi"; import { useGetGenres } from "@/api/mangamochi";
import { useAuth } from "@/contexts/auth-context";
import { Switch } from "@/components/ui/switch";
interface FilterSidebarProps { interface FilterSidebarProps {
selectedGenres: number[]; selectedGenres: number[];
selectedStatus: string[]; selectedStatus: string[];
minRating: number; minRating: number;
userFavorites: boolean;
onGenresChange: (genres: number[]) => void; onGenresChange: (genres: number[]) => void;
onStatusChange: (status: string[]) => void; onStatusChange: (status: string[]) => void;
onRatingChange: (rating: number) => void; onRatingChange: (rating: number) => void;
onUserFavoritesChange: (favorites: boolean) => void;
} }
const STATUSES = ["Ongoing", "Completed", "Hiatus"]; const STATUSES = ["Ongoing", "Completed", "Hiatus"];
@ -28,11 +32,14 @@ export function FilterSidebar({
selectedGenres, selectedGenres,
selectedStatus, selectedStatus,
minRating, minRating,
userFavorites,
onGenresChange, onGenresChange,
onStatusChange, onStatusChange,
onRatingChange, onRatingChange,
onUserFavoritesChange,
}: FilterSidebarProps) { }: FilterSidebarProps) {
const { data: genresData } = useGetGenres(); const { data: genresData } = useGetGenres();
const { isAuthenticated } = useAuth();
const toggleGenre = (genre: number) => { const toggleGenre = (genre: number) => {
if (selectedGenres.includes(genre)) { if (selectedGenres.includes(genre)) {
@ -54,10 +61,14 @@ export function FilterSidebar({
onGenresChange([]); onGenresChange([]);
onStatusChange([]); onStatusChange([]);
onRatingChange(0); onRatingChange(0);
onUserFavoritesChange(false);
}; };
const hasActiveFilters = const hasActiveFilters =
selectedGenres.length > 0 || selectedStatus.length > 0 || minRating > 0; selectedGenres.length > 0 ||
selectedStatus.length > 0 ||
minRating > 0 ||
userFavorites;
return ( return (
<aside className="w-64 border-r border-border bg-sidebar"> <aside className="w-64 border-r border-border bg-sidebar">
@ -82,6 +93,27 @@ export function FilterSidebar({
{/* Filters */} {/* Filters */}
<div className="flex-1 overflow-y-auto px-6 py-6"> <div className="flex-1 overflow-y-auto px-6 py-6">
<div className="space-y-8"> <div className="space-y-8">
{isAuthenticated && (
<>
<div>
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
Content
</h3>
<div className="flex items-center justify-between rounded-md px-3 py-2">
<label className="text-sm text-sidebar-foreground">
Show Only Favorites
</label>
<Switch
checked={userFavorites}
onCheckedChange={onUserFavoritesChange}
/>
</div>
</div>
<Separator className="bg-sidebar-border" />
</>
)}
{/* Genres */} {/* Genres */}
<div> <div>
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground"> <h3 className="mb-3 text-sm font-medium text-sidebar-foreground">

View File

@ -1,8 +1,17 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { Star, Calendar, Database } from "lucide-react"; import { Star, Calendar, Database, Heart } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useCallback } from "react";
import {
PageMangaListDTO,
useSetFavorite,
useSetUnfavorite,
} from "@/api/mangamochi";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/auth-context";
interface Manga { interface Manga {
id: number; id: number;
@ -15,14 +24,19 @@ interface Manga {
authors: string[]; authors: string[];
genres: string[]; genres: string[];
score: number; score: number;
favorite: boolean;
// chapters: number // chapters: number
} }
interface MangaCardProps { interface MangaCardProps {
manga: Manga; manga: Manga;
queryKey: any;
} }
export function MangaCard({ manga }: MangaCardProps) { export function MangaCard({ manga, queryKey }: MangaCardProps) {
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const formatter = new Intl.DateTimeFormat("en-US", { const formatter = new Intl.DateTimeFormat("en-US", {
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
@ -42,11 +56,58 @@ export function MangaCard({ manga }: MangaCardProps) {
const author = manga.authors.join(", "); const author = manga.authors.join(", ");
const { mutate: mutateFavorite, isPending: isPendingFavorite } =
useSetFavorite({
mutation: {
onSuccess: () =>
queryClient.setQueryData(
queryKey,
(oldData: PageMangaListDTO | undefined) => {
return {
...oldData,
content:
oldData?.content?.map((m) =>
m.id === manga.id ? { ...m, favorite: true } : m,
) || [],
};
},
),
},
});
const { mutate: mutateUnfavorite, isPending: isPendingUnfavorite } =
useSetUnfavorite({
mutation: {
onSuccess: () =>
queryClient.setQueryData(
queryKey,
(oldData: PageMangaListDTO | undefined) => {
return {
...oldData,
content:
oldData?.content?.map((m) =>
m.id === manga.id ? { ...m, favorite: false } : m,
) || [],
};
},
),
},
});
const handleFavoriteClick = useCallback(
(isFavorite: boolean) => {
isFavorite
? mutateUnfavorite({ id: manga.id })
: mutateFavorite({ id: manga.id });
},
[mutateUnfavorite, manga.id, mutateFavorite],
);
return ( return (
<Link href={`/manga/${manga.id}`}> <Card className="h-full group overflow-hidden border-border bg-card py-0 gap-0 transition-all hover:border-primary/50 hover:shadow-lg hover:shadow-primary/10 cursor-pointer relative">
<Card className="group overflow-hidden border-border bg-card py-0 gap-0 transition-all hover:border-primary/50 hover:shadow-lg hover:shadow-primary/10 cursor-pointer"> <CardContent className="p-0">
<CardContent className="p-0"> <div className="relative aspect-[2/3] overflow-hidden bg-muted">
<div className="relative aspect-[2/3] overflow-hidden bg-muted"> <Link href={`/manga/${manga.id}`}>
<Image <Image
src={ src={
(manga.coverImageKey && (manga.coverImageKey &&
@ -66,55 +127,78 @@ export function MangaCard({ manga }: MangaCardProps) {
{manga.status} {manga.status}
</Badge> </Badge>
</div> </div>
</div> </Link>
{/* Info */} {isAuthenticated && (
<div className="space-y-2 p-4"> <Button
<h3 className="line-clamp-2 text-sm font-semibold leading-tight text-foreground"> size="icon"
variant="ghost"
onClick={(event) => {
event.preventDefault(); // stop <a> default nav
event.stopPropagation(); // stop bubbling to other elements
handleFavoriteClick(manga.favorite);
}}
className="absolute left-2 top-2 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background/90"
disabled={isPendingFavorite || isPendingUnfavorite}
>
<Heart
className={`h-4 w-4 transition-colors ${
manga.favorite
? "fill-red-500 text-red-500"
: "text-muted-foreground hover:text-red-500"
}`}
/>
</Button>
)}
</div>
{/* Info */}
<div className="space-y-2 p-4">
<Link href={`/manga/${manga.id}`}>
<h3 className="line-clamp-2 text-sm font-semibold leading-tight text-foreground hover:underline">
{manga.title} {manga.title}
</h3> </h3>
</Link>
<p className="text-xs text-muted-foreground">{author}</p> <p className="text-xs text-muted-foreground">{author}</p>
{publishedFrom && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
<span>{dateRange}</span>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
<span className="text-xs font-medium text-foreground">
{manga.score}
</span>
</div>
{/*<span className="text-xs text-muted-foreground">Ch. {manga.chapters}</span>*/}
</div>
{publishedFrom && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Database className="h-3 w-3" /> <Calendar className="h-3 w-3" />
<span> <span>{dateRange}</span>
{manga.providerCount}{" "} </div>
{manga.providerCount === 1 ? "provider" : "providers"} )}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
<span className="text-xs font-medium text-foreground">
{manga.score}
</span> </span>
</div> </div>
<div className="flex flex-wrap gap-1">
{manga.genres.map((genre) => (
<Badge
key={genre}
variant="outline"
className="border-border text-xs text-muted-foreground"
>
{genre}
</Badge>
))}
</div>
</div> </div>
</CardContent>
</Card> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
</Link> <Database className="h-3 w-3" />
<span>
{manga.providerCount}{" "}
{manga.providerCount === 1 ? "provider" : "providers"}
</span>
</div>
<div className="flex flex-wrap gap-1">
{manga.genres.map((genre) => (
<Badge
key={genre}
variant="outline"
className="border-border text-xs text-muted-foreground"
>
{genre}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
); );
} }

View File

@ -3,13 +3,14 @@ import { MangaListDTO } from "@/api/mangamochi";
interface MangaGridProps { interface MangaGridProps {
manga: MangaListDTO[]; manga: MangaListDTO[];
queryKey: any;
} }
export function MangaGrid({ manga }: MangaGridProps) { export function MangaGrid({ manga, queryKey }: MangaGridProps) {
return ( return (
<div className="grid grid-cols-2 gap-6 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> <div className="grid grid-cols-2 gap-6 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 auto-rows-fr">
{manga.map((item) => ( {manga.map((item) => (
<MangaCard key={item.id} manga={item} /> <MangaCard key={item.id} manga={item} queryKey={queryKey} />
))} ))}
</div> </div>
); );