All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
204 lines
6.2 KiB
TypeScript
204 lines
6.2 KiB
TypeScript
import Link from "next/link";
|
|
import Image from "next/image";
|
|
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;
|
|
title: string;
|
|
coverImageKey?: string;
|
|
status?: string;
|
|
publishedFrom?: string;
|
|
publishedTo?: string;
|
|
providerCount?: number;
|
|
authors: string[];
|
|
genres: string[];
|
|
score: number;
|
|
favorite: boolean;
|
|
}
|
|
|
|
interface MangaCardProps {
|
|
manga: Manga;
|
|
queryKey: any;
|
|
}
|
|
|
|
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",
|
|
year: "numeric",
|
|
});
|
|
|
|
const publishedTo = manga.publishedTo
|
|
? formatter.format(new Date(manga.publishedTo))
|
|
: null;
|
|
const publishedFrom = manga.publishedFrom
|
|
? formatter.format(new Date(manga.publishedFrom))
|
|
: null;
|
|
|
|
const dateRange = publishedTo
|
|
? `${publishedFrom} - ${publishedTo}`
|
|
: `${publishedFrom} - Present`;
|
|
|
|
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 (
|
|
<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 &&
|
|
process.env.NEXT_PUBLIC_OMV_BASE_URL + "/" +
|
|
manga.coverImageKey) ||
|
|
"/placeholder.svg"
|
|
}
|
|
alt={manga.title}
|
|
fill
|
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
|
/>
|
|
<div className="absolute right-2 top-2">
|
|
<Badge
|
|
variant="secondary"
|
|
className="border border-border/50 bg-background/80 text-foreground backdrop-blur-sm"
|
|
>
|
|
{manga.status}
|
|
</Badge>
|
|
</div>
|
|
</Link>
|
|
|
|
{isAuthenticated && (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
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 && manga.score > 0 ? manga.score : "-"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
);
|
|
}
|