feat/loading-state #31

Merged
rov merged 8 commits from feat/loading-state into main 2026-04-05 21:28:30 -03:00
12 changed files with 469 additions and 100 deletions

View File

@ -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
View File

@ -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

View 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>
);
};

View 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 }

View File

@ -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,

View File

@ -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"}`}

View File

@ -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

View File

@ -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) => {

View File

@ -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>

View File

@ -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>
</> </>
)} )}

View File

@ -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.

View File

@ -14,7 +14,7 @@ import { useCallback } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useGetManga } from "@/api/generated/catalog/catalog.ts"; import { useGetManga } from "@/api/generated/catalog/catalog.ts";
import {useFetchAllContentImages, useFetchContentProviderContentList} from "@/api/generated/ingestion/ingestion.ts"; import { useFetchAllContentImages, useFetchContentProviderContentList } from "@/api/generated/ingestion/ingestion.ts";
import { import {
useFollowManga, useFollowManga,
useSetFavorite, useSetFavorite,
@ -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}