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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-04-11 21:15:01 +08:00
parent e7dc39738c
commit 57255e2624
12 changed files with 203 additions and 44 deletions

View File

@ -1,4 +1,5 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signCoverUrls } from "@/lib/r2";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
export async function GET() { export async function GET() {
@ -8,7 +9,10 @@ export async function GET() {
_count: { select: { chapters: true } }, _count: { select: { chapters: true } },
}, },
}); });
return Response.json(manga);
const signedManga = await signCoverUrls(manga);
return Response.json(signedManga);
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {

30
app/api/pages/route.ts Normal file
View File

@ -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);
}

View File

@ -1,4 +1,5 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signCoverUrls } from "@/lib/r2";
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -23,5 +24,5 @@ export async function GET(request: Request) {
orderBy: { title: "asc" }, orderBy: { title: "asc" },
}); });
return Response.json(results); return Response.json(await signCoverUrls(results));
} }

View File

@ -1,4 +1,5 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signCoverUrls } from "@/lib/r2";
import { GenreTabs } from "@/components/GenreTabs"; import { GenreTabs } from "@/components/GenreTabs";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -15,11 +16,12 @@ export default async function GenrePage() {
include: { _count: { select: { chapters: true } } }, 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 ( return (
<div className="max-w-6xl mx-auto px-4 py-5"> <div className="max-w-6xl mx-auto px-4 py-5">
<GenreTabs manga={manga} genres={genres} /> <GenreTabs manga={signedManga} genres={genres} />
</div> </div>
); );
} }

View File

@ -32,7 +32,7 @@ export default async function ChapterReaderPage({ params }: Props) {
chapters: { chapters: {
orderBy: { number: "asc" }, orderBy: { number: "asc" },
include: { include: {
pages: { orderBy: { number: "asc" } }, _count: { select: { pages: true } },
}, },
}, },
}, },
@ -60,7 +60,8 @@ export default async function ChapterReaderPage({ params }: Props) {
return ( return (
<PageReader <PageReader
pages={currentChapter.pages} chapterId={currentChapter.id}
totalPages={currentChapter._count.pages}
mangaSlug={manga.slug} mangaSlug={manga.slug}
mangaTitle={manga.title} mangaTitle={manga.title}
chapterNumber={currentChapter.number} chapterNumber={currentChapter.number}

View File

@ -1,5 +1,6 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signUrl } from "@/lib/r2";
import { ChapterList } from "@/components/ChapterList"; import { ChapterList } from "@/components/ChapterList";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -36,6 +37,8 @@ export default async function MangaDetailPage({ params }: Props) {
if (!manga) notFound(); if (!manga) notFound();
const signedCoverUrl = await signUrl(manga.coverUrl);
return ( return (
<div className="max-w-3xl mx-auto px-4 py-6"> <div className="max-w-3xl mx-auto px-4 py-6">
{/* Hero section */} {/* Hero section */}
@ -43,7 +46,7 @@ export default async function MangaDetailPage({ params }: Props) {
<div className="w-28 sm:w-36 shrink-0"> <div className="w-28 sm:w-36 shrink-0">
<div className="aspect-[3/4] rounded-xl overflow-hidden bg-card"> <div className="aspect-[3/4] rounded-xl overflow-hidden bg-card">
<img <img
src={manga.coverUrl} src={signedCoverUrl}
alt={manga.title} alt={manga.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />

View File

@ -1,4 +1,5 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signCoverUrls } from "@/lib/r2";
import { TrendingCarousel } from "@/components/TrendingCarousel"; import { TrendingCarousel } from "@/components/TrendingCarousel";
import { GenreTabs } from "@/components/GenreTabs"; import { GenreTabs } from "@/components/GenreTabs";
@ -11,11 +12,13 @@ export default async function Home() {
include: { _count: { select: { chapters: true } } }, include: { _count: { select: { chapters: true } } },
}); });
const signedManga = await signCoverUrls(manga);
// Top 10 for trending // Top 10 for trending
const trending = manga.slice(0, 10); const trending = signedManga.slice(0, 10);
// Extract unique genres // Extract unique genres
const genres = [...new Set(manga.map((m) => m.genre))].sort(); const genres = [...new Set(signedManga.map((m) => m.genre))].sort();
return ( return (
<div className="max-w-6xl mx-auto px-4 py-5 space-y-8"> <div className="max-w-6xl mx-auto px-4 py-5 space-y-8">
@ -23,7 +26,7 @@ export default async function Home() {
<TrendingCarousel manga={trending} /> <TrendingCarousel manga={trending} />
{/* Genre browsing section — horizontal tabs + filtered grid */} {/* Genre browsing section — horizontal tabs + filtered grid */}
<GenreTabs manga={manga} genres={genres} /> <GenreTabs manga={signedManga} genres={genres} />
</div> </div>
); );
} }

View File

@ -1,4 +1,5 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signCoverUrls } from "@/lib/r2";
import { MangaGrid } from "@/components/MangaGrid"; import { MangaGrid } from "@/components/MangaGrid";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -24,13 +25,15 @@ export default async function SearchPage({ searchParams }: Props) {
}) })
: []; : [];
const signedManga = await signCoverUrls(manga);
return ( return (
<div className="max-w-7xl mx-auto px-4 py-6"> <div className="max-w-7xl mx-auto px-4 py-6">
<h1 className="text-xl font-bold mb-4"> <h1 className="text-xl font-bold mb-4">
{q ? `Results for "${q}"` : "Search"} {q ? `Results for "${q}"` : "Search"}
</h1> </h1>
{q ? ( {q ? (
<MangaGrid manga={manga} /> <MangaGrid manga={signedManga} />
) : ( ) : (
<p className="text-muted text-center py-12"> <p className="text-muted text-center py-12">
Use the search bar above to find manga Use the search bar above to find manga

View File

@ -66,7 +66,12 @@ export function GenreTabs({
{filtered.length > 0 ? ( {filtered.length > 0 ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3 sm:gap-4"> <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3 sm:gap-4">
{filtered.map((m) => ( {filtered.map((m) => (
<Link key={m.slug} href={`/manga/${m.slug}`} className="group block"> <Link key={m.slug} href={`/manga/${m.slug}`} className="group block relative">
{m._count && m._count.chapters > 0 && (
<span className="absolute -top-2 -right-2 z-10 min-w-[30px] h-[30px] flex items-center justify-center px-1.5 text-sm font-bold bg-accent text-white rounded-lg shadow-[2px_4px_12px_rgba(0,0,0,0.5),0_1px_3px_rgba(0,0,0,0.3)] border border-white/20">
{m._count.chapters}
</span>
)}
<div className="relative aspect-[3/4] rounded-xl overflow-hidden bg-card"> <div className="relative aspect-[3/4] rounded-xl overflow-hidden bg-card">
<img <img
src={m.coverUrl} src={m.coverUrl}
@ -75,11 +80,6 @@ export function GenreTabs({
loading="lazy" loading="lazy"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
{m._count && m._count.chapters > 0 && (
<span className="absolute top-2 right-2 px-1.5 py-0.5 text-[10px] font-bold bg-accent/90 text-white rounded-md">
{m._count.chapters} ch
</span>
)}
<div className="absolute bottom-0 left-0 right-0 p-2.5"> <div className="absolute bottom-0 left-0 right-0 p-2.5">
<h3 className="text-[12px] sm:text-[13px] font-semibold text-white leading-tight line-clamp-2"> <h3 className="text-[12px] sm:text-[13px] font-semibold text-white leading-tight line-clamp-2">
{m.title} {m.title}

View File

@ -14,7 +14,12 @@ export function MangaCard({
chapterCount, chapterCount,
}: MangaCardProps) { }: MangaCardProps) {
return ( return (
<Link href={`/manga/${slug}`} className="group block"> <Link href={`/manga/${slug}`} className="group block relative">
{chapterCount !== undefined && (
<span className="absolute -top-2 -right-2 z-10 min-w-[30px] h-[30px] flex items-center justify-center px-1.5 text-sm font-bold bg-accent text-white rounded-lg shadow-[2px_4px_12px_rgba(0,0,0,0.5),0_1px_3px_rgba(0,0,0,0.3)] border border-white/20">
{chapterCount}
</span>
)}
<div className="relative aspect-[3/4] rounded-xl overflow-hidden bg-card"> <div className="relative aspect-[3/4] rounded-xl overflow-hidden bg-card">
<img <img
src={coverUrl} src={coverUrl}
@ -23,11 +28,6 @@ export function MangaCard({
loading="lazy" loading="lazy"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
{chapterCount !== undefined && (
<span className="absolute top-2 right-2 px-2 py-0.5 text-[10px] font-semibold bg-accent/90 text-white rounded-full">
{chapterCount} ch
</span>
)}
<div className="absolute bottom-0 left-0 right-0 p-3"> <div className="absolute bottom-0 left-0 right-0 p-3">
<h3 className="text-sm font-semibold text-white leading-tight line-clamp-2"> <h3 className="text-sm font-semibold text-white leading-tight line-clamp-2">
{title} {title}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
type PageData = { type PageData = {
@ -14,7 +14,8 @@ type ChapterInfo = {
}; };
type PageReaderProps = { type PageReaderProps = {
pages: PageData[]; chapterId: number;
totalPages: number;
mangaSlug: string; mangaSlug: string;
mangaTitle: string; mangaTitle: string;
chapterNumber: number; chapterNumber: number;
@ -24,8 +25,12 @@ type PageReaderProps = {
chapters: ChapterInfo[]; chapters: ChapterInfo[];
}; };
const BATCH_SIZE = 7;
const PREFETCH_AT = 3;
export function PageReader({ export function PageReader({
pages, chapterId,
totalPages,
mangaSlug, mangaSlug,
mangaTitle, mangaTitle,
chapterNumber, chapterNumber,
@ -36,7 +41,91 @@ export function PageReader({
}: PageReaderProps) { }: PageReaderProps) {
const [showUI, setShowUI] = useState(true); const [showUI, setShowUI] = useState(true);
const [showDrawer, setShowDrawer] = useState(false); const [showDrawer, setShowDrawer] = useState(false);
const [pages, setPages] = useState<PageData[]>([]);
const lastScrollY = useRef(0); const lastScrollY = useRef(0);
const offsetRef = useRef(0);
const doneRef = useRef(false);
const loadingRef = useRef(false);
const triggerIndicesRef = useRef<Set<number>>(new Set());
const observerRef = useRef<IntersectionObserver | null>(null);
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(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(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
@ -89,16 +178,20 @@ export function PageReader({
{/* Pages - vertical scroll (webtoon style, best for mobile) */} {/* Pages - vertical scroll (webtoon style, best for mobile) */}
<div <div
className="max-w-4xl mx-auto" className="max-w-4xl mx-auto leading-[0]"
onClick={() => setShowUI(!showUI)} onClick={() => setShowUI(!showUI)}
> >
{pages.map((page) => ( {pages.map((page, i) => (
<div key={page.number} className="relative"> <div
key={page.number}
className="relative leading-[0]"
data-page-index={i}
ref={(el) => setPageRef(i, el)}
>
<img <img
src={page.imageUrl} src={page.imageUrl}
alt={`Page ${page.number}`} alt={`Page ${page.number}`}
className="w-full h-auto block" className="w-full h-auto block align-bottom -mb-px"
loading={page.number <= 3 ? "eager" : "lazy"}
/> />
</div> </div>
))} ))}
@ -134,18 +227,7 @@ export function PageReader({
onClick={() => setShowDrawer(true)} onClick={() => setShowDrawer(true)}
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors" className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
> >
<svg {chapterTitle}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className="w-5 h-5"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
Ch. {chapterNumber}
</button> </button>
{nextChapter ? ( {nextChapter ? (
<Link <Link

View File

@ -1,4 +1,8 @@
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import {
S3Client,
PutObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ const s3 = new S3Client({
@ -22,3 +26,29 @@ export async function getPresignedUploadUrl(key: string) {
export function getPublicUrl(key: string) { export function getPublicUrl(key: string) {
return `${process.env.R2_PUBLIC_URL}/${key}`; return `${process.env.R2_PUBLIC_URL}/${key}`;
} }
export async function getPresignedReadUrl(key: string) {
const command = new GetObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
});
return getSignedUrl(s3, command, { expiresIn: 60 });
}
export async function signUrl(publicUrl: string) {
const prefix = process.env.R2_PUBLIC_URL!;
if (!publicUrl.startsWith(prefix)) return publicUrl;
const key = publicUrl.replace(prefix, "").replace(/^\//, "");
return getPresignedReadUrl(key);
}
export async function signCoverUrls<T extends { coverUrl: string }>(
items: T[]
): Promise<T[]> {
return Promise.all(
items.map(async (item) => ({
...item,
coverUrl: await signUrl(item.coverUrl),
}))
);
}