- 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>
69 lines
1.6 KiB
TypeScript
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;
|
|
}
|