diff --git a/app/api/manga/route.ts b/app/api/manga/route.ts
index e36c28d..df89808 100644
--- a/app/api/manga/route.ts
+++ b/app/api/manga/route.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { signCoverUrls } from "@/lib/r2";
import { NextRequest } from "next/server";
export async function GET() {
@@ -8,7 +9,10 @@ export async function GET() {
_count: { select: { chapters: true } },
},
});
- return Response.json(manga);
+
+ const signedManga = await signCoverUrls(manga);
+
+ return Response.json(signedManga);
}
export async function POST(request: NextRequest) {
diff --git a/app/api/pages/route.ts b/app/api/pages/route.ts
new file mode 100644
index 0000000..8fe520f
--- /dev/null
+++ b/app/api/pages/route.ts
@@ -0,0 +1,30 @@
+import { prisma } from "@/lib/db";
+import { signUrl } from "@/lib/r2";
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const chapterId = parseInt(searchParams.get("chapterId") ?? "", 10);
+ const offset = Math.max(parseInt(searchParams.get("offset") ?? "0", 10), 0);
+ const limit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "7", 10), 1), 20);
+
+ if (isNaN(chapterId)) {
+ return Response.json({ error: "Missing chapterId" }, { status: 400 });
+ }
+
+ const pages = await prisma.page.findMany({
+ where: { chapterId },
+ orderBy: { number: "asc" },
+ skip: offset,
+ take: limit,
+ select: { number: true, imageUrl: true },
+ });
+
+ const signedPages = await Promise.all(
+ pages.map(async (p) => ({
+ number: p.number,
+ imageUrl: await signUrl(p.imageUrl),
+ }))
+ );
+
+ return Response.json(signedPages);
+}
diff --git a/app/api/search/route.ts b/app/api/search/route.ts
index 1c129bf..9c4b666 100644
--- a/app/api/search/route.ts
+++ b/app/api/search/route.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { signCoverUrls } from "@/lib/r2";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
@@ -23,5 +24,5 @@ export async function GET(request: Request) {
orderBy: { title: "asc" },
});
- return Response.json(results);
+ return Response.json(await signCoverUrls(results));
}
diff --git a/app/genre/page.tsx b/app/genre/page.tsx
index 0c0ec3b..cc6340e 100644
--- a/app/genre/page.tsx
+++ b/app/genre/page.tsx
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { signCoverUrls } from "@/lib/r2";
import { GenreTabs } from "@/components/GenreTabs";
import type { Metadata } from "next";
@@ -15,11 +16,12 @@ export default async function GenrePage() {
include: { _count: { select: { chapters: true } } },
});
- const genres = [...new Set(manga.map((m) => m.genre))].sort();
+ const signedManga = await signCoverUrls(manga);
+ const genres = [...new Set(signedManga.map((m) => m.genre))].sort();
return (
-
+
);
}
diff --git a/app/manga/[slug]/[chapter]/page.tsx b/app/manga/[slug]/[chapter]/page.tsx
index d9dad13..2c165c9 100644
--- a/app/manga/[slug]/[chapter]/page.tsx
+++ b/app/manga/[slug]/[chapter]/page.tsx
@@ -32,7 +32,7 @@ export default async function ChapterReaderPage({ params }: Props) {
chapters: {
orderBy: { number: "asc" },
include: {
- pages: { orderBy: { number: "asc" } },
+ _count: { select: { pages: true } },
},
},
},
@@ -60,7 +60,8 @@ export default async function ChapterReaderPage({ params }: Props) {
return (
{/* Hero section */}
@@ -43,7 +46,7 @@ export default async function MangaDetailPage({ params }: Props) {
diff --git a/app/page.tsx b/app/page.tsx
index 249938f..ccb248b 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { signCoverUrls } from "@/lib/r2";
import { TrendingCarousel } from "@/components/TrendingCarousel";
import { GenreTabs } from "@/components/GenreTabs";
@@ -11,11 +12,13 @@ export default async function Home() {
include: { _count: { select: { chapters: true } } },
});
+ const signedManga = await signCoverUrls(manga);
+
// Top 10 for trending
- const trending = manga.slice(0, 10);
+ const trending = signedManga.slice(0, 10);
// Extract unique genres
- const genres = [...new Set(manga.map((m) => m.genre))].sort();
+ const genres = [...new Set(signedManga.map((m) => m.genre))].sort();
return (
@@ -23,7 +26,7 @@ export default async function Home() {
{/* Genre browsing section — horizontal tabs + filtered grid */}
-
+
);
}
diff --git a/app/search/page.tsx b/app/search/page.tsx
index 6e704b6..9516633 100644
--- a/app/search/page.tsx
+++ b/app/search/page.tsx
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { signCoverUrls } from "@/lib/r2";
import { MangaGrid } from "@/components/MangaGrid";
import type { Metadata } from "next";
@@ -24,13 +25,15 @@ export default async function SearchPage({ searchParams }: Props) {
})
: [];
+ const signedManga = await signCoverUrls(manga);
+
return (
{q ? `Results for "${q}"` : "Search"}
{q ? (
-
+
) : (
Use the search bar above to find manga
diff --git a/components/GenreTabs.tsx b/components/GenreTabs.tsx
index 598a7a8..5183968 100644
--- a/components/GenreTabs.tsx
+++ b/components/GenreTabs.tsx
@@ -66,7 +66,12 @@ export function GenreTabs({
{filtered.length > 0 ? (
{filtered.map((m) => (
-
+
+ {m._count && m._count.chapters > 0 && (
+
+ {m._count.chapters}
+
+ )}
- {m._count && m._count.chapters > 0 && (
-
- {m._count.chapters} ch
-
- )}
{m.title}
diff --git a/components/MangaCard.tsx b/components/MangaCard.tsx
index b6af6f9..abcd611 100644
--- a/components/MangaCard.tsx
+++ b/components/MangaCard.tsx
@@ -14,7 +14,12 @@ export function MangaCard({
chapterCount,
}: MangaCardProps) {
return (
-
+
+ {chapterCount !== undefined && (
+
+ {chapterCount}
+
+ )}
- {chapterCount !== undefined && (
-
- {chapterCount} ch
-
- )}
{title}
diff --git a/components/PageReader.tsx b/components/PageReader.tsx
index b28ecca..1e79a29 100644
--- a/components/PageReader.tsx
+++ b/components/PageReader.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
type PageData = {
@@ -14,7 +14,8 @@ type ChapterInfo = {
};
type PageReaderProps = {
- pages: PageData[];
+ chapterId: number;
+ totalPages: number;
mangaSlug: string;
mangaTitle: string;
chapterNumber: number;
@@ -24,8 +25,12 @@ type PageReaderProps = {
chapters: ChapterInfo[];
};
+const BATCH_SIZE = 7;
+const PREFETCH_AT = 3;
+
export function PageReader({
- pages,
+ chapterId,
+ totalPages,
mangaSlug,
mangaTitle,
chapterNumber,
@@ -36,7 +41,91 @@ export function PageReader({
}: PageReaderProps) {
const [showUI, setShowUI] = useState(true);
const [showDrawer, setShowDrawer] = useState(false);
+ const [pages, setPages] = useState([]);
const lastScrollY = useRef(0);
+ const offsetRef = useRef(0);
+ const doneRef = useRef(false);
+ const loadingRef = useRef(false);
+ const triggerIndicesRef = useRef>(new Set());
+ const observerRef = useRef(null);
+ const pageRefsRef = useRef>(new Map());
+
+ const fetchBatch = useCallback(async () => {
+ if (loadingRef.current || doneRef.current) return;
+ loadingRef.current = true;
+ try {
+ const res = await fetch(
+ `/api/pages?chapterId=${chapterId}&offset=${offsetRef.current}&limit=${BATCH_SIZE}`
+ );
+ const batch: PageData[] = await res.json();
+ if (batch.length === 0) {
+ doneRef.current = true;
+ } else {
+ const triggerIndex = offsetRef.current + PREFETCH_AT - 1;
+ triggerIndicesRef.current.add(triggerIndex);
+
+ // If trigger element is already mounted, observe it now
+ const existing = pageRefsRef.current.get(triggerIndex);
+ if (existing && observerRef.current) {
+ observerRef.current.observe(existing);
+ }
+
+ setPages((prev) => [...prev, ...batch]);
+ offsetRef.current += batch.length;
+ if (offsetRef.current >= totalPages) {
+ doneRef.current = true;
+ }
+ }
+ } catch {
+ // retry on next intersection
+ } finally {
+ loadingRef.current = false;
+ }
+ }, [chapterId, totalPages]);
+
+ useEffect(() => {
+ observerRef.current = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ const index = Number(
+ (entry.target as HTMLElement).dataset.pageIndex
+ );
+ if (triggerIndicesRef.current.has(index)) {
+ triggerIndicesRef.current.delete(index);
+ fetchBatch();
+ }
+ }
+ }
+ },
+ { rootMargin: "200px" }
+ );
+ return () => observerRef.current?.disconnect();
+ }, [fetchBatch]);
+
+ useEffect(() => {
+ fetchBatch();
+ }, [fetchBatch]);
+
+ const setPageRef = useCallback(
+ (index: number, el: HTMLDivElement | null) => {
+ const observer = observerRef.current;
+ if (!observer) return;
+
+ const prev = pageRefsRef.current.get(index);
+ if (prev) observer.unobserve(prev);
+
+ if (el) {
+ pageRefsRef.current.set(index, el);
+ if (triggerIndicesRef.current.has(index)) {
+ observer.observe(el);
+ }
+ } else {
+ pageRefsRef.current.delete(index);
+ }
+ },
+ []
+ );
useEffect(() => {
const handleScroll = () => {
@@ -89,16 +178,20 @@ export function PageReader({
{/* Pages - vertical scroll (webtoon style, best for mobile) */}
setShowUI(!showUI)}
>
- {pages.map((page) => (
-
+ {pages.map((page, i) => (
+
setPageRef(i, el)}
+ >
))}
@@ -134,18 +227,7 @@ export function PageReader({
onClick={() => setShowDrawer(true)}
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
>
-
-
-
-
-
- Ch. {chapterNumber}
+ {chapterTitle}
{nextChapter ? (
(
+ items: T[]
+): Promise
{
+ return Promise.all(
+ items.map(async (item) => ({
+ ...item,
+ coverUrl: await signUrl(item.coverUrl),
+ }))
+ );
+}