diff --git a/CLAUDE.md b/CLAUDE.md
index ebf6c35..02db7b4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,28 +41,58 @@ simple reader would hit. Key invariants:
Python scraper writes them on insert; the backfill script covers older
rows. With dims=0, the reader falls back to 3/4 and layout shifts on
image load.
+- Inside each placeholder div the `
` is `absolute inset-0 w-full
+ h-full object-contain` — this is load-bearing. Natural-flow (`h-auto`)
+ lets subpixel aspect mismatch between DB dims and actual file dims
+ overflow the div, causing overlap with the next page. Absolute-pinned
+ + object-contain clamps the img to div dimensions no matter what.
- Initial chapter meta (dims skeleton) is fetched server-side in
`app/manga/[slug]/[chapter]/page.tsx` and passed as a prop. Later
- chapters' meta is lazy-fetched via `/api/chapters/[id]/meta`.
-- Image URLs (signed, short-TTL) come in batches via `/api/pages` triggered
- by an `IntersectionObserver` with 1200 px margin. A separate
- intersecting-pages `Map` is maintained for the scroll tick so the topmost
- visible page can be found in O(k) without walking every placeholder.
+ chapters' meta is lazy-fetched via `/api/chapters/[id]/meta`, triggered
+ by an `IntersectionObserver` with 1200 px margin when user enters the
+ last `PREFETCH_NEXT_AT` pages of a chapter.
+- Image URLs are **not signed** — served direct from the custom domain.
+ Fetched via `/api/pages` in fixed chunks of `IMAGE_CHUNK_SIZE = 5`.
+ Trigger runs in a `useEffect` on `[currentPageNum, currentChapterNum,
+ images, chapterMetas]`: fetches forward chunk when `currentPage +
+ PREFETCH_LEAD (3) >= maxCached`, backward chunk when `currentPage -
+ PREFETCH_LEAD <= minCached`, and next chapter's first chunk when
+ `currentPage + PREFETCH_LEAD >= meta.length`. `forceFetchPage`
+ (`limit=1`) is a viewport-observer fallback for pages that enter view
+ without cached URL (onError retry, unhandled gap).
+- A separate intersecting-pages `Map` is maintained for the scroll tick
+ so the topmost visible page can be found in O(k) without walking every
+ placeholder.
- Continuous multi-chapter reading auto-appends the next chapter when the
- user gets within `PREFETCH_NEXT_AT = 3` pages of the end.
+ user gets within `PREFETCH_NEXT_AT = 3` pages of the end. Only chapters
+ `>= startChapterNumber` render (scrolling **up** past the start doesn't
+ load prior chapters — use drawer or double-tap-left). Deferred bidi-
+ infinite-scroll plan is in the user-memory system.
+- Image cache pruning runs on `[currentChapterNum, currentPageNum]`.
+ `keep` set is `{current, current-1 if in first KEEP_PREV_CHAPTER_PAGES,
+ current+1 if in last KEEP_PREV_CHAPTER_PAGES}`. Must keep current+1
+ conditionally so a backward scroll from a just-entered chapter doesn't
+ immediately drop the images the user is about to re-enter.
- URL syncs to the chapter currently in viewport via
`window.history.replaceState`. `prevChapter` / `nextChapter` for
double-tap nav are derived from `currentChapterNum`, **not** from the URL
or props.
+- `window.history.scrollRestoration = "manual"` is set in the resume
+ `useLayoutEffect`. Without this, refreshing while deep in an auto-
+ appended chapter has the browser restore a `scrollY` that references a
+ (now gone) taller document, clamping to `scrollHeight` and landing
+ near the bottom.
- All Links into the reader use `scroll={false}`, and the double-tap
`router.push(..., { scroll: false })`. This is load-bearing — without it,
App Router's default scroll-to-top clobbers the `useLayoutEffect` that
restores the resume page.
- Reading progress is persisted to `localStorage` under the key
- `sunnymh:last-read:` as JSON `{chapter, page}`. `readProgress`
- in `components/ReadingProgressButton.tsx` also accepts the **legacy
- bare-number format** (just a chapter number string) for backward
- compatibility — preserve this when touching the storage format.
+ `sunnymh:last-read:` as JSON `{chapter, page, ratio}` (ratio =
+ fractional scroll offset inside the current page). `readProgress` in
+ `components/ReadingProgressButton.tsx` also accepts the **legacy
+ bare-number format** (just a chapter number string) AND the pre-ratio
+ `{chapter, page}` shape — preserve both when touching the storage
+ format.
### Immersive reader route
@@ -98,7 +128,8 @@ nav being present needs to account for this.
- `app/manga/[slug]/[chapter]/page.tsx` — reader. Issues both required
queries (manga+chapters, initial chapter page meta) in parallel via
`Promise.all`.
-- `app/api/pages/route.ts` — batched `{number, imageUrl}` with signed URLs.
+- `app/api/pages/route.ts` — batched `{number, imageUrl}` (raw custom-
+ domain URLs, not signed).
- `app/api/chapters/[chapterId]/meta/route.ts` — `[{number, width, height}]`
for lazy next-chapter prefetch.
- `app/api/search/route.ts` — case-insensitive title search.
@@ -107,6 +138,41 @@ nav being present needs to account for this.
- `app/sitemap.ts` — auto-generated `/sitemap.xml`. Each page uses
`generateMetadata()` for per-route title/description.
+### Caching strategy
+
+All DB-querying pages are `export const dynamic = "force-dynamic"`
+with their Prisma queries wrapped in `unstable_cache`. **Pages do not
+page-level ISR / static-prerender** — the Docker production build
+runs without `DATABASE_URL` in the build env, so any page Next tries
+to prerender at build time would fail on the DB connection. Keeping
+everything dynamic sidesteps this while `unstable_cache` around the
+query still dedupes DB hits across runtime requests.
+
+Cache keys + TTLs:
+
+- `home-manga-list` — `app/page.tsx` (5 min)
+- `genre-manga-list` — `app/genre/page.tsx` (5 min)
+- `sitemap-manga-list` — `app/sitemap.ts` (1 hour)
+- `reader-manga` — reader's manga+chapters query (5 min)
+- `reader-chapter-meta` — reader's page-dim query (1 hour — chapter
+ contents are immutable)
+- `search-manga` — `app/search/page.tsx`, keyed on query string (60 s)
+- `app/manga/[slug]/page.tsx` — has `revalidate = 300` directly (it's a
+ dynamic segment without `generateStaticParams`, so Next caches the
+ rendered result per-URL at runtime without prerendering at build).
+
+**No revalidateTag hook exists.** The scraper (`../manga-dl/manga.py`)
+writes to Postgres directly and has no callback into this app.
+Consequence: new chapters / manga appear up to 5 min after they land
+in the DB. Accepted trade-off for now; wire a `POST /api/revalidate`
+endpoint if that window becomes too long.
+- **Images**: no application-layer cache — Cloudflare edge cache handles
+ it. Bucket is private at the R2 level; only exposed via the custom
+ domain `images.04080616.xyz` which is fronted by a **WAF Custom Rule**
+ blocking requests whose `http.referer` is non-empty and doesn't
+ contain `04080616.xyz` / `localhost` / `10.8.0.`. Hotlinking from
+ other sites 403s; direct `
` and RSS / no-referer access work.
+
### Ingestion is a sibling Python repo
New manga and chapters are created by `../manga-dl/manga.py`, not by this
diff --git a/app/genre/page.tsx b/app/genre/page.tsx
index 033b691..3bdb84f 100644
--- a/app/genre/page.tsx
+++ b/app/genre/page.tsx
@@ -1,20 +1,28 @@
+import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/db";
import { collectGenres } from "@/lib/genres";
import { GenreTabs } from "@/components/GenreTabs";
import type { Metadata } from "next";
-export const revalidate = 300;
+export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Genres",
};
+const getGenreManga = unstable_cache(
+ async () =>
+ prisma.manga.findMany({
+ where: { status: "PUBLISHED" },
+ orderBy: { title: "asc" },
+ include: { _count: { select: { chapters: true } } },
+ }),
+ ["genre-manga-list"],
+ { revalidate: 300 }
+);
+
export default async function GenrePage() {
- const manga = await prisma.manga.findMany({
- where: { status: "PUBLISHED" },
- orderBy: { title: "asc" },
- include: { _count: { select: { chapters: true } } },
- });
+ const manga = await getGenreManga();
const genres = collectGenres(manga);
diff --git a/app/page.tsx b/app/page.tsx
index 8579e78..11453d0 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,16 +1,24 @@
+import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/db";
import { collectGenres } from "@/lib/genres";
import { TrendingCarousel } from "@/components/TrendingCarousel";
import { GenreTabs } from "@/components/GenreTabs";
-export const revalidate = 300;
+export const dynamic = "force-dynamic";
+
+const getPublishedManga = unstable_cache(
+ async () =>
+ prisma.manga.findMany({
+ where: { status: "PUBLISHED" },
+ orderBy: { updatedAt: "desc" },
+ include: { _count: { select: { chapters: true } } },
+ }),
+ ["home-manga-list"],
+ { revalidate: 300 }
+);
export default async function Home() {
- const manga = await prisma.manga.findMany({
- where: { status: "PUBLISHED" },
- orderBy: { updatedAt: "desc" },
- include: { _count: { select: { chapters: true } } },
- });
+ const manga = await getPublishedManga();
const trending = manga.slice(0, 10);
const genres = collectGenres(manga);
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 6156d52..c5e5f16 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -1,13 +1,21 @@
import type { MetadataRoute } from "next";
+import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/db";
-export const revalidate = 3600;
+export const dynamic = "force-dynamic";
+
+const getSitemapManga = unstable_cache(
+ async () =>
+ prisma.manga.findMany({
+ where: { status: "PUBLISHED" },
+ select: { slug: true, updatedAt: true },
+ }),
+ ["sitemap-manga-list"],
+ { revalidate: 3600 }
+);
export default async function sitemap(): Promise {
- const manga = await prisma.manga.findMany({
- where: { status: "PUBLISHED" },
- select: { slug: true, updatedAt: true },
- });
+ const manga = await getSitemapManga();
const mangaEntries: MetadataRoute.Sitemap = manga.map((m) => ({
url: `https://www.04080616.xyz/manga/${m.slug}`,