Compare commits

...

2 Commits

Author SHA1 Message Date
90f8f50166 Enable ISR on list pages; cache reader + search DB queries
Stable image URLs (post-signing removal) make page-level caching
safe again. Homepage, genre page, sitemap, and detail page now
revalidate on an interval instead of running Prisma on every hit.
Reader and search keep dynamic rendering (searchParams) but wrap
their Prisma queries in unstable_cache.

TTLs: home/genre/detail 5m, reader manga 5m, reader page meta 1h
(immutable), search 1m, sitemap 1h.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:12:51 +08:00
0923dc1dce Reader: contain img inside aspect-ratio placeholder
Prior rendering let <img> flow naturally (h-auto), so subpixel aspect
mismatches between DB dims and natural file dims caused img to over-
flow the placeholder div — manifesting as gaps or content overlap
between consecutive pages. Switching to absolute inset-0 +
object-contain pins the img to div dimensions regardless.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:12:41 +08:00
7 changed files with 52 additions and 32 deletions

View File

@ -3,7 +3,7 @@ import { collectGenres } from "@/lib/genres";
import { GenreTabs } from "@/components/GenreTabs"; import { GenreTabs } from "@/components/GenreTabs";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const dynamic = "force-dynamic"; export const revalidate = 300;
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Genres", title: "Genres",

View File

@ -1,4 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { PageReader } from "@/components/PageReader"; import { PageReader } from "@/components/PageReader";
import { encodeId } from "@/lib/hashids"; import { encodeId } from "@/lib/hashids";
@ -9,6 +10,32 @@ type Props = {
searchParams: Promise<{ resume?: string }>; searchParams: Promise<{ resume?: string }>;
}; };
const getMangaForReader = unstable_cache(
async (slug: string) =>
prisma.manga.findUnique({
where: { slug },
include: {
chapters: {
orderBy: { number: "asc" },
include: { _count: { select: { pages: true } } },
},
},
}),
["reader-manga"],
{ revalidate: 300 }
);
const getChapterPageMeta = unstable_cache(
async (slug: string, chapterNum: number) =>
prisma.page.findMany({
where: { chapter: { number: chapterNum, manga: { slug } } },
orderBy: { number: "asc" },
select: { number: true, width: true, height: true },
}),
["reader-chapter-meta"],
{ revalidate: 3600 }
);
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: {
@ -18,7 +45,7 @@ export async function generateMetadata({
const chapterNum = parseInt(chapter, 10); const chapterNum = parseInt(chapter, 10);
if (isNaN(chapterNum)) return { title: "Not Found" }; if (isNaN(chapterNum)) return { title: "Not Found" };
const manga = await prisma.manga.findUnique({ where: { slug } }); const manga = await getMangaForReader(slug);
if (!manga) return { title: "Not Found" }; if (!manga) return { title: "Not Found" };
return { return {
@ -37,22 +64,8 @@ export default async function ChapterReaderPage({
if (isNaN(chapterNum)) notFound(); if (isNaN(chapterNum)) notFound();
const [manga, initialChapterMeta] = await Promise.all([ const [manga, initialChapterMeta] = await Promise.all([
prisma.manga.findUnique({ getMangaForReader(slug),
where: { slug }, getChapterPageMeta(slug, chapterNum),
include: {
chapters: {
orderBy: { number: "asc" },
include: {
_count: { select: { pages: true } },
},
},
},
}),
prisma.page.findMany({
where: { chapter: { number: chapterNum, manga: { slug } } },
orderBy: { number: "asc" },
select: { number: true, width: true, height: true },
}),
]); ]);
if (!manga) notFound(); if (!manga) notFound();

View File

@ -5,6 +5,8 @@ import { ChapterList } from "@/components/ChapterList";
import { ReadingProgressButton } from "@/components/ReadingProgressButton"; import { ReadingProgressButton } from "@/components/ReadingProgressButton";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const revalidate = 300;
type Props = { type Props = {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}; };

View File

@ -3,7 +3,7 @@ import { collectGenres } from "@/lib/genres";
import { TrendingCarousel } from "@/components/TrendingCarousel"; import { TrendingCarousel } from "@/components/TrendingCarousel";
import { GenreTabs } from "@/components/GenreTabs"; import { GenreTabs } from "@/components/GenreTabs";
export const dynamic = "force-dynamic"; export const revalidate = 300;
export default async function Home() { export default async function Home() {
const manga = await prisma.manga.findMany({ const manga = await prisma.manga.findMany({

View File

@ -1,3 +1,4 @@
import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { MangaGrid } from "@/components/MangaGrid"; import { MangaGrid } from "@/components/MangaGrid";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -10,19 +11,23 @@ type Props = {
searchParams: Promise<{ q?: string }>; searchParams: Promise<{ q?: string }>;
}; };
const searchManga = unstable_cache(
async (q: string) =>
prisma.manga.findMany({
where: {
status: "PUBLISHED",
title: { contains: q, mode: "insensitive" },
},
orderBy: { title: "asc" },
include: { _count: { select: { chapters: true } } },
}),
["search-manga"],
{ revalidate: 60 }
);
export default async function SearchPage({ searchParams }: Props) { export default async function SearchPage({ searchParams }: Props) {
const { q } = await searchParams; const { q } = await searchParams;
const manga = q ? await searchManga(q) : [];
const manga = q
? await prisma.manga.findMany({
where: {
status: "PUBLISHED",
title: { contains: q, mode: "insensitive" },
},
orderBy: { title: "asc" },
include: { _count: { select: { chapters: true } } },
})
: [];
return ( return (
<div className="max-w-7xl mx-auto px-4 py-6"> <div className="max-w-7xl mx-auto px-4 py-6">

View File

@ -1,7 +1,7 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic"; export const revalidate = 3600;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const manga = await prisma.manga.findMany({ const manga = await prisma.manga.findMany({

View File

@ -659,7 +659,7 @@ export function PageReader({
src={url} src={url}
alt={`Page ${p.number}`} alt={`Page ${p.number}`}
fetchPriority={isVisible ? "high" : "low"} fetchPriority={isVisible ? "high" : "low"}
className="w-full h-auto block [-webkit-touch-callout:none]" className="absolute inset-0 w-full h-full object-contain [-webkit-touch-callout:none]"
draggable={false} draggable={false}
onError={() => { onError={() => {
setImages((prev) => { setImages((prev) => {