feat: add favorite manga functionality
This commit is contained in:
parent
4fa67fcc81
commit
05ad5330f5
@ -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
|
||||||
|
|||||||
15
app/page.tsx
15
app/page.tsx
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,13 +127,38 @@ export function MangaCard({ manga }: MangaCardProps) {
|
|||||||
{manga.status}
|
{manga.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="space-y-2 p-4">
|
<div className="space-y-2 p-4">
|
||||||
<h3 className="line-clamp-2 text-sm font-semibold leading-tight text-foreground">
|
<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>
|
||||||
|
|
||||||
@ -90,7 +176,6 @@ export function MangaCard({ manga }: MangaCardProps) {
|
|||||||
{manga.score}
|
{manga.score}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/*<span className="text-xs text-muted-foreground">Ch. {manga.chapters}</span>*/}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
@ -115,6 +200,5 @@ export function MangaCard({ manga }: MangaCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user