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

View File

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

View File

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