- Resume scroll position only when arriving via 继续阅读 (?resume=1).
Plain chapter-list / drawer clicks now actively scroll to top on mount.
- Progress format extended to {chapter, page, ratio} for within-page
precision; legacy bare-number and {chapter, page} still read correctly.
- Tappable skeleton logo (sunflower outline, spins) while a page loads;
tap force-fetches a fresh signed URL.
- Viewport-priority image loading: second IntersectionObserver at margin 0
marks truly-visible pages, drives <img fetchpriority="high"> and fires
immediate single-page fetches that cut the batch queue.
- Bounded image cache: unmount previous chapter's <img> elements when
currentPage > 5 into the new chapter; placeholders stay for layout.
One AbortController per live chapter; unmount aborts in-flight batches.
- Hashed chapter IDs on the wire via hashids; DB PKs unchanged.
- Origin/Referer allowlist + rate limiting on all /api/* routes via a
withGuards(opts, handler) wrapper (eliminates 6-line boilerplate x5).
- robots.txt allows Googlebot/Bingbot/Slurp/DuckDuckBot/Baiduspider/
YandexBot only; disallows /api/ for all UAs.
- Extract pure helpers for future TDD: lib/scroll-ratio.ts (calcScrollRatio,
scrollOffsetFromRatio), lib/progress.ts (parseProgress + injectable
StorageLike), lib/rate-limit.ts (optional { now, store, ipOf } deps),
lib/api-guards.ts.
- New env keys: HASHIDS_SALT, ALLOWED_ORIGINS (wired into docker-compose).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the prepend/flushSync/scrollBy gymnastics with placeholder divs
sized by each page's width/height. Document height is correct from the
first paint, so resume + backward scroll just work — no scroll
compensation, no gesture fights, no forced aspect ratio distorting images.
- New /api/chapters/[id]/meta returns the dim skeleton for any chapter.
- Chapter page pre-fetches the starting chapter's meta server-side and
parallelizes the two Prisma queries via Promise.all.
- Reader renders placeholders with aspectRatio: w/h, lazy-loads image
URLs in batches via IntersectionObserver, and prefetches the next
chapter's meta ~3 pages from the end.
- Scroll tracker walks only the intersecting-pages set (~3–5 elements)
instead of every loaded page per rAF.
- scroll={false} on all Links into the reader + { scroll: false } on
double-tap router.push, plus a belt-and-suspenders rAF re-scroll, so
resume survives soft navigation and browser scroll-restoration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Upgrades the reader's last-read tracking from {chapter} to {chapter, page}.
- ReadingProgressButton: storage is now JSON {chapter, page}; legacy
bare-number values are read back as {chapter, page: 1}. Button label
is unchanged ("继续阅读 · #N title") — the extra precision lives in
the reader's first-fetch offset, not the label.
- PageReader: on mount with saved progress, seed offsetRef to
(page - 1) so the first /api/pages call starts AT the resumed page
instead of the beginning of the chapter. currentPageNum state is
initialized from storage too, so the first persist write is a no-op
that matches the saved value.
- Scroll tracker now also tracks currentPageNum (last page whose top
has crossed above viewport top+80), and persistence writes the
{chapter, page} pair on each change.
Known limitation: earlier pages of the resumed chapter aren't loaded
yet — a follow-up commit adds scroll-up prefetch for those.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reading progress
- New ReadingProgressButton client component that reads/writes
localStorage key "sunnymh:last-read:{slug}"
- PageReader writes the currently-visible chapter as the user scrolls
through continuous chapters
- Manga detail CTA now reads: "开始阅读" on first visit, or
"继续阅读 · #{N} {title}" when a prior chapter is stored (with
spacing and truncation for long titles)
Multiple genres
- lib/genres.ts with parseGenres() and collectGenres() helpers to
split "冒险, 恋爱, 魔幻" into a list and aggregate across a collection
- Manga detail renders one pill per genre
- GenreTabs filters via parseGenres(...).includes(activeGenre) so a
multi-genre manga appears under each of its genres
- Home / Genre pages compute the tab list from collectGenres(signedManga)
- Card captions (GenreTabs + TrendingCarousel) show "冒险 · 恋爱 · 魔幻"
with truncation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>