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
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env*
|
||||
.env.development.local
|
||||
.env.test.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;
|
||||
searchText: string;
|
||||
setSearchText: (text: string) => void;
|
||||
isSidebarOpen: boolean;
|
||||
setIsSidebarOpen: (open: boolean) => void;
|
||||
resetFilters: () => void;
|
||||
|
||||
/* Manga Provider Card State */
|
||||
@ -49,6 +51,7 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [showAdultContent, setShowAdultContent] = useState(false);
|
||||
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
|
||||
/* Manga Provider Card State */
|
||||
const [expandedProviderIds, setExpandedProviderIds] = useState<number[]>([]);
|
||||
@ -104,6 +107,8 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
setSortOption,
|
||||
searchText,
|
||||
setSearchText,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
resetFilters,
|
||||
expandedProviderIds,
|
||||
toggleProviderId,
|
||||
@ -119,6 +124,7 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
showAdultContent,
|
||||
sortOption,
|
||||
searchText,
|
||||
isSidebarOpen,
|
||||
resetFilters,
|
||||
expandedProviderIds,
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -18,6 +18,8 @@ interface FilterSidebarProps {
|
||||
onRatingChange: (rating: number) => void;
|
||||
onUserFavoritesChange: (favorites: boolean) => void;
|
||||
onShowAdultContentChange: (showAdult: boolean) => void;
|
||||
onHide: () => void;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const STATUSES = ["Ongoing", "Completed", "Hiatus"];
|
||||
@ -40,6 +42,8 @@ export function FilterSidebar({
|
||||
onRatingChange,
|
||||
onUserFavoritesChange,
|
||||
onShowAdultContentChange,
|
||||
onHide,
|
||||
isDisabled = false,
|
||||
}: FilterSidebarProps) {
|
||||
const { data: genresData, isPending: isPendingGenres } = useGetGenres();
|
||||
const { isAuthenticated } = useAuth();
|
||||
@ -79,14 +83,25 @@ export function FilterSidebar({
|
||||
<div className="sticky top-0 flex h-screen flex-col">
|
||||
{/* Header */}
|
||||
<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">
|
||||
Filters
|
||||
</h2>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllFilters}
|
||||
disabled={isDisabled}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
Clear
|
||||
@ -110,6 +125,7 @@ export function FilterSidebar({
|
||||
<Switch
|
||||
checked={userFavorites}
|
||||
onCheckedChange={onUserFavoritesChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md px-3 py-2">
|
||||
@ -119,6 +135,7 @@ export function FilterSidebar({
|
||||
<Switch
|
||||
checked={showAdultContent}
|
||||
onCheckedChange={onShowAdultContentChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -145,8 +162,8 @@ export function FilterSidebar({
|
||||
isSelected
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
|
||||
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
}`}
|
||||
onClick={() => toggleGenre(genre.id)}
|
||||
} ${isDisabled ? "opacity-50 pointer-events-none grayscale-[0.5]" : ""}`}
|
||||
onClick={() => !isDisabled && toggleGenre(genre.id)}
|
||||
>
|
||||
{genre.name}
|
||||
{isSelected && <X className="ml-1 h-3 w-3" />}
|
||||
@ -174,8 +191,8 @@ export function FilterSidebar({
|
||||
isSelected
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
|
||||
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
}`}
|
||||
onClick={() => toggleStatus(status)}
|
||||
} ${isDisabled ? "opacity-50 pointer-events-none grayscale-[0.5]" : ""}`}
|
||||
onClick={() => !isDisabled && toggleStatus(status)}
|
||||
>
|
||||
{status}
|
||||
{isSelected && <X className="ml-1 h-3 w-3" />}
|
||||
@ -203,7 +220,8 @@ export function FilterSidebar({
|
||||
isSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
|
||||
}`}
|
||||
} ${isDisabled ? "opacity-50 pointer-events-none" : ""}`}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Star
|
||||
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 { Calendar, Database, Heart, Star } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type {
|
||||
DefaultResponseDTOPageMangaListDTO,
|
||||
MangaListDTO,
|
||||
PageMangaListDTO,
|
||||
} from "@/api/generated/api.schemas.ts";
|
||||
import {
|
||||
useSetFavorite,
|
||||
@ -13,27 +13,36 @@ import {
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card, CardContent } from "@/components/ui/card.tsx";
|
||||
import { Skeleton } from "@/components/ui/skeleton.tsx";
|
||||
import { useAuth } from "@/contexts/AuthContext.tsx";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
|
||||
|
||||
interface MangaCardProps {
|
||||
manga: MangaListDTO;
|
||||
queryKey: unknown;
|
||||
queryKey: readonly unknown[];
|
||||
}
|
||||
|
||||
export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [isImageLoading, setIsImageLoading] = useState(true);
|
||||
|
||||
const updateQueryData = useCallback(
|
||||
(oldData: PageMangaListDTO | undefined, isFavorite: boolean) => ({
|
||||
(
|
||||
oldData: DefaultResponseDTOPageMangaListDTO | undefined,
|
||||
isFavorite: boolean,
|
||||
) => ({
|
||||
...oldData,
|
||||
data: {
|
||||
...oldData?.data,
|
||||
content:
|
||||
oldData?.content?.map((manga) =>
|
||||
manga.id === manga.id ? { ...manga, favorite: isFavorite } : manga,
|
||||
oldData?.data?.content?.map((item) =>
|
||||
item.id === manga.id ? { ...item, favorite: isFavorite } : item,
|
||||
) || [],
|
||||
},
|
||||
}),
|
||||
[],
|
||||
[manga.id],
|
||||
);
|
||||
|
||||
const { mutate: mutateFavorite, isPending: isPendingFavorite } =
|
||||
@ -41,8 +50,8 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
|
||||
mutation: {
|
||||
onSuccess: () =>
|
||||
queryClient.setQueryData(
|
||||
[queryKey],
|
||||
(oldData: PageMangaListDTO | undefined) =>
|
||||
queryKey,
|
||||
(oldData: DefaultResponseDTOPageMangaListDTO | undefined) =>
|
||||
updateQueryData(oldData, true),
|
||||
),
|
||||
},
|
||||
@ -53,8 +62,8 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
|
||||
mutation: {
|
||||
onSuccess: () =>
|
||||
queryClient.setQueryData(
|
||||
[queryKey],
|
||||
(oldData: PageMangaListDTO | undefined) =>
|
||||
queryKey,
|
||||
(oldData: DefaultResponseDTOPageMangaListDTO | undefined) =>
|
||||
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">
|
||||
<CardContent className="p-0">
|
||||
<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}`}>
|
||||
<img
|
||||
src={
|
||||
@ -84,7 +96,11 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
|
||||
"/placeholder.svg"
|
||||
}
|
||||
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">
|
||||
<Badge
|
||||
|
||||
@ -3,7 +3,7 @@ import { MangaCard } from "@/features/home/components/MangaCard.tsx";
|
||||
|
||||
interface MangaGridProps {
|
||||
manga: MangaListDTO[];
|
||||
queryKey: unknown;
|
||||
queryKey: readonly unknown[];
|
||||
}
|
||||
|
||||
export const MangaGrid = ({ manga, queryKey }: MangaGridProps) => {
|
||||
|
||||
@ -21,11 +21,13 @@ export type SortOption =
|
||||
interface SortDropdownProps {
|
||||
currentSort: SortOption;
|
||||
onSortChange: (sort: SortOption) => void;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const SortDropdown = ({
|
||||
currentSort,
|
||||
onSortChange,
|
||||
isDisabled = false,
|
||||
}: SortDropdownProps) => {
|
||||
const getSortLabel = () => {
|
||||
switch (currentSort) {
|
||||
@ -51,7 +53,12 @@ export const SortDropdown = ({
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<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" />
|
||||
{getSortLabel()}
|
||||
</Button>
|
||||
|
||||
@ -6,6 +6,7 @@ import { useMarkContentAsRead } from "@/api/generated/user-interaction/user-inte
|
||||
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useReadingTracker } from "@/features/chapter/hooks/useReadingTracker.ts";
|
||||
import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
|
||||
|
||||
const Chapter = () => {
|
||||
const { setCurrentChapterPage, getCurrentChapterPage } = useReadingTracker();
|
||||
@ -109,6 +110,14 @@ const Chapter = () => {
|
||||
}
|
||||
}, [infiniteScroll]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<MangaLoadingState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.data) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
@ -125,6 +134,25 @@ const Chapter = () => {
|
||||
}
|
||||
|
||||
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) */
|
||||
const goToNextPage = () => {
|
||||
@ -171,10 +199,33 @@ const Chapter = () => {
|
||||
<h1 className="text-sm font-semibold text-foreground">
|
||||
{data.data.mangaTitle}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Chapter {chapterNumber}
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{chapterNumber}
|
||||
</p>
|
||||
</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 className="flex items-center gap-4">
|
||||
@ -202,7 +253,7 @@ const Chapter = () => {
|
||||
{data.data.mangaTitle}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chapter {chapterNumber}
|
||||
{chapterNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -224,6 +275,36 @@ const Chapter = () => {
|
||||
|
||||
{/* LOAD MORE SENTINEL */}
|
||||
<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>
|
||||
) : (
|
||||
/* ------------------------------------------------------------------ */
|
||||
@ -271,6 +352,36 @@ const Chapter = () => {
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { BookOpen, Search } from "lucide-react";
|
||||
import { BookOpen, PanelLeftOpen, Search } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useGetMangas } from "@/api/generated/catalog/catalog.ts";
|
||||
import { AuthHeader } from "@/components/AuthHeader.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Pagination } from "@/components/Pagination.tsx";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Skeleton } from "@/components/ui/skeleton.tsx";
|
||||
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
||||
import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
|
||||
import { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
|
||||
import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
|
||||
import { SortDropdown } from "@/features/home/components/SortDropdown.tsx";
|
||||
import { useDynamicPageSize } from "@/hooks/useDynamicPageSize.ts";
|
||||
|
||||
@ -33,11 +36,18 @@ const Home = () => {
|
||||
setSortOption,
|
||||
searchText,
|
||||
setSearchText,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
} = useUIState();
|
||||
|
||||
const [debouncedSearchText] = useDebounce(searchText, 500);
|
||||
|
||||
const { data: mangasData, queryKey: mangasQueryKey } = useGetMangas({
|
||||
const {
|
||||
data: mangasData,
|
||||
queryKey: mangasQueryKey,
|
||||
isPending,
|
||||
isFetching,
|
||||
} = useGetMangas({
|
||||
page: currentPage - 1,
|
||||
size: itemsPerPage,
|
||||
sort: ["id"],
|
||||
@ -47,6 +57,7 @@ const Home = () => {
|
||||
userFavorites,
|
||||
score: minRating,
|
||||
});
|
||||
const isFiltersDisabled = isFetching;
|
||||
|
||||
const startSearchRef = useRef(debouncedSearchText);
|
||||
|
||||
@ -62,6 +73,7 @@ const Home = () => {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{isSidebarOpen && (
|
||||
<FilterSidebar
|
||||
selectedGenres={selectedGenres}
|
||||
selectedStatus={selectedStatus}
|
||||
@ -73,9 +85,12 @@ const Home = () => {
|
||||
onRatingChange={setMinRating}
|
||||
onUserFavoritesChange={setUserFavorites}
|
||||
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">
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
@ -86,12 +101,31 @@ const Home = () => {
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||
MangaMochi
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{mangasData?.data?.totalElements} titles available
|
||||
<div className="mt-1 flex items-center gap-2 min-h-[1.25rem]">
|
||||
{isFetching ? (
|
||||
<Skeleton className="h-4 w-24" />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mangasData?.data?.totalElements ?? 0} titles available
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 />
|
||||
<AuthHeader />
|
||||
</div>
|
||||
@ -107,15 +141,18 @@ const Home = () => {
|
||||
onChange={(e) => {
|
||||
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>
|
||||
</header>
|
||||
|
||||
<main className="px-8 py-8">
|
||||
{mangasData?.data?.content && mangasData.data.content.length > 0 ? (
|
||||
<main className="flex-1 px-8 py-8 flex flex-col">
|
||||
{isPending ? (
|
||||
<MangaLoadingState />
|
||||
) : mangasData?.data?.content && mangasData.data.content.length > 0 ? (
|
||||
<>
|
||||
{mangasData.data?.totalElements && (
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
@ -130,6 +167,7 @@ const Home = () => {
|
||||
<SortDropdown
|
||||
currentSort={sortOption}
|
||||
onSortChange={setSortOption}
|
||||
isDisabled={isFiltersDisabled}
|
||||
/>
|
||||
</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">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
No manga found matching your filters.
|
||||
|
||||
@ -35,6 +35,8 @@ import { useUIState } from "@/contexts/UIStateContext.tsx";
|
||||
import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
|
||||
import { useScrollPersistence } from "@/hooks/useScrollPersistence.ts";
|
||||
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
const Manga = () => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
@ -44,7 +46,7 @@ const Manga = () => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: mangaData, queryKey } = useGetManga(mangaId);
|
||||
const { data: mangaData, queryKey, isLoading } = useGetManga(mangaId);
|
||||
|
||||
const { mutate, isPending: fetchPending } =
|
||||
useFetchContentProviderContentList({
|
||||
@ -122,7 +124,85 @@ const Manga = () => {
|
||||
[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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
@ -212,7 +292,13 @@ const Manga = () => {
|
||||
}}
|
||||
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
|
||||
size="icon"
|
||||
@ -226,13 +312,16 @@ const Manga = () => {
|
||||
}}
|
||||
disabled={isPendingFavoriteChange}
|
||||
>
|
||||
{isPendingFavoriteChange ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Heart
|
||||
className={`h-4 w-4 transition-colors ${
|
||||
mangaData?.data?.favorite
|
||||
className={`h-4 w-4 transition-colors ${mangaData?.data?.favorite
|
||||
? "fill-red-500 text-red-500"
|
||||
: "text-muted-foreground hover:text-red-500"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@ -349,11 +438,18 @@ const Manga = () => {
|
||||
<Card key={provider.id} className="border-border bg-card">
|
||||
<Collapsible
|
||||
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">
|
||||
<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">
|
||||
<Database className="h-5 w-5 text-primary" />
|
||||
<div className="text-left">
|
||||
@ -366,48 +462,71 @@ const Manga = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{provider.supportsChapterFetch && provider.active && (
|
||||
<div className={"flex gap-4 pr-4"}>
|
||||
<Button
|
||||
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>
|
||||
<div className="flex gap-4 px-4">
|
||||
{provider.chaptersAvailable > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isPending || fetchAllPending}
|
||||
onClick={() =>
|
||||
mutate({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
fetchAllMutate({
|
||||
mangaContentProviderId: provider.id ?? -1,
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
{fetchAllPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={
|
||||
provider.chaptersAvailable > 0
|
||||
? "cursor-pointer"
|
||||
: "invisible"
|
||||
}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform ${
|
||||
expandedProviderIds.includes(provider.id ?? -1)
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform ${expandedProviderIds.includes(provider.id ?? -1)
|
||||
? "rotate-180"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</CardContent>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</CardContent>
|
||||
<CollapsibleContent>
|
||||
<MangaChapter
|
||||
mangaId={mangaId}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user