feat/improvements #37

Merged
rov merged 3 commits from feat/improvements into main 2026-04-18 15:25:36 -03:00
3 changed files with 245 additions and 205 deletions
Showing only changes of commit 866f01f281 - Show all commits

View File

@ -26,20 +26,25 @@ export const MangaChapter = ({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutate: mutateDownloadChapterArchive } = useDownloadContentArchive({ const { mutate: mutateDownloadChapterArchive, isPending: isDownloading } =
mutation: { useDownloadContentArchive({
onSuccess: (data, { mangaContentId }) => { mutation: {
const url = window.URL.createObjectURL(data); onMutate: ({ mangaContentId }) => setDownloadingId(mangaContentId),
const link = document.createElement("a"); onSuccess: (data, { mangaContentId }) => {
link.href = url; const url = window.URL.createObjectURL(data);
link.setAttribute("download", `${mangaContentId}.cbz`); const link = document.createElement("a");
document.body.appendChild(link); link.href = url;
link.click(); link.setAttribute("download", `${mangaContentId}.cbz`);
link.remove(); document.body.appendChild(link);
window.URL.revokeObjectURL(url); link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onSettled: () => setDownloadingId(null),
}, },
}, });
});
const [downloadingId, setDownloadingId] = useState<number | null>(null);
const { mutate, isPending: isPendingFetchChapter } = const { mutate, isPending: isPendingFetchChapter } =
useFetchContentProviderContent({ useFetchContentProviderContent({
@ -78,13 +83,12 @@ export const MangaChapter = ({
return ( return (
<div <div
key={chapter.id} key={chapter.id}
className="flex items-center justify-between rounded-lg border border-border bg-background p-3" className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between rounded-lg border border-border bg-background p-4"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
<div <div
className={`flex h-8 w-8 items-center justify-center rounded-full ${ className={`flex h-8 w-8 items-center justify-center rounded-full ${chapter.isRead ? "bg-primary/20" : "bg-muted"
chapter.isRead ? "bg-primary/20" : "bg-muted" }`}
}`}
> >
{chapter.isRead ? ( {chapter.isRead ? (
<Check className="h-4 w-4 text-primary" /> <Check className="h-4 w-4 text-primary" />
@ -94,35 +98,57 @@ export const MangaChapter = ({
</span> </span>
)} )}
</div> </div>
<div> <div className="min-w-0 flex-1 flex items-center justify-between gap-2">
<p className="text-sm font-medium text-foreground flex items-center gap-2"> <div className="min-w-0 flex-1">
{chapter.language?.code && ( <p className="text-sm font-medium text-foreground flex items-center gap-2 flex-wrap min-w-0">
<ReactCountryFlag {chapter.language?.code && (
countryCode={chapter.language.code.split("-")[1]} <ReactCountryFlag
svg countryCode={chapter.language.code.split("-")[1]}
style={{ svg
width: "1.2em", style={{
height: "1.2em", width: "1.2em",
}} height: "1.2em",
title={chapter.language.name} }}
/> title={chapter.language.name}
)} />
{chapter.title} )}
</p> <span className="truncate flex-1">{chapter.title}</span>
{chapter.downloaded && (
<p className="text-xs text-muted-foreground">
In database
</p> </p>
{chapter.downloaded && (
<p className="text-xs text-muted-foreground">
In database
</p>
)}
</div>
{chapter.downloaded && (
<Button
size="sm"
variant="outline"
onClick={() =>
mutateDownloadChapterArchive({
mangaContentId: chapter.id,
params: { contentArchiveFileType: "CBZ" },
})
}
className="sm:hidden h-8 w-8 p-0"
disabled={isDownloading && downloadingId === chapter.id}
>
{isDownloading && downloadingId === chapter.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
)} )}
</div> </div>
</div> </div>
{chapter.downloaded ? ( {chapter.downloaded ? (
<div className="flex gap-2"> <div className="flex flex-col gap-3 sm:flex-row sm:gap-2 w-full sm:w-auto">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => handleReadChapter(chapter.id)} onClick={() => handleReadChapter(chapter.id)}
className="gap-2" className="gap-2 w-full sm:w-auto"
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
Read Read
@ -136,9 +162,14 @@ export const MangaChapter = ({
params: { contentArchiveFileType: "CBZ" }, params: { contentArchiveFileType: "CBZ" },
}) })
} }
className="gap-2" className="hidden sm:flex gap-2 w-full sm:w-auto"
disabled={isDownloading && downloadingId === chapter.id}
> >
<Download className="h-4 w-4" /> {isDownloading && downloadingId === chapter.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Download Download
</Button> </Button>
</div> </div>
@ -148,7 +179,7 @@ export const MangaChapter = ({
variant="outline" variant="outline"
onClick={() => fetchChapter(chapter.id)} onClick={() => fetchChapter(chapter.id)}
disabled={isPendingFetchChapter} disabled={isPendingFetchChapter}
className="gap-2 cursor-pointer" className="gap-2 cursor-pointer w-full sm:w-auto"
> >
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
{isPendingFetchChapter && fetchingId === chapter.id {isPendingFetchChapter && fetchingId === chapter.id

View File

@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { useGetMangaContentImages } from "@/api/generated/content/content.ts"; import { useGetMangaContentImages } from "@/api/generated/content/content.ts";
import { useMarkContentAsRead } from "@/api/generated/user-interaction/user-interaction.ts"; import { useMarkContentAsRead } from "@/api/generated/user-interaction/user-interaction.ts";
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -314,76 +313,85 @@ const Chapter = () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* HEADER */} {/* HEADER */}
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 text-xs sm:text-sm">
<div className="px-4 py-4 sm:px-8"> <div className="px-2 py-2 sm:px-4 sm:py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2 sm:gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-1 sm:gap-4 flex-1 min-w-0">
<Button <div className="flex items-center gap-1 shrink-0">
variant="ghost" <Button
size="sm" variant="ghost"
onClick={() => navigate(`/manga/${mangaId}`)} size="sm"
className="gap-2" onClick={() => navigate(`/manga/${mangaId}`)}
> className="gap-2 px-2 sm:px-3"
<ArrowLeft className="h-4 w-4" /> >
<span className="hidden sm:inline">Back</span> <ArrowLeft className="h-4 w-4" />
</Button> <span className="hidden md:inline">Back</span>
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => navigate("/")} onClick={() => navigate("/")}
className="gap-2" className="gap-2 px-1.5 sm:px-3"
> >
<Home className="h-4 w-4" /> <Home className="h-4 w-4" />
<span className="hidden sm:inline">Home</span> <span className="hidden md:inline">Home</span>
</Button> </Button>
<div className="hidden sm:block">
<h1 className="text-sm font-semibold text-foreground">
{data.data.mangaTitle}
</h1>
<p className="text-xs text-muted-foreground text-center">
{chapterNumber}
</p>
</div> </div>
{previousContentId && ( <div className="flex items-center gap-1 shrink-0">
<Button {previousContentId && (
variant="ghost" <Button
size="sm" variant="ghost"
onClick={goToPreviousChapter} size="sm"
className="gap-1" onClick={goToPreviousChapter}
> className="gap-1 px-1.5"
<ChevronLeft className="h-4 w-4" /> >
<span className="hidden sm:inline">Prev</span> <ChevronLeft className="h-4 w-4" />
</Button> <span className="hidden sm:inline">Prev</span>
)} </Button>
{nextContentId && ( )}
<Button {nextContentId && (
variant="ghost" <Button
size="sm" variant="ghost"
onClick={goToNextChapter} size="sm"
className="gap-1" onClick={goToNextChapter}
> className="gap-1 px-1.5"
<span className="hidden sm:inline">Next</span> >
<ChevronRight className="h-4 w-4" /> <span className="hidden sm:inline">Next</span>
</Button> <ChevronRight className="h-4 w-4" />
)} </Button>
)}
</div>
{/* Title Info - now visible on all screens in the middle */}
<div className="flex-1 min-w-0 px-1 text-center">
<h1 className="text-[10px] sm:text-xs font-semibold text-foreground truncate">
{data.data.mangaTitle}
</h1>
<p className="text-[8px] sm:text-[10px] text-muted-foreground truncate">
Chapter {chapterNumber}
</p>
</div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-2 sm:gap-4 shrink-0">
<span className="text-sm text-muted-foreground"> <span className="text-[10px] sm:text-sm text-muted-foreground whitespace-nowrap">
Page {currentPage} / {images.length} {currentPage} / {images.length}
</span> </span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setInfiniteScroll((v) => !v)} onClick={() => setInfiniteScroll((v) => !v)}
className="text-xs" className="h-8 px-2 sm:px-3 text-[10px] sm:text-xs"
> >
{infiniteScroll ? "Single Page Mode" : "Scroll Mode"} {infiniteScroll ? (
<span className="">Single</span>
) : (
<span className="">Scroll</span>
)}
<span className="hidden sm:inline"> Mode</span>
</Button> </Button>
<ThemeToggle />
</div> </div>
</div> </div>
</div> </div>
@ -391,18 +399,10 @@ const Chapter = () => {
{/* MAIN */} {/* MAIN */}
<main <main
className={`mx-auto max-w-4xl ${ className={`mx-auto max-w-4xl ${infiniteScroll ? "px-0 py-0" : "px-4 py-8"
infiniteScroll ? "px-0 py-0" : "px-4 py-8" }`}
}`}
> >
<div className="mb-4 sm:hidden"> {/* Removed mobile header from main as it's now in the sticky header */}
<h1 className="text-lg font-semibold text-foreground">
{data.data.mangaTitle}
</h1>
<p className="text-sm text-muted-foreground">
{chapterNumber}
</p>
</div>
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* MODE 1 --- INFINITE SCROLL MODE */} {/* MODE 1 --- INFINITE SCROLL MODE */}

View File

@ -8,6 +8,7 @@ import {
ChevronDown, ChevronDown,
Database, Database,
Heart, Heart,
MoreVertical,
Star, Star,
} from "lucide-react"; } from "lucide-react";
import { useCallback } from "react"; import { useCallback } from "react";
@ -30,6 +31,12 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible.tsx"; } from "@/components/ui/collapsible.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import { useAuth } from "@/contexts/AuthContext.tsx"; import { useAuth } from "@/contexts/AuthContext.tsx";
import { useUIState } from "@/contexts/UIStateContext.tsx"; import { useUIState } from "@/contexts/UIStateContext.tsx";
import { MangaChapter } from "@/features/manga/MangaChapter.tsx"; import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
@ -155,9 +162,9 @@ const Manga = () => {
{/* Content Shell */} {/* Content Shell */}
<main className="px-8 py-8"> <main className="px-8 py-8">
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
<div className="grid gap-8 lg:grid-cols-[300px_1fr]"> <div className="grid gap-8 md:grid-cols-[300px_1fr]">
{/* Cover Skeleton */} {/* Cover Skeleton */}
<Skeleton className="aspect-2/3 w-full rounded-lg lg:sticky lg:top-8" /> <Skeleton className="aspect-2/3 w-full rounded-lg md:sticky md:top-8" />
{/* Details Skeleton */} {/* Details Skeleton */}
<div className="space-y-6"> <div className="space-y-6">
@ -252,10 +259,10 @@ const Manga = () => {
<main className="px-8 py-8"> <main className="px-8 py-8">
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
{/* Manga Info Section */} {/* Manga Info Section */}
<div className="grid gap-8 lg:grid-cols-[300px_1fr]"> <div className="grid gap-8 md:grid-cols-[300px_1fr]">
{/* Cover */} {/* Cover */}
<div className="lg:sticky lg:top-24"> <div className="md:sticky md:top-24">
<div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-muted"> <div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-muted w-full">
<img <img
src={ src={
(mangaData.data?.coverImageKey && (mangaData.data?.coverImageKey &&
@ -273,14 +280,14 @@ const Manga = () => {
{/* Details */} {/* Details */}
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<div className="mb-3 flex items-center justify-between gap-4"> <div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-balance text-4xl font-bold tracking-tight text-foreground"> <h1 className="text-balance text-3xl sm:text-4xl font-bold tracking-tight text-foreground">
{mangaData.data?.title} {mangaData.data?.title}
</h1> </h1>
<div className="flex gap-4 items-center"> <div className="flex gap-2 items-center flex-wrap">
<Badge <Badge
variant="secondary" variant="secondary"
className="border border-border bg-card text-foreground max-h-6" className="border border-border bg-card text-foreground whitespace-nowrap"
> >
{mangaData.data?.status} {mangaData.data?.status}
</Badge> </Badge>
@ -294,7 +301,7 @@ const Manga = () => {
)} )}
{isAuthenticated && ( {isAuthenticated && (
<> <div className="flex gap-2 ml-auto sm:ml-0">
<Button <Button
size="icon" size="icon"
variant="outline" variant="outline"
@ -338,7 +345,7 @@ const Manga = () => {
/> />
)} )}
</Button> </Button>
</> </div>
)} )}
</div> </div>
</div> </div>
@ -395,6 +402,18 @@ const Manga = () => {
dangerouslySetInnerHTML={{ __html: mangaData.data?.synopsis ?? "" }} dangerouslySetInnerHTML={{ __html: mangaData.data?.synopsis ?? "" }}
/> />
<div className="flex flex-wrap gap-2">
{mangaData.data?.genres.map((genre) => (
<Badge
key={genre}
variant="outline"
className="border-border text-foreground"
>
{genre}
</Badge>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-4"> <div className="flex items-center gap-3 rounded-lg border border-border bg-card p-4">
<Star className="h-5 w-5 fill-primary text-primary" /> <Star className="h-5 w-5 fill-primary text-primary" />
@ -446,18 +465,6 @@ const Manga = () => {
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2">
{mangaData.data?.genres.map((genre) => (
<Badge
key={genre}
variant="outline"
className="border-border text-foreground"
>
{genre}
</Badge>
))}
</div>
</div> </div>
</div> </div>
@ -489,83 +496,85 @@ const Manga = () => {
asChild asChild
className={`flex-1 ${provider.chaptersAvailable > 0 ? "cursor-pointer" : "cursor-default"}`} 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"> <p className="font-semibold text-foreground">
<p className="font-semibold text-foreground"> {provider.providerName}
{provider.providerName} </p>
</p> <p className="text-sm text-muted-foreground whitespace-nowrap">
<p className="text-sm text-muted-foreground"> {provider.chaptersDownloaded} downloaded {" "}
{provider.chaptersDownloaded} downloaded {" "} {provider.chaptersAvailable} available
{provider.chaptersAvailable} available </p>
</p>
</div>
</div> </div>
</div> </div>
</CollapsibleTrigger> </CollapsibleTrigger>
{provider.supportsChapterFetch && provider.active && ( <div className="flex items-center gap-2">
<div className="flex gap-4 px-4"> <CollapsibleTrigger asChild>
{provider.chaptersAvailable > 0 && ( <div
<Button className={
size="sm" provider.chaptersAvailable > 0
variant="outline" ? "cursor-pointer"
disabled={isPending || fetchAllPending} : "invisible"
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 ? ( <ChevronDown
<Spinner /> className={`h-5 w-5 text-muted-foreground transition-transform ${expandedProviderIds.includes(provider.id ?? -1)
) : ( ? "rotate-180"
<Database className="h-4 w-4" /> : ""
)} }`}
Fetch list from Provider />
</Button> </div>
</div> </CollapsibleTrigger>
)}
<CollapsibleTrigger asChild> {provider.supportsChapterFetch && provider.active && (
<div <DropdownMenu>
className={ <DropdownMenuTrigger asChild>
provider.chaptersAvailable > 0 <Button
? "cursor-pointer" variant="ghost"
: "invisible" size="icon"
} disabled={isPending || fetchAllPending}
> onClick={(e) => e.stopPropagation()}
<ChevronDown >
className={`h-5 w-5 text-muted-foreground transition-transform ${expandedProviderIds.includes(provider.id ?? -1) {isPending || fetchAllPending ? (
? "rotate-180" <Spinner />
: "" ) : (
}`} <MoreVertical className="h-4 w-4" />
/> )}
</div> </Button>
</CollapsibleTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end">
{provider.chaptersAvailable > 0 && (
<DropdownMenuItem
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
fetchAllMutate({
mangaContentProviderId: provider.id ?? -1,
});
}}
className="gap-2"
>
<Database className="h-4 w-4" />
Fetch all from Provider
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
mutate({
mangaContentProviderId: provider.id ?? -1,
});
}}
className="gap-2"
>
<Database className="h-4 w-4" />
Fetch list from Provider
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</CardContent> </CardContent>
<CollapsibleContent> <CollapsibleContent>
<MangaChapter <MangaChapter