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:
parent
e7dc39738c
commit
57255e2624
@ -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) {
|
||||
|
||||
30
app/api/pages/route.ts
Normal file
30
app/api/pages/route.ts
Normal 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);
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="max-w-6xl mx-auto px-4 py-5">
|
||||
<GenreTabs manga={manga} genres={genres} />
|
||||
<GenreTabs manga={signedManga} genres={genres} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<PageReader
|
||||
pages={currentChapter.pages}
|
||||
chapterId={currentChapter.id}
|
||||
totalPages={currentChapter._count.pages}
|
||||
mangaSlug={manga.slug}
|
||||
mangaTitle={manga.title}
|
||||
chapterNumber={currentChapter.number}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { signUrl } from "@/lib/r2";
|
||||
import { ChapterList } from "@/components/ChapterList";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
@ -36,6 +37,8 @@ export default async function MangaDetailPage({ params }: Props) {
|
||||
|
||||
if (!manga) notFound();
|
||||
|
||||
const signedCoverUrl = await signUrl(manga.coverUrl);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-6">
|
||||
{/* Hero section */}
|
||||
@ -43,7 +46,7 @@ export default async function MangaDetailPage({ params }: Props) {
|
||||
<div className="w-28 sm:w-36 shrink-0">
|
||||
<div className="aspect-[3/4] rounded-xl overflow-hidden bg-card">
|
||||
<img
|
||||
src={manga.coverUrl}
|
||||
src={signedCoverUrl}
|
||||
alt={manga.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
@ -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 (
|
||||
<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} />
|
||||
|
||||
{/* Genre browsing section — horizontal tabs + filtered grid */}
|
||||
<GenreTabs manga={manga} genres={genres} />
|
||||
<GenreTabs manga={signedManga} genres={genres} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
<h1 className="text-xl font-bold mb-4">
|
||||
{q ? `Results for "${q}"` : "Search"}
|
||||
</h1>
|
||||
{q ? (
|
||||
<MangaGrid manga={manga} />
|
||||
<MangaGrid manga={signedManga} />
|
||||
) : (
|
||||
<p className="text-muted text-center py-12">
|
||||
Use the search bar above to find manga
|
||||
|
||||
@ -66,7 +66,12 @@ export function GenreTabs({
|
||||
{filtered.length > 0 ? (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3 sm:gap-4">
|
||||
{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">
|
||||
<img
|
||||
src={m.coverUrl}
|
||||
@ -75,11 +80,6 @@ export function GenreTabs({
|
||||
loading="lazy"
|
||||
/>
|
||||
<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">
|
||||
<h3 className="text-[12px] sm:text-[13px] font-semibold text-white leading-tight line-clamp-2">
|
||||
{m.title}
|
||||
|
||||
@ -14,7 +14,12 @@ export function MangaCard({
|
||||
chapterCount,
|
||||
}: MangaCardProps) {
|
||||
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">
|
||||
<img
|
||||
src={coverUrl}
|
||||
@ -23,11 +28,6 @@ export function MangaCard({
|
||||
loading="lazy"
|
||||
/>
|
||||
<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">
|
||||
<h3 className="text-sm font-semibold text-white leading-tight line-clamp-2">
|
||||
{title}
|
||||
|
||||
@ -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<PageData[]>([]);
|
||||
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(() => {
|
||||
const handleScroll = () => {
|
||||
@ -89,16 +178,20 @@ export function PageReader({
|
||||
|
||||
{/* Pages - vertical scroll (webtoon style, best for mobile) */}
|
||||
<div
|
||||
className="max-w-4xl mx-auto"
|
||||
className="max-w-4xl mx-auto leading-[0]"
|
||||
onClick={() => setShowUI(!showUI)}
|
||||
>
|
||||
{pages.map((page) => (
|
||||
<div key={page.number} className="relative">
|
||||
{pages.map((page, i) => (
|
||||
<div
|
||||
key={page.number}
|
||||
className="relative leading-[0]"
|
||||
data-page-index={i}
|
||||
ref={(el) => setPageRef(i, el)}
|
||||
>
|
||||
<img
|
||||
src={page.imageUrl}
|
||||
alt={`Page ${page.number}`}
|
||||
className="w-full h-auto block"
|
||||
loading={page.number <= 3 ? "eager" : "lazy"}
|
||||
className="w-full h-auto block align-bottom -mb-px"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -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"
|
||||
>
|
||||
<svg
|
||||
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}
|
||||
{chapterTitle}
|
||||
</button>
|
||||
{nextChapter ? (
|
||||
<Link
|
||||
|
||||
32
lib/r2.ts
32
lib/r2.ts
@ -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";
|
||||
|
||||
const s3 = new S3Client({
|
||||
@ -22,3 +26,29 @@ export async function getPresignedUploadUrl(key: string) {
|
||||
export function getPublicUrl(key: string) {
|
||||
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),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user