feature(home): chapter page read
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful

This commit is contained in:
Rodrigo Verdiani 2025-10-30 16:06:46 -03:00
parent a7d7d5fddb
commit 7359823f4d
8 changed files with 363 additions and 4 deletions

View File

@ -0,0 +1,23 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/providers/ThemeProvider.tsx";
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="h-9 w-9"
>
{theme === "dark" ? (
<Sun className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
) : (
<Moon className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
);
}

View File

@ -0,0 +1,52 @@
import { useCallback } from "react";
interface ReadingTrackerData {
chapterPage: { [chapterId: number]: number };
}
export const useReadingTracker = () => {
const setCurrentChapterPage = useCallback(
(id: number, pageNumber: number) => {
const jsonString = localStorage.getItem("readingTrackerData");
let readingTrackerData: ReadingTrackerData;
try {
readingTrackerData = jsonString
? JSON.parse(jsonString)
: { chapterPage: {} };
} catch (error) {
console.error("Error parsing reading tracker data:", error);
readingTrackerData = { chapterPage: {} };
}
const updatedData = {
...readingTrackerData,
chapterPage: {
...readingTrackerData.chapterPage,
[id]: pageNumber,
},
};
localStorage.setItem("readingTrackerData", JSON.stringify(updatedData));
},
[],
);
const getCurrentChapterPage = useCallback(
(id: number): number | undefined => {
const jsonString = localStorage.getItem("readingTrackerData");
if (!jsonString) return undefined;
try {
const readingTrackerData: ReadingTrackerData = JSON.parse(jsonString);
return readingTrackerData.chapterPage[id];
} catch (error) {
console.error("Error parsing reading tracker data:", error);
return undefined;
}
},
[],
);
return { setCurrentChapterPage, getCurrentChapterPage };
};

215
src/pages/Chapter.tsx Normal file
View File

@ -0,0 +1,215 @@
import { ArrowLeft, ChevronLeft, ChevronRight, Home } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import {
useGetMangaChapterImages,
useMarkAsRead,
} from "@/api/generated/manga-chapter/manga-chapter.ts";
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Button } from "@/components/ui/button";
import { useReadingTracker } from "@/features/chapter/hooks/useReadingTracker.ts";
const Chapter = () => {
const { setCurrentChapterPage, getCurrentChapterPage } = useReadingTracker();
const navigate = useNavigate();
const params = useParams();
const mangaId = Number(params.mangaId);
const chapterNumber = Number(params.chapterId);
const [currentPage, setCurrentPage] = useState(
getCurrentChapterPage(chapterNumber) ?? 1,
);
const { data, isLoading } = useGetMangaChapterImages(chapterNumber);
const { mutate } = useMarkAsRead();
useEffect(() => {
if (!data || isLoading) {
return;
}
if (currentPage === data.data?.chapterImageKeys.length) {
mutate({ chapterId: chapterNumber });
}
}, [data, mutate, currentPage]);
useEffect(() => {
setCurrentChapterPage(chapterNumber, currentPage);
}, [chapterNumber, currentPage]);
useEffect(() => {
if (!isLoading && !data?.data) {
return;
}
const storedChapterPage = getCurrentChapterPage(chapterNumber);
if (storedChapterPage) {
setCurrentPage(storedChapterPage);
}
}, [getCurrentChapterPage, isLoading, data?.data]);
if (!data?.data) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<h1 className="text-2xl font-bold text-foreground">
Manga not found
</h1>
<Button onClick={() => navigate("/")} className="mt-4">
Go back home
</Button>
</div>
</div>
);
}
const goToNextPage = () => {
if (!data?.data) {
return;
}
if (currentPage < data.data.chapterImageKeys.length) {
setCurrentPage(currentPage + 1);
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
const goToPreviousPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
return (
<div className="min-h-screen bg-background">
{/* 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>
<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">
Chapter {chapterNumber}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
Page {currentPage} / {data.data.chapterImageKeys.length}
</span>
<ThemeToggle />
</div>
</div>
</div>
</header>
{/* Reader Content */}
<main className="mx-auto max-w-4xl px-4 py-8">
{/* Mobile title */}
<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">
Chapter {chapterNumber}
</p>
</div>
{/* Manga Page */}
<div className="relative mx-auto mb-8 overflow-hidden rounded-lg border border-border bg-muted">
<img
src={
import.meta.env.VITE_OMV_BASE_URL +
"/" +
data.data.chapterImageKeys[currentPage - 1] ||
"/placeholder.svg"
}
alt={`Page ${currentPage}`}
width={1000}
height={1400}
className="h-auto w-full"
// priority
/>
</div>
{/* Navigation Controls */}
<div className="space-y-4">
{/* Page Navigation */}
<div className="flex items-center justify-center gap-4">
<Button
onClick={goToPreviousPage}
disabled={currentPage === 1}
variant="outline"
className="gap-2 bg-transparent"
>
<ChevronLeft className="h-4 w-4" />
Previous Page
</Button>
<span className="text-sm font-medium text-foreground">
{currentPage} / {data.data.chapterImageKeys.length}
</span>
<Button
onClick={goToNextPage}
disabled={currentPage === data.data.chapterImageKeys.length}
variant="outline"
className="gap-2 bg-transparent"
>
Next Page
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/*/!* Chapter Navigation *!/*/}
{/*{(currentPage === data.chapterImageKeys.length || currentPage === 1) && (*/}
{/* <div className="flex items-center justify-center gap-4 border-t border-border pt-4">*/}
{/* <Button*/}
{/* onClick={goToPreviousChapter}*/}
{/* disabled={chapterNumber === 1}*/}
{/* variant="secondary"*/}
{/* className="gap-2"*/}
{/* >*/}
{/* <ChevronLeft className="h-4 w-4" />*/}
{/* Previous Chapter*/}
{/* </Button>*/}
{/* <Button*/}
{/* onClick={goToNextChapter}*/}
{/* disabled={chapterNumber === manga.chapters}*/}
{/* variant="secondary"*/}
{/* className="gap-2"*/}
{/* >*/}
{/* Next Chapter*/}
{/* <ChevronRight className="h-4 w-4" />*/}
{/* </Button>*/}
{/* </div>*/}
{/*)}*/}
</div>
</main>
</div>
);
};
export default Chapter;

View File

@ -4,6 +4,7 @@ import { useDebounce } from "use-debounce";
import { useGetMangas } from "@/api/generated/manga/manga.ts"; import { useGetMangas } from "@/api/generated/manga/manga.ts";
import { AuthHeader } from "@/components/AuthHeader.tsx"; import { AuthHeader } from "@/components/AuthHeader.tsx";
import { Pagination } from "@/components/Pagination.tsx"; import { Pagination } from "@/components/Pagination.tsx";
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx"; import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
import { ImportDropdown } from "@/features/home/components/ImportDropdown.tsx"; import { ImportDropdown } from "@/features/home/components/ImportDropdown.tsx";
@ -77,7 +78,7 @@ const Home = () => {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<ImportDropdown /> <ImportDropdown />
{/*<ThemeToggle />*/} <ThemeToggle />
<AuthHeader /> <AuthHeader />
</div> </div>
</div> </div>

View File

@ -15,6 +15,7 @@ import {
useGetManga, useGetManga,
} from "@/api/generated/manga/manga.ts"; } from "@/api/generated/manga/manga.ts";
import { useFetchAllChapters } from "@/api/generated/manga-chapter/manga-chapter.ts"; import { useFetchAllChapters } from "@/api/generated/manga-chapter/manga-chapter.ts";
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@ -96,7 +97,7 @@ const Manga = () => {
</Button> </Button>
<h1 className="text-xl font-bold text-foreground">MangaMochi</h1> <h1 className="text-xl font-bold text-foreground">MangaMochi</h1>
</div> </div>
{/*<ThemeToggle />*/} <ThemeToggle />
</div> </div>
</div> </div>
</header> </header>

View File

@ -4,6 +4,7 @@ import { AppLayout } from "@/components/Layout/AppLayout.tsx";
const Home = lazy(() => import("./Home.tsx")); const Home = lazy(() => import("./Home.tsx"));
const Manga = lazy(() => import("./Manga.tsx")); const Manga = lazy(() => import("./Manga.tsx"));
const Chapter = lazy(() => import("./Chapter.tsx"));
const Login = lazy(() => import("./Login.tsx")); const Login = lazy(() => import("./Login.tsx"));
const Register = lazy(() => import("./Register.tsx")); const Register = lazy(() => import("./Register.tsx"));
@ -34,6 +35,10 @@ export const Router = createBrowserRouter([
path: ":mangaId", path: ":mangaId",
element: <Manga />, element: <Manga />,
}, },
{
path: ":mangaId/chapter/:chapterId",
element: <Chapter />,
},
], ],
}, },
], ],

View File

@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { AuthProvider } from "@/contexts/AuthContext.tsx"; import { AuthProvider } from "@/contexts/AuthContext.tsx";
import { ThemeProvider } from "@/providers/ThemeProvider.tsx";
interface ProvidersProps { interface ProvidersProps {
children: ReactNode; children: ReactNode;
@ -10,8 +11,10 @@ interface ProvidersProps {
export const Providers = ({ children }: ProvidersProps) => { export const Providers = ({ children }: ProvidersProps) => {
return ( return (
<QueryClientProvider client={new QueryClient()}> <QueryClientProvider client={new QueryClient()}>
<Toaster /> <ThemeProvider>
<AuthProvider>{children}</AuthProvider> <Toaster />
<AuthProvider>{children}</AuthProvider>
</ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
); );
}; };

View File

@ -0,0 +1,59 @@
import {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from "react";
type Theme = "light" | "dark";
type ThemeContextType = {
theme: Theme;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [theme, setTheme] = useState<Theme>("dark");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const savedTheme = localStorage.getItem("theme") as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
}
}, []);
useEffect(() => {
if (mounted) {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
localStorage.setItem("theme", theme);
}
}, [theme, mounted]);
const toggleTheme = () => {
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}