feat: add favorite manga functionality
This commit is contained in:
parent
4fa67fcc81
commit
05ad5330f5
@ -60,6 +60,7 @@ export interface MangaListDTO {
|
||||
genres: string[];
|
||||
authors: string[];
|
||||
score: number;
|
||||
favorite: boolean;
|
||||
}
|
||||
|
||||
export interface PageMangaListDTO {
|
||||
@ -69,9 +70,9 @@ export interface PageMangaListDTO {
|
||||
content?: MangaListDTO[];
|
||||
number?: number;
|
||||
pageable?: PageableObject;
|
||||
sort?: SortObject;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
sort?: SortObject;
|
||||
numberOfElements?: number;
|
||||
empty?: boolean;
|
||||
}
|
||||
@ -154,6 +155,7 @@ export type GetMangasParams = {
|
||||
searchQuery?: string;
|
||||
genreIds?: number[];
|
||||
statuses?: string[];
|
||||
userFavorites?: boolean;
|
||||
/**
|
||||
* Zero-based page index (0..N)
|
||||
* @minimum 0
|
||||
@ -260,6 +262,182 @@ export const useFetchMangaChapters = <TError = unknown, TContext = unknown>(
|
||||
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.
|
||||
* @summary Mark a chapter as read
|
||||
|
||||
15
app/page.tsx
15
app/page.tsx
@ -19,18 +19,19 @@ export default function HomePage() {
|
||||
const [selectedGenres, setSelectedGenres] = useState<number[]>([]);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
|
||||
const [minRating, setMinRating] = useState(0);
|
||||
const { user } = useAuth();
|
||||
const [userFavorites, setUserFavorites] = useState(false);
|
||||
|
||||
const mangas = useGetMangas({
|
||||
const { data: mangas, queryKey } = useGetMangas({
|
||||
page: currentPage - 1,
|
||||
size: ITEMS_PER_PAGE,
|
||||
sort: ["id"],
|
||||
searchQuery: searchQuery,
|
||||
statuses: selectedStatus,
|
||||
genreIds: selectedGenres,
|
||||
userFavorites,
|
||||
});
|
||||
|
||||
const totalPages = mangas?.data?.totalPages;
|
||||
const totalPages = mangas?.totalPages;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
@ -38,9 +39,11 @@ export default function HomePage() {
|
||||
selectedGenres={selectedGenres}
|
||||
selectedStatus={selectedStatus}
|
||||
minRating={minRating}
|
||||
userFavorites={userFavorites}
|
||||
onGenresChange={setSelectedGenres}
|
||||
onStatusChange={setSelectedStatus}
|
||||
onRatingChange={setMinRating}
|
||||
onUserFavoritesChange={setUserFavorites}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
@ -57,7 +60,7 @@ export default function HomePage() {
|
||||
MangaMochi
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{mangas?.data?.totalElements} titles available
|
||||
{mangas?.totalElements} titles available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -86,9 +89,9 @@ export default function HomePage() {
|
||||
</header>
|
||||
|
||||
<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 && (
|
||||
<div className="mt-12">
|
||||
|
||||
@ -18,10 +18,8 @@ import { useState } from "react";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const { user, logout, updatePreferences } = useAuth();
|
||||
const [itemsPerPage, setItemsPerPage] = useState(
|
||||
user?.preferences.itemsPerPage || 12,
|
||||
);
|
||||
const { user, logout } = useAuth();
|
||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
@ -49,7 +47,7 @@ export default function ProfilePage() {
|
||||
};
|
||||
|
||||
const handleSavePreferences = () => {
|
||||
updatePreferences({ itemsPerPage });
|
||||
// updatePreferences({ itemsPerPage });
|
||||
};
|
||||
|
||||
return (
|
||||
@ -89,20 +87,12 @@ export default function ProfilePage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<Input value={user.username} disabled />
|
||||
<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>
|
||||
<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>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@ -153,7 +143,7 @@ export default function ProfilePage() {
|
||||
Favorite Manga
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{user.favorites.length}
|
||||
{/*{user.favorites.length}*/}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-card p-4">
|
||||
@ -161,51 +151,51 @@ export default function ProfilePage() {
|
||||
Manga Reading
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{Object.keys(user.chaptersRead).length}
|
||||
{/*{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>
|
||||
)}
|
||||
{/*{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>
|
||||
)}
|
||||
{/*{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>
|
||||
)}
|
||||
{/*{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>
|
||||
|
||||
@ -5,14 +5,18 @@ import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Star, X } from "lucide-react";
|
||||
import { useGetGenres } from "@/api/mangamochi";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface FilterSidebarProps {
|
||||
selectedGenres: number[];
|
||||
selectedStatus: string[];
|
||||
minRating: number;
|
||||
userFavorites: boolean;
|
||||
onGenresChange: (genres: number[]) => void;
|
||||
onStatusChange: (status: string[]) => void;
|
||||
onRatingChange: (rating: number) => void;
|
||||
onUserFavoritesChange: (favorites: boolean) => void;
|
||||
}
|
||||
|
||||
const STATUSES = ["Ongoing", "Completed", "Hiatus"];
|
||||
@ -28,11 +32,14 @@ export function FilterSidebar({
|
||||
selectedGenres,
|
||||
selectedStatus,
|
||||
minRating,
|
||||
userFavorites,
|
||||
onGenresChange,
|
||||
onStatusChange,
|
||||
onRatingChange,
|
||||
onUserFavoritesChange,
|
||||
}: FilterSidebarProps) {
|
||||
const { data: genresData } = useGetGenres();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const toggleGenre = (genre: number) => {
|
||||
if (selectedGenres.includes(genre)) {
|
||||
@ -54,10 +61,14 @@ export function FilterSidebar({
|
||||
onGenresChange([]);
|
||||
onStatusChange([]);
|
||||
onRatingChange(0);
|
||||
onUserFavoritesChange(false);
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
selectedGenres.length > 0 || selectedStatus.length > 0 || minRating > 0;
|
||||
selectedGenres.length > 0 ||
|
||||
selectedStatus.length > 0 ||
|
||||
minRating > 0 ||
|
||||
userFavorites;
|
||||
|
||||
return (
|
||||
<aside className="w-64 border-r border-border bg-sidebar">
|
||||
@ -82,6 +93,27 @@ export function FilterSidebar({
|
||||
{/* Filters */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<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 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
import Link from "next/link";
|
||||
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 { 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 {
|
||||
id: number;
|
||||
@ -15,14 +24,19 @@ interface Manga {
|
||||
authors: string[];
|
||||
genres: string[];
|
||||
score: number;
|
||||
favorite: boolean;
|
||||
// chapters: number
|
||||
}
|
||||
|
||||
interface MangaCardProps {
|
||||
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", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
@ -42,11 +56,58 @@ export function MangaCard({ manga }: MangaCardProps) {
|
||||
|
||||
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 (
|
||||
<Link href={`/manga/${manga.id}`}>
|
||||
<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">
|
||||
<div className="relative aspect-[2/3] overflow-hidden bg-muted">
|
||||
<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">
|
||||
<CardContent className="p-0">
|
||||
<div className="relative aspect-[2/3] overflow-hidden bg-muted">
|
||||
<Link href={`/manga/${manga.id}`}>
|
||||
<Image
|
||||
src={
|
||||
(manga.coverImageKey &&
|
||||
@ -66,55 +127,78 @@ export function MangaCard({ manga }: MangaCardProps) {
|
||||
{manga.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Info */}
|
||||
<div className="space-y-2 p-4">
|
||||
<h3 className="line-clamp-2 text-sm font-semibold leading-tight text-foreground">
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
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}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground">{author}</p>
|
||||
|
||||
{publishedFrom && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Database className="h-3 w-3" />
|
||||
<span>
|
||||
{manga.providerCount}{" "}
|
||||
{manga.providerCount === 1 ? "provider" : "providers"}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,13 +3,14 @@ import { MangaListDTO } from "@/api/mangamochi";
|
||||
|
||||
interface MangaGridProps {
|
||||
manga: MangaListDTO[];
|
||||
queryKey: any;
|
||||
}
|
||||
|
||||
export function MangaGrid({ manga }: MangaGridProps) {
|
||||
export function MangaGrid({ manga, queryKey }: MangaGridProps) {
|
||||
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) => (
|
||||
<MangaCard key={item.id} manga={item} />
|
||||
<MangaCard key={item.id} manga={item} queryKey={queryKey} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user