feat/loading-state #31
@ -1,2 +0,0 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:8080
|
|
||||||
VITE_OMV_BASE_URL=http://omv.badger-pirarucu.ts.net:9000/mangamochi-dev
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -222,7 +222,7 @@ web_modules/
|
|||||||
.yarn-integrity
|
.yarn-integrity
|
||||||
|
|
||||||
# dotenv environment variable files
|
# dotenv environment variable files
|
||||||
.env
|
.env*
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|||||||
40
src/components/MangaLoadingState.tsx
Normal file
40
src/components/MangaLoadingState.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
const CHEEKY_MESSAGES = [
|
||||||
|
"Sharpening katanas and downloading manga...",
|
||||||
|
"Searching for the One Piece... and your titles.",
|
||||||
|
"Summoning the Great Sage for faster loading...",
|
||||||
|
"Powering up to Super Saiyan level... please wait.",
|
||||||
|
"Collecting all seven Dragon Balls to fetch data...",
|
||||||
|
"Even Saitama takes a second to load... sometimes.",
|
||||||
|
"Naruto is training, wait for the results...",
|
||||||
|
"Loading... because we don't have a Death Note for bugs.",
|
||||||
|
"Entering the Hidden Leaf Village... of data.",
|
||||||
|
"Waiting for the next chapter... and your results.",
|
||||||
|
"Training in the Hyperbolic Time Chamber for better speed...",
|
||||||
|
"Collecting chakra for the ultimate data retrieval...",
|
||||||
|
"Waiting for the next hiatus to end... oh wait, just loading.",
|
||||||
|
"Reading the manga faster than you can... hold on.",
|
||||||
|
"Asking the Shinigami for the right data...",
|
||||||
|
"Is this a Jojo reference? No, it's just loading.",
|
||||||
|
"Surpassing our limits... Right here! Right now!",
|
||||||
|
"Hunting for the rarest manga volumes in the digital world...",
|
||||||
|
"Dodging spoilers while fetching your manga...",
|
||||||
|
"Preparing the transmutation circle for your results...",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MangaLoadingState = () => {
|
||||||
|
const loadingMessage = useRef(
|
||||||
|
CHEEKY_MESSAGES[Math.floor(Math.random() * CHEEKY_MESSAGES.length)],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
||||||
|
<Spinner className="size-12 text-primary" />
|
||||||
|
<p className="animate-pulse text-muted-foreground font-medium text-center px-4">
|
||||||
|
{loadingMessage.current}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
src/components/ui/spinner.tsx
Normal file
16
src/components/ui/spinner.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<Loader2Icon
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
className={cn("size-4 animate-spin", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
@ -26,6 +26,8 @@ interface UIStateContextType {
|
|||||||
setSortOption: (sort: SortOption) => void;
|
setSortOption: (sort: SortOption) => void;
|
||||||
searchText: string;
|
searchText: string;
|
||||||
setSearchText: (text: string) => void;
|
setSearchText: (text: string) => void;
|
||||||
|
isSidebarOpen: boolean;
|
||||||
|
setIsSidebarOpen: (open: boolean) => void;
|
||||||
resetFilters: () => void;
|
resetFilters: () => void;
|
||||||
|
|
||||||
/* Manga Provider Card State */
|
/* Manga Provider Card State */
|
||||||
@ -49,6 +51,7 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const [showAdultContent, setShowAdultContent] = useState(false);
|
const [showAdultContent, setShowAdultContent] = useState(false);
|
||||||
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
|
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||||
|
|
||||||
/* Manga Provider Card State */
|
/* Manga Provider Card State */
|
||||||
const [expandedProviderIds, setExpandedProviderIds] = useState<number[]>([]);
|
const [expandedProviderIds, setExpandedProviderIds] = useState<number[]>([]);
|
||||||
@ -104,6 +107,8 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
setSortOption,
|
setSortOption,
|
||||||
searchText,
|
searchText,
|
||||||
setSearchText,
|
setSearchText,
|
||||||
|
isSidebarOpen,
|
||||||
|
setIsSidebarOpen,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
expandedProviderIds,
|
expandedProviderIds,
|
||||||
toggleProviderId,
|
toggleProviderId,
|
||||||
@ -119,6 +124,7 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
showAdultContent,
|
showAdultContent,
|
||||||
sortOption,
|
sortOption,
|
||||||
searchText,
|
searchText,
|
||||||
|
isSidebarOpen,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
expandedProviderIds,
|
expandedProviderIds,
|
||||||
toggleProviderId,
|
toggleProviderId,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Star, X } from "lucide-react";
|
import { PanelLeftClose, Star, X } from "lucide-react";
|
||||||
import { useGetGenres } from "@/api/generated/catalog/catalog.ts";
|
import { useGetGenres } from "@/api/generated/catalog/catalog.ts";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -18,6 +18,8 @@ interface FilterSidebarProps {
|
|||||||
onRatingChange: (rating: number) => void;
|
onRatingChange: (rating: number) => void;
|
||||||
onUserFavoritesChange: (favorites: boolean) => void;
|
onUserFavoritesChange: (favorites: boolean) => void;
|
||||||
onShowAdultContentChange: (showAdult: boolean) => void;
|
onShowAdultContentChange: (showAdult: boolean) => void;
|
||||||
|
onHide: () => void;
|
||||||
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUSES = ["Ongoing", "Completed", "Hiatus"];
|
const STATUSES = ["Ongoing", "Completed", "Hiatus"];
|
||||||
@ -40,6 +42,8 @@ export function FilterSidebar({
|
|||||||
onRatingChange,
|
onRatingChange,
|
||||||
onUserFavoritesChange,
|
onUserFavoritesChange,
|
||||||
onShowAdultContentChange,
|
onShowAdultContentChange,
|
||||||
|
onHide,
|
||||||
|
isDisabled = false,
|
||||||
}: FilterSidebarProps) {
|
}: FilterSidebarProps) {
|
||||||
const { data: genresData, isPending: isPendingGenres } = useGetGenres();
|
const { data: genresData, isPending: isPendingGenres } = useGetGenres();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
@ -79,14 +83,25 @@ export function FilterSidebar({
|
|||||||
<div className="sticky top-0 flex h-screen flex-col">
|
<div className="sticky top-0 flex h-screen flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-sidebar-border px-6 py-6">
|
<div className="flex items-center justify-between border-b border-sidebar-border px-6 py-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onHide}
|
||||||
|
className="h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent"
|
||||||
|
>
|
||||||
|
<PanelLeftClose className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
<h2 className="text-lg font-semibold text-sidebar-foreground">
|
<h2 className="text-lg font-semibold text-sidebar-foreground">
|
||||||
Filters
|
Filters
|
||||||
</h2>
|
</h2>
|
||||||
|
</div>
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={clearAllFilters}
|
onClick={clearAllFilters}
|
||||||
|
disabled={isDisabled}
|
||||||
className="h-8 px-2 text-xs"
|
className="h-8 px-2 text-xs"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
@ -110,6 +125,7 @@ export function FilterSidebar({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={userFavorites}
|
checked={userFavorites}
|
||||||
onCheckedChange={onUserFavoritesChange}
|
onCheckedChange={onUserFavoritesChange}
|
||||||
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between rounded-md px-3 py-2">
|
<div className="flex items-center justify-between rounded-md px-3 py-2">
|
||||||
@ -119,6 +135,7 @@ export function FilterSidebar({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={showAdultContent}
|
checked={showAdultContent}
|
||||||
onCheckedChange={onShowAdultContentChange}
|
onCheckedChange={onShowAdultContentChange}
|
||||||
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -145,8 +162,8 @@ export function FilterSidebar({
|
|||||||
isSelected
|
isSelected
|
||||||
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
|
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
|
||||||
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
|
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
|
||||||
}`}
|
} ${isDisabled ? "opacity-50 pointer-events-none grayscale-[0.5]" : ""}`}
|
||||||
onClick={() => toggleGenre(genre.id)}
|
onClick={() => !isDisabled && toggleGenre(genre.id)}
|
||||||
>
|
>
|
||||||
{genre.name}
|
{genre.name}
|
||||||
{isSelected && <X className="ml-1 h-3 w-3" />}
|
{isSelected && <X className="ml-1 h-3 w-3" />}
|
||||||
@ -174,8 +191,8 @@ export function FilterSidebar({
|
|||||||
isSelected
|
isSelected
|
||||||
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
|
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
|
||||||
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
|
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
|
||||||
}`}
|
} ${isDisabled ? "opacity-50 pointer-events-none grayscale-[0.5]" : ""}`}
|
||||||
onClick={() => toggleStatus(status)}
|
onClick={() => !isDisabled && toggleStatus(status)}
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
{isSelected && <X className="ml-1 h-3 w-3" />}
|
{isSelected && <X className="ml-1 h-3 w-3" />}
|
||||||
@ -203,7 +220,8 @@ export function FilterSidebar({
|
|||||||
isSelected
|
isSelected
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
|
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
|
||||||
}`}
|
} ${isDisabled ? "opacity-50 pointer-events-none" : ""}`}
|
||||||
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
<Star
|
<Star
|
||||||
className={`h-4 w-4 ${isSelected ? "fill-sidebar-primary text-sidebar-primary" : "text-muted-foreground"}`}
|
className={`h-4 w-4 ${isSelected ? "fill-sidebar-primary text-sidebar-primary" : "text-muted-foreground"}`}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Calendar, Database, Heart, Star } from "lucide-react";
|
import { Calendar, Database, Heart, Star } from "lucide-react";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type {
|
import type {
|
||||||
|
DefaultResponseDTOPageMangaListDTO,
|
||||||
MangaListDTO,
|
MangaListDTO,
|
||||||
PageMangaListDTO,
|
|
||||||
} from "@/api/generated/api.schemas.ts";
|
} from "@/api/generated/api.schemas.ts";
|
||||||
import {
|
import {
|
||||||
useSetFavorite,
|
useSetFavorite,
|
||||||
@ -13,27 +13,36 @@ import {
|
|||||||
import { Badge } from "@/components/ui/badge.tsx";
|
import { Badge } from "@/components/ui/badge.tsx";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Card, CardContent } from "@/components/ui/card.tsx";
|
import { Card, CardContent } from "@/components/ui/card.tsx";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton.tsx";
|
||||||
import { useAuth } from "@/contexts/AuthContext.tsx";
|
import { useAuth } from "@/contexts/AuthContext.tsx";
|
||||||
|
import { cn } from "@/lib/utils.ts";
|
||||||
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
|
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
|
||||||
|
|
||||||
interface MangaCardProps {
|
interface MangaCardProps {
|
||||||
manga: MangaListDTO;
|
manga: MangaListDTO;
|
||||||
queryKey: unknown;
|
queryKey: readonly unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
|
export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [isImageLoading, setIsImageLoading] = useState(true);
|
||||||
|
|
||||||
const updateQueryData = useCallback(
|
const updateQueryData = useCallback(
|
||||||
(oldData: PageMangaListDTO | undefined, isFavorite: boolean) => ({
|
(
|
||||||
|
oldData: DefaultResponseDTOPageMangaListDTO | undefined,
|
||||||
|
isFavorite: boolean,
|
||||||
|
) => ({
|
||||||
...oldData,
|
...oldData,
|
||||||
|
data: {
|
||||||
|
...oldData?.data,
|
||||||
content:
|
content:
|
||||||
oldData?.content?.map((manga) =>
|
oldData?.data?.content?.map((item) =>
|
||||||
manga.id === manga.id ? { ...manga, favorite: isFavorite } : manga,
|
item.id === manga.id ? { ...item, favorite: isFavorite } : item,
|
||||||
) || [],
|
) || [],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[],
|
[manga.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutate: mutateFavorite, isPending: isPendingFavorite } =
|
const { mutate: mutateFavorite, isPending: isPendingFavorite } =
|
||||||
@ -41,8 +50,8 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
|
|||||||
mutation: {
|
mutation: {
|
||||||
onSuccess: () =>
|
onSuccess: () =>
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
[queryKey],
|
queryKey,
|
||||||
(oldData: PageMangaListDTO | undefined) =>
|
(oldData: DefaultResponseDTOPageMangaListDTO | undefined) =>
|
||||||
updateQueryData(oldData, true),
|
updateQueryData(oldData, true),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -53,8 +62,8 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
|
|||||||
mutation: {
|
mutation: {
|
||||||
onSuccess: () =>
|
onSuccess: () =>
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
[queryKey],
|
queryKey,
|
||||||
(oldData: PageMangaListDTO | undefined) =>
|
(oldData: DefaultResponseDTOPageMangaListDTO | undefined) =>
|
||||||
updateQueryData(oldData, false),
|
updateQueryData(oldData, false),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -74,6 +83,9 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
|
|||||||
<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="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">
|
<CardContent className="p-0">
|
||||||
<div className="relative aspect-2/3 overflow-hidden bg-muted">
|
<div className="relative aspect-2/3 overflow-hidden bg-muted">
|
||||||
|
{isImageLoading && (
|
||||||
|
<Skeleton className="absolute inset-0 z-10 h-full w-full" />
|
||||||
|
)}
|
||||||
<Link to={`/manga/${manga.id}`}>
|
<Link to={`/manga/${manga.id}`}>
|
||||||
<img
|
<img
|
||||||
src={
|
src={
|
||||||
@ -84,7 +96,11 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
|
|||||||
"/placeholder.svg"
|
"/placeholder.svg"
|
||||||
}
|
}
|
||||||
alt={manga.title}
|
alt={manga.title}
|
||||||
className="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
onLoad={() => setIsImageLoading(false)}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 h-full w-full object-cover transition-all duration-300 group-hover:scale-105",
|
||||||
|
isImageLoading ? "opacity-0" : "opacity-100",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-2 top-2">
|
<div className="absolute right-2 top-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { MangaCard } from "@/features/home/components/MangaCard.tsx";
|
|||||||
|
|
||||||
interface MangaGridProps {
|
interface MangaGridProps {
|
||||||
manga: MangaListDTO[];
|
manga: MangaListDTO[];
|
||||||
queryKey: unknown;
|
queryKey: readonly unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MangaGrid = ({ manga, queryKey }: MangaGridProps) => {
|
export const MangaGrid = ({ manga, queryKey }: MangaGridProps) => {
|
||||||
|
|||||||
@ -21,11 +21,13 @@ export type SortOption =
|
|||||||
interface SortDropdownProps {
|
interface SortDropdownProps {
|
||||||
currentSort: SortOption;
|
currentSort: SortOption;
|
||||||
onSortChange: (sort: SortOption) => void;
|
onSortChange: (sort: SortOption) => void;
|
||||||
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SortDropdown = ({
|
export const SortDropdown = ({
|
||||||
currentSort,
|
currentSort,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
|
isDisabled = false,
|
||||||
}: SortDropdownProps) => {
|
}: SortDropdownProps) => {
|
||||||
const getSortLabel = () => {
|
const getSortLabel = () => {
|
||||||
switch (currentSort) {
|
switch (currentSort) {
|
||||||
@ -51,7 +53,12 @@ export const SortDropdown = ({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2 bg-transparent">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 bg-transparent"
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
{getSortLabel()}
|
{getSortLabel()}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useMarkContentAsRead } from "@/api/generated/user-interaction/user-inte
|
|||||||
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useReadingTracker } from "@/features/chapter/hooks/useReadingTracker.ts";
|
import { useReadingTracker } from "@/features/chapter/hooks/useReadingTracker.ts";
|
||||||
|
import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
|
||||||
|
|
||||||
const Chapter = () => {
|
const Chapter = () => {
|
||||||
const { setCurrentChapterPage, getCurrentChapterPage } = useReadingTracker();
|
const { setCurrentChapterPage, getCurrentChapterPage } = useReadingTracker();
|
||||||
@ -109,6 +110,14 @@ const Chapter = () => {
|
|||||||
}
|
}
|
||||||
}, [infiniteScroll]);
|
}, [infiniteScroll]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<MangaLoadingState />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!data?.data) {
|
if (!data?.data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
@ -125,6 +134,25 @@ const Chapter = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const images = data.data.contentImageKeys;
|
const images = data.data.contentImageKeys;
|
||||||
|
const previousContentId = data.data.previousContentId;
|
||||||
|
const nextContentId = data.data.nextContentId;
|
||||||
|
|
||||||
|
const goToNextChapter = () => {
|
||||||
|
if (nextContentId) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setVisibleCount(1);
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
navigate(`/manga/${mangaId}/chapter/${nextContentId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const goToPreviousChapter = () => {
|
||||||
|
if (previousContentId) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setVisibleCount(1);
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
navigate(`/manga/${mangaId}/chapter/${previousContentId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Standard navigation (non-infinite mode) */
|
/** Standard navigation (non-infinite mode) */
|
||||||
const goToNextPage = () => {
|
const goToNextPage = () => {
|
||||||
@ -171,10 +199,33 @@ const Chapter = () => {
|
|||||||
<h1 className="text-sm font-semibold text-foreground">
|
<h1 className="text-sm font-semibold text-foreground">
|
||||||
{data.data.mangaTitle}
|
{data.data.mangaTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
Chapter {chapterNumber}
|
{chapterNumber}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{previousContentId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToPreviousChapter}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Prev</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{nextContentId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToNextChapter}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Next</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@ -202,7 +253,7 @@ const Chapter = () => {
|
|||||||
{data.data.mangaTitle}
|
{data.data.mangaTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Chapter {chapterNumber}
|
{chapterNumber}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -224,6 +275,36 @@ const Chapter = () => {
|
|||||||
|
|
||||||
{/* LOAD MORE SENTINEL */}
|
{/* LOAD MORE SENTINEL */}
|
||||||
<div ref={loadMoreRef} className="h-10" />
|
<div ref={loadMoreRef} className="h-10" />
|
||||||
|
|
||||||
|
{/* CHAPTER NAVIGATION (infinite scroll mode) */}
|
||||||
|
{(previousContentId || nextContentId) && (
|
||||||
|
<div className="flex items-center justify-center gap-3 border-t border-border py-6">
|
||||||
|
{previousContentId ? (
|
||||||
|
<Button
|
||||||
|
onClick={goToPreviousChapter}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 bg-transparent"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1" />
|
||||||
|
)}
|
||||||
|
{nextContentId ? (
|
||||||
|
<Button
|
||||||
|
onClick={goToNextChapter}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 bg-transparent"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@ -271,6 +352,36 @@ const Chapter = () => {
|
|||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CHAPTER NAVIGATION */}
|
||||||
|
{(previousContentId || nextContentId) && (
|
||||||
|
<div className="flex items-center justify-center gap-3 border-t border-border pt-4">
|
||||||
|
{previousContentId ? (
|
||||||
|
<Button
|
||||||
|
onClick={goToPreviousChapter}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 bg-transparent"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1" />
|
||||||
|
)}
|
||||||
|
{nextContentId ? (
|
||||||
|
<Button
|
||||||
|
onClick={goToNextChapter}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 bg-transparent"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import { BookOpen, Search } from "lucide-react";
|
import { BookOpen, PanelLeftOpen, Search } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { useGetMangas } from "@/api/generated/catalog/catalog.ts";
|
import { useGetMangas } from "@/api/generated/catalog/catalog.ts";
|
||||||
import { AuthHeader } from "@/components/AuthHeader.tsx";
|
import { AuthHeader } from "@/components/AuthHeader.tsx";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Pagination } from "@/components/Pagination.tsx";
|
import { Pagination } from "@/components/Pagination.tsx";
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
||||||
import { Input } from "@/components/ui/input.tsx";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton.tsx";
|
||||||
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
||||||
import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
|
import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
|
||||||
import { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
|
import { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
|
||||||
|
import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
|
||||||
import { SortDropdown } from "@/features/home/components/SortDropdown.tsx";
|
import { SortDropdown } from "@/features/home/components/SortDropdown.tsx";
|
||||||
import { useDynamicPageSize } from "@/hooks/useDynamicPageSize.ts";
|
import { useDynamicPageSize } from "@/hooks/useDynamicPageSize.ts";
|
||||||
|
|
||||||
@ -33,11 +36,18 @@ const Home = () => {
|
|||||||
setSortOption,
|
setSortOption,
|
||||||
searchText,
|
searchText,
|
||||||
setSearchText,
|
setSearchText,
|
||||||
|
isSidebarOpen,
|
||||||
|
setIsSidebarOpen,
|
||||||
} = useUIState();
|
} = useUIState();
|
||||||
|
|
||||||
const [debouncedSearchText] = useDebounce(searchText, 500);
|
const [debouncedSearchText] = useDebounce(searchText, 500);
|
||||||
|
|
||||||
const { data: mangasData, queryKey: mangasQueryKey } = useGetMangas({
|
const {
|
||||||
|
data: mangasData,
|
||||||
|
queryKey: mangasQueryKey,
|
||||||
|
isPending,
|
||||||
|
isFetching,
|
||||||
|
} = useGetMangas({
|
||||||
page: currentPage - 1,
|
page: currentPage - 1,
|
||||||
size: itemsPerPage,
|
size: itemsPerPage,
|
||||||
sort: ["id"],
|
sort: ["id"],
|
||||||
@ -47,6 +57,7 @@ const Home = () => {
|
|||||||
userFavorites,
|
userFavorites,
|
||||||
score: minRating,
|
score: minRating,
|
||||||
});
|
});
|
||||||
|
const isFiltersDisabled = isFetching;
|
||||||
|
|
||||||
const startSearchRef = useRef(debouncedSearchText);
|
const startSearchRef = useRef(debouncedSearchText);
|
||||||
|
|
||||||
@ -62,6 +73,7 @@ const Home = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-background">
|
<div className="flex min-h-screen bg-background">
|
||||||
|
{isSidebarOpen && (
|
||||||
<FilterSidebar
|
<FilterSidebar
|
||||||
selectedGenres={selectedGenres}
|
selectedGenres={selectedGenres}
|
||||||
selectedStatus={selectedStatus}
|
selectedStatus={selectedStatus}
|
||||||
@ -73,9 +85,12 @@ const Home = () => {
|
|||||||
onRatingChange={setMinRating}
|
onRatingChange={setMinRating}
|
||||||
onUserFavoritesChange={setUserFavorites}
|
onUserFavoritesChange={setUserFavorites}
|
||||||
onShowAdultContentChange={setShowAdultContent}
|
onShowAdultContentChange={setShowAdultContent}
|
||||||
|
onHide={() => setIsSidebarOpen(false)}
|
||||||
|
isDisabled={isFiltersDisabled}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1 flex flex-col">
|
||||||
<header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="px-8 py-6">
|
<div className="px-8 py-6">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@ -86,12 +101,31 @@ const Home = () => {
|
|||||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||||
MangaMochi
|
MangaMochi
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<div className="mt-1 flex items-center gap-2 min-h-[1.25rem]">
|
||||||
{mangasData?.data?.totalElements} titles available
|
{isFetching ? (
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{mangasData?.data?.totalElements ?? 0} titles available
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{!isSidebarOpen && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<PanelLeftOpen className="h-4 w-4" />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<AuthHeader />
|
<AuthHeader />
|
||||||
</div>
|
</div>
|
||||||
@ -107,15 +141,18 @@ const Home = () => {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearchText(e.target.value);
|
setSearchText(e.target.value);
|
||||||
}}
|
}}
|
||||||
className="pl-10 bg-card border-border"
|
disabled={isFiltersDisabled}
|
||||||
|
className="pl-10 bg-card border-border disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="px-8 py-8">
|
<main className="flex-1 px-8 py-8 flex flex-col">
|
||||||
{mangasData?.data?.content && mangasData.data.content.length > 0 ? (
|
{isPending ? (
|
||||||
|
<MangaLoadingState />
|
||||||
|
) : mangasData?.data?.content && mangasData.data.content.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{mangasData.data?.totalElements && (
|
{mangasData.data?.totalElements && (
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
@ -130,6 +167,7 @@ const Home = () => {
|
|||||||
<SortDropdown
|
<SortDropdown
|
||||||
currentSort={sortOption}
|
currentSort={sortOption}
|
||||||
onSortChange={setSortOption}
|
onSortChange={setSortOption}
|
||||||
|
isDisabled={isFiltersDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -149,7 +187,7 @@ const Home = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-[400px] items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
No manga found matching your filters.
|
No manga found matching your filters.
|
||||||
|
|||||||
@ -35,6 +35,8 @@ import { useUIState } from "@/contexts/UIStateContext.tsx";
|
|||||||
import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
|
import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
|
||||||
import { useScrollPersistence } from "@/hooks/useScrollPersistence.ts";
|
import { useScrollPersistence } from "@/hooks/useScrollPersistence.ts";
|
||||||
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
|
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
const Manga = () => {
|
const Manga = () => {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
@ -44,7 +46,7 @@ const Manga = () => {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: mangaData, queryKey } = useGetManga(mangaId);
|
const { data: mangaData, queryKey, isLoading } = useGetManga(mangaId);
|
||||||
|
|
||||||
const { mutate, isPending: fetchPending } =
|
const { mutate, isPending: fetchPending } =
|
||||||
useFetchContentProviderContentList({
|
useFetchContentProviderContentList({
|
||||||
@ -122,7 +124,85 @@ const Manga = () => {
|
|||||||
[mangaData?.data?.id, mutateUnfollow, mutateFollow],
|
[mangaData?.data?.id, mutateUnfollow, mutateFollow],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mangaData) {
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
|
||||||
|
<div className="px-8 py-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Library
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-xl font-bold text-foreground">MangaMochi</h1>
|
||||||
|
</div>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content Shell */}
|
||||||
|
<main className="px-8 py-8">
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[300px_1fr]">
|
||||||
|
{/* Cover Skeleton */}
|
||||||
|
<Skeleton className="aspect-2/3 w-full rounded-lg lg:sticky lg:top-8" />
|
||||||
|
|
||||||
|
{/* Details Skeleton */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Skeleton className="h-10 w-2/3" />
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Skeleton className="h-6 w-20" />
|
||||||
|
<Skeleton className="h-10 w-10" />
|
||||||
|
<Skeleton className="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-20 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-6 w-16 px-3 py-1" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12">
|
||||||
|
<Skeleton className="mb-6 h-8 w-64" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-20 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mangaData?.data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@ -212,7 +292,13 @@ const Manga = () => {
|
|||||||
}}
|
}}
|
||||||
disabled={isPendingFollowChange}
|
disabled={isPendingFollowChange}
|
||||||
>
|
>
|
||||||
{mangaData?.data?.following ? <BellOff /> : <Bell />}
|
{isPendingFollowChange ? (
|
||||||
|
<Spinner />
|
||||||
|
) : mangaData?.data?.following ? (
|
||||||
|
<BellOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -226,13 +312,16 @@ const Manga = () => {
|
|||||||
}}
|
}}
|
||||||
disabled={isPendingFavoriteChange}
|
disabled={isPendingFavoriteChange}
|
||||||
>
|
>
|
||||||
|
{isPendingFavoriteChange ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
<Heart
|
<Heart
|
||||||
className={`h-4 w-4 transition-colors ${
|
className={`h-4 w-4 transition-colors ${mangaData?.data?.favorite
|
||||||
mangaData?.data?.favorite
|
|
||||||
? "fill-red-500 text-red-500"
|
? "fill-red-500 text-red-500"
|
||||||
: "text-muted-foreground hover:text-red-500"
|
: "text-muted-foreground hover:text-red-500"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -349,11 +438,18 @@ const Manga = () => {
|
|||||||
<Card key={provider.id} className="border-border bg-card">
|
<Card key={provider.id} className="border-border bg-card">
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={expandedProviderIds.includes(provider.id ?? -1)}
|
open={expandedProviderIds.includes(provider.id ?? -1)}
|
||||||
onOpenChange={() => toggleProvider(provider.id ?? -1)}
|
onOpenChange={() => {
|
||||||
|
if (provider.chaptersAvailable > 0) {
|
||||||
|
toggleProvider(provider.id ?? -1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CollapsibleTrigger className="w-full">
|
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
<div className="flex items-center justify-between w-full">
|
<CollapsibleTrigger
|
||||||
|
asChild
|
||||||
|
className={`flex-1 ${provider.chaptersAvailable > 0 ? "cursor-pointer" : "cursor-default"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Database className="h-5 w-5 text-primary" />
|
<Database className="h-5 w-5 text-primary" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
@ -366,48 +462,71 @@ const Manga = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
{provider.supportsChapterFetch && provider.active && (
|
{provider.supportsChapterFetch && provider.active && (
|
||||||
<div className={"flex gap-4 pr-4"}>
|
<div className="flex gap-4 px-4">
|
||||||
<Button
|
{provider.chaptersAvailable > 0 && (
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={(isPending || fetchAllPending) && provider.chaptersAvailable > 0}
|
|
||||||
onClick={() =>
|
|
||||||
fetchAllMutate({
|
|
||||||
mangaContentProviderId: provider.id?? -1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Database className="h-4 w-4" />
|
|
||||||
Fetch all from Provider
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isPending || fetchAllPending}
|
disabled={isPending || fetchAllPending}
|
||||||
onClick={() =>
|
onClick={(e) => {
|
||||||
mutate({
|
e.stopPropagation();
|
||||||
|
fetchAllMutate({
|
||||||
mangaContentProviderId: provider.id ?? -1,
|
mangaContentProviderId: provider.id ?? -1,
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
|
{fetchAllPending ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Fetch all from Provider
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending || fetchAllPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
mutate({
|
||||||
|
mangaContentProviderId: provider.id ?? -1,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
)}
|
||||||
Fetch list from Provider
|
Fetch list from Provider
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
provider.chaptersAvailable > 0
|
||||||
|
? "cursor-pointer"
|
||||||
|
: "invisible"
|
||||||
|
}
|
||||||
|
>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`h-5 w-5 text-muted-foreground transition-transform ${
|
className={`h-5 w-5 text-muted-foreground transition-transform ${expandedProviderIds.includes(provider.id ?? -1)
|
||||||
expandedProviderIds.includes(provider.id ?? -1)
|
|
||||||
? "rotate-180"
|
? "rotate-180"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
</CardContent>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<MangaChapter
|
<MangaChapter
|
||||||
mangaId={mangaId}
|
mangaId={mangaId}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user