sunnymh-manga-site/lib/progress.ts
yiekheng b993de43bc Reader: fix resume bug, add loading skeleton, scraping protection, bounded image cache
- 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>
2026-04-15 21:48:15 +08:00

69 lines
1.6 KiB
TypeScript

export type ReadingProgress = {
chapter: number;
page: number;
ratio: number;
};
export type StorageLike = Pick<Storage, "getItem" | "setItem">;
export function storageKey(slug: string): string {
return `sunnymh:last-read:${slug}`;
}
function clampRatio(n: unknown): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
export function parseProgress(raw: string | null): ReadingProgress | null {
if (!raw) return null;
if (raw.startsWith("{")) {
try {
const parsed = JSON.parse(raw) as Partial<ReadingProgress>;
if (
typeof parsed.chapter === "number" &&
typeof parsed.page === "number" &&
parsed.chapter > 0 &&
parsed.page > 0
) {
return {
chapter: parsed.chapter,
page: parsed.page,
ratio: clampRatio(parsed.ratio),
};
}
} catch {
return null;
}
return null;
}
const n = Number(raw);
return Number.isFinite(n) && n > 0
? { chapter: n, page: 1, ratio: 0 }
: null;
}
export function readProgress(
slug: string,
storage: StorageLike | null = defaultStorage()
): ReadingProgress | null {
if (!storage) return null;
return parseProgress(storage.getItem(storageKey(slug)));
}
export function writeProgress(
slug: string,
progress: ReadingProgress,
storage: StorageLike | null = defaultStorage()
): void {
if (!storage) return;
storage.setItem(storageKey(slug), JSON.stringify(progress));
}
function defaultStorage(): StorageLike | null {
return typeof window === "undefined" ? null : window.localStorage;
}