From 57255e26242353a5e1794eb57f1699c140afe673 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 11 Apr 2026 21:15:01 +0800 Subject: [PATCH] Add signed R2 URLs, batched page fetching, and 3D chapter badges - Sign all image URLs server-side with 60s expiry presigned URLs - Add /api/pages endpoint for batched page fetching (7 per batch) - PageReader prefetches next batch when user scrolls to 3rd page - Move chapter count badge outside overflow-hidden for 3D effect - Fix missing URL signing on search and genre pages - Extract signCoverUrls helper to reduce duplication - Clamp API limit param to prevent abuse Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/manga/route.ts | 6 +- app/api/pages/route.ts | 30 +++++++ app/api/search/route.ts | 3 +- app/genre/page.tsx | 6 +- app/manga/[slug]/[chapter]/page.tsx | 5 +- app/manga/[slug]/page.tsx | 5 +- app/page.tsx | 9 +- app/search/page.tsx | 5 +- components/GenreTabs.tsx | 12 +-- components/MangaCard.tsx | 12 +-- components/PageReader.tsx | 122 +++++++++++++++++++++++----- lib/r2.ts | 32 +++++++- 12 files changed, 203 insertions(+), 44 deletions(-) create mode 100644 app/api/pages/route.ts 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) {
{manga.title} 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)} + > {`Page
))} @@ -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), + })) + ); +}