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>
This commit is contained in:
parent
f2ef775f70
commit
b993de43bc
@ -1,11 +1,15 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { decodeId } from "@/lib/hashids";
|
||||||
|
import { withGuards } from "@/lib/api-guards";
|
||||||
|
|
||||||
type Params = { params: Promise<{ chapterId: string }> };
|
type Ctx = { params: Promise<{ chapterId: string }> };
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: Params) {
|
export const GET = withGuards<Ctx>(
|
||||||
|
{ rateLimit: { key: "chapter-meta", limit: 60, windowMs: 60_000 } },
|
||||||
|
async (_request, { params }) => {
|
||||||
const { chapterId: raw } = await params;
|
const { chapterId: raw } = await params;
|
||||||
const chapterId = parseInt(raw, 10);
|
const chapterId = decodeId(raw);
|
||||||
if (isNaN(chapterId)) {
|
if (chapterId === null) {
|
||||||
return Response.json({ error: "Invalid chapterId" }, { status: 400 });
|
return Response.json({ error: "Invalid chapterId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,3 +21,4 @@ export async function GET(_request: Request, { params }: Params) {
|
|||||||
|
|
||||||
return Response.json(pages);
|
return Response.json(pages);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { signCoverUrls } from "@/lib/r2";
|
import { signCoverUrls } from "@/lib/r2";
|
||||||
import { NextRequest } from "next/server";
|
import { withGuards } from "@/lib/api-guards";
|
||||||
|
|
||||||
export async function GET() {
|
export const GET = withGuards(
|
||||||
|
{ rateLimit: { key: "manga-list", limit: 30, windowMs: 60_000 } },
|
||||||
|
async () => {
|
||||||
const manga = await prisma.manga.findMany({
|
const manga = await prisma.manga.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
@ -14,8 +16,9 @@ export async function GET() {
|
|||||||
|
|
||||||
return Response.json(signedManga);
|
return Response.json(signedManga);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export const POST = withGuards({}, async (request) => {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
const { title, description, coverUrl, slug, status } = body;
|
const { title, description, coverUrl, slug, status } = body;
|
||||||
@ -38,4 +41,4 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(manga, { status: 201 });
|
return Response.json(manga, { status: 201 });
|
||||||
}
|
});
|
||||||
|
|||||||
@ -1,14 +1,21 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { signUrl } from "@/lib/r2";
|
import { signUrl } from "@/lib/r2";
|
||||||
|
import { decodeId } from "@/lib/hashids";
|
||||||
|
import { withGuards } from "@/lib/api-guards";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export const GET = withGuards(
|
||||||
|
{ rateLimit: { key: "pages", limit: 300, windowMs: 60_000 } },
|
||||||
|
async (request) => {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const chapterId = parseInt(searchParams.get("chapterId") ?? "", 10);
|
const chapterId = decodeId(searchParams.get("chapter") ?? "");
|
||||||
const offset = Math.max(parseInt(searchParams.get("offset") ?? "0", 10), 0);
|
const offset = Math.max(parseInt(searchParams.get("offset") ?? "0", 10), 0);
|
||||||
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "7", 10), 1), 20);
|
const limit = Math.min(
|
||||||
|
Math.max(parseInt(searchParams.get("limit") ?? "7", 10), 1),
|
||||||
|
20
|
||||||
|
);
|
||||||
|
|
||||||
if (isNaN(chapterId)) {
|
if (chapterId === null) {
|
||||||
return Response.json({ error: "Missing chapterId" }, { status: 400 });
|
return Response.json({ error: "Missing chapter" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pages = await prisma.page.findMany({
|
const pages = await prisma.page.findMany({
|
||||||
@ -28,3 +35,4 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
return Response.json(signedPages);
|
return Response.json(signedPages);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { signCoverUrls } from "@/lib/r2";
|
import { signCoverUrls } from "@/lib/r2";
|
||||||
|
import { withGuards } from "@/lib/api-guards";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export const GET = withGuards(
|
||||||
|
{ rateLimit: { key: "search", limit: 30, windowMs: 60_000 } },
|
||||||
|
async (request) => {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const q = searchParams.get("q")?.trim();
|
const q = searchParams.get("q")?.trim();
|
||||||
|
|
||||||
@ -26,3 +29,4 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
return Response.json(await signCoverUrls(results));
|
return Response.json(await signCoverUrls(results));
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
|
||||||
import { getPresignedUploadUrl, getPublicUrl } from "@/lib/r2";
|
import { getPresignedUploadUrl, getPublicUrl } from "@/lib/r2";
|
||||||
|
import { withGuards } from "@/lib/api-guards";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export const POST = withGuards({}, async (request) => {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { key } = body;
|
const { key } = body;
|
||||||
|
|
||||||
@ -16,4 +16,4 @@ export async function POST(request: NextRequest) {
|
|||||||
const publicUrl = getPublicUrl(key);
|
const publicUrl = getPublicUrl(key);
|
||||||
|
|
||||||
return Response.json({ uploadUrl, publicUrl });
|
return Response.json({ uploadUrl, publicUrl });
|
||||||
}
|
});
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
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 type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ slug: string; chapter: string }>;
|
params: Promise<{ slug: string; chapter: string }>;
|
||||||
|
searchParams: Promise<{ resume?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string; chapter: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
const { slug, chapter } = await params;
|
const { slug, chapter } = await params;
|
||||||
const chapterNum = parseInt(chapter, 10);
|
const chapterNum = parseInt(chapter, 10);
|
||||||
if (isNaN(chapterNum)) return { title: "Not Found" };
|
if (isNaN(chapterNum)) return { title: "Not Found" };
|
||||||
@ -21,8 +27,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ChapterReaderPage({ params }: Props) {
|
export default async function ChapterReaderPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: Props) {
|
||||||
const { slug, chapter } = await params;
|
const { slug, chapter } = await params;
|
||||||
|
const { resume } = await searchParams;
|
||||||
const chapterNum = parseInt(chapter, 10);
|
const chapterNum = parseInt(chapter, 10);
|
||||||
if (isNaN(chapterNum)) notFound();
|
if (isNaN(chapterNum)) notFound();
|
||||||
|
|
||||||
@ -51,7 +61,7 @@ export default async function ChapterReaderPage({ params }: Props) {
|
|||||||
if (!currentChapter) notFound();
|
if (!currentChapter) notFound();
|
||||||
|
|
||||||
const allChapters = manga.chapters.map((c) => ({
|
const allChapters = manga.chapters.map((c) => ({
|
||||||
id: c.id,
|
id: encodeId(c.id),
|
||||||
number: c.number,
|
number: c.number,
|
||||||
title: c.title,
|
title: c.title,
|
||||||
totalPages: c._count.pages,
|
totalPages: c._count.pages,
|
||||||
@ -64,6 +74,7 @@ export default async function ChapterReaderPage({ params }: Props) {
|
|||||||
startChapterNumber={currentChapter.number}
|
startChapterNumber={currentChapter.number}
|
||||||
chapters={allChapters}
|
chapters={allChapters}
|
||||||
initialChapterMeta={initialChapterMeta}
|
initialChapterMeta={initialChapterMeta}
|
||||||
|
resume={resume === "1"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
27
app/robots.ts
Normal file
27
app/robots.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
const SITE_URL = "https://www.04080616.xyz";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: [
|
||||||
|
"Googlebot",
|
||||||
|
"Bingbot",
|
||||||
|
"Slurp",
|
||||||
|
"DuckDuckBot",
|
||||||
|
"Baiduspider",
|
||||||
|
"YandexBot",
|
||||||
|
],
|
||||||
|
allow: "/",
|
||||||
|
disallow: "/api/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userAgent: "*",
|
||||||
|
disallow: "/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
components/LoadingLogo.tsx
Normal file
40
components/LoadingLogo.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onTap?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoadingLogo({ onTap }: Props) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Reload page"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTap?.();
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 m-auto w-16 h-16 flex items-center justify-center cursor-pointer active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="w-full h-full animate-[spin_2s_linear_infinite] text-accent/40"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
>
|
||||||
|
<g transform="translate(50,50)">
|
||||||
|
<ellipse cx="0" cy="-22" rx="6.5" ry="13" />
|
||||||
|
<ellipse cx="0" cy="-22" rx="6.5" ry="13" transform="rotate(45)" />
|
||||||
|
<ellipse cx="0" cy="-22" rx="6.5" ry="13" transform="rotate(90)" />
|
||||||
|
<ellipse cx="0" cy="-22" rx="6.5" ry="13" transform="rotate(135)" />
|
||||||
|
<ellipse cx="0" cy="-22" rx="6.5" ry="13" transform="rotate(180)" />
|
||||||
|
<ellipse cx="0" cy="-22" rx="6.5" ry="13" transform="rotate(225)" />
|
||||||
|
<ellipse cx="0" cy="-22" rx="6.5" ry="13" transform="rotate(270)" />
|
||||||
|
<ellipse cx="0" cy="-22" rx="6.5" ry="13" transform="rotate(315)" />
|
||||||
|
<circle cx="0" cy="0" r="8" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,9 +15,14 @@ import {
|
|||||||
readProgress,
|
readProgress,
|
||||||
writeProgress,
|
writeProgress,
|
||||||
} from "@/components/ReadingProgressButton";
|
} from "@/components/ReadingProgressButton";
|
||||||
|
import { LoadingLogo } from "@/components/LoadingLogo";
|
||||||
|
import {
|
||||||
|
calcScrollRatio,
|
||||||
|
scrollOffsetFromRatio,
|
||||||
|
} from "@/lib/scroll-ratio";
|
||||||
|
|
||||||
type ChapterMeta = {
|
type ChapterMeta = {
|
||||||
id: number;
|
id: string;
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@ -31,11 +36,13 @@ type PageReaderProps = {
|
|||||||
startChapterNumber: number;
|
startChapterNumber: number;
|
||||||
chapters: ChapterMeta[];
|
chapters: ChapterMeta[];
|
||||||
initialChapterMeta: PageMeta[];
|
initialChapterMeta: PageMeta[];
|
||||||
|
resume: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PREFETCH_NEXT_AT = 3;
|
const PREFETCH_NEXT_AT = 3;
|
||||||
const IMAGE_BATCH_RADIUS = 3;
|
const IMAGE_BATCH_RADIUS = 3;
|
||||||
const DOUBLE_TAP_MS = 280;
|
const DOUBLE_TAP_MS = 280;
|
||||||
|
const KEEP_PREV_CHAPTER_PAGES = 5;
|
||||||
|
|
||||||
const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`;
|
const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`;
|
||||||
|
|
||||||
@ -51,6 +58,7 @@ export function PageReader({
|
|||||||
startChapterNumber,
|
startChapterNumber,
|
||||||
chapters,
|
chapters,
|
||||||
initialChapterMeta,
|
initialChapterMeta,
|
||||||
|
resume,
|
||||||
}: PageReaderProps) {
|
}: PageReaderProps) {
|
||||||
const [showUI, setShowUI] = useState(true);
|
const [showUI, setShowUI] = useState(true);
|
||||||
const [showDrawer, setShowDrawer] = useState(false);
|
const [showDrawer, setShowDrawer] = useState(false);
|
||||||
@ -58,16 +66,17 @@ export function PageReader({
|
|||||||
[startChapterNumber]: initialChapterMeta,
|
[startChapterNumber]: initialChapterMeta,
|
||||||
});
|
});
|
||||||
const [images, setImages] = useState<Record<string, string>>({});
|
const [images, setImages] = useState<Record<string, string>>({});
|
||||||
|
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
|
||||||
const [currentChapterNum, setCurrentChapterNum] =
|
const [currentChapterNum, setCurrentChapterNum] =
|
||||||
useState(startChapterNumber);
|
useState(startChapterNumber);
|
||||||
const [currentPageNum, setCurrentPageNum] = useState(() => {
|
const [currentPageNum, setCurrentPageNum] = useState(() => {
|
||||||
if (typeof window === "undefined") return 1;
|
if (typeof window === "undefined" || !resume) return 1;
|
||||||
const p = readProgress(mangaSlug);
|
const p = readProgress(mangaSlug);
|
||||||
if (p && p.chapter === startChapterNumber && p.page > 1) return p.page;
|
if (p && p.chapter === startChapterNumber && p.page > 1) return p.page;
|
||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
|
const currentRatioRef = useRef(0);
|
||||||
|
|
||||||
// Observer stays stable across state updates.
|
|
||||||
const imagesRef = useRef(images);
|
const imagesRef = useRef(images);
|
||||||
const chapterMetasRef = useRef(chapterMetas);
|
const chapterMetasRef = useRef(chapterMetas);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -79,14 +88,16 @@ export function PageReader({
|
|||||||
|
|
||||||
const metaInflightRef = useRef<Set<number>>(new Set());
|
const metaInflightRef = useRef<Set<number>>(new Set());
|
||||||
const imagesInflightRef = useRef<Set<string>>(new Set());
|
const imagesInflightRef = useRef<Set<string>>(new Set());
|
||||||
|
const forceInflightRef = useRef<Set<string>>(new Set());
|
||||||
|
const radiusAbortRef = useRef<Map<number, AbortController>>(new Map());
|
||||||
const pageElRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
const pageElRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
|
const viewportObserverRef = useRef<IntersectionObserver | null>(null);
|
||||||
const hiddenByScrollRef = useRef(false);
|
const hiddenByScrollRef = useRef(false);
|
||||||
const drawerScrollRef = useRef<HTMLDivElement | null>(null);
|
const drawerScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const drawerActiveRef = useRef<HTMLAnchorElement | null>(null);
|
const drawerActiveRef = useRef<HTMLAnchorElement | null>(null);
|
||||||
// Pages currently inside the observer's viewport margin. The scroll tick
|
|
||||||
// walks this small set instead of every loaded page.
|
|
||||||
const intersectingPagesRef = useRef<Map<string, IntersectingPage>>(new Map());
|
const intersectingPagesRef = useRef<Map<string, IntersectingPage>>(new Map());
|
||||||
|
const visibleKeysRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const loadedChapterNumbers = useMemo(() => {
|
const loadedChapterNumbers = useMemo(() => {
|
||||||
return Object.keys(chapterMetas)
|
return Object.keys(chapterMetas)
|
||||||
@ -118,13 +129,23 @@ export function PageReader({
|
|||||||
if (toFetch.length === 0) return;
|
if (toFetch.length === 0) return;
|
||||||
const minP = toFetch[0];
|
const minP = toFetch[0];
|
||||||
const maxP = toFetch[toFetch.length - 1];
|
const maxP = toFetch[toFetch.length - 1];
|
||||||
|
// One controller per live chapter — every batch for this chapter
|
||||||
|
// reuses the signal so chapter-unmount aborts them all in one shot.
|
||||||
|
let controller = radiusAbortRef.current.get(chapterNum);
|
||||||
|
if (!controller) {
|
||||||
|
controller = new AbortController();
|
||||||
|
radiusAbortRef.current.set(chapterNum, controller);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/pages?chapterId=${chapter.id}&offset=${minP - 1}&limit=${
|
`/api/pages?chapter=${chapter.id}&offset=${minP - 1}&limit=${
|
||||||
maxP - minP + 1
|
maxP - minP + 1
|
||||||
}`
|
}`,
|
||||||
|
{ signal: controller.signal }
|
||||||
);
|
);
|
||||||
|
if (!res.ok) return;
|
||||||
const batch: { number: number; imageUrl: string }[] = await res.json();
|
const batch: { number: number; imageUrl: string }[] = await res.json();
|
||||||
|
if (!Array.isArray(batch)) return;
|
||||||
setImages((prev) => {
|
setImages((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const item of batch) {
|
for (const item of batch) {
|
||||||
@ -133,13 +154,42 @@ export function PageReader({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// observer will re-trigger on next intersection
|
// aborted or failed — observer will re-trigger on next intersection
|
||||||
} finally {
|
} finally {
|
||||||
for (const p of toFetch)
|
for (const p of toFetch)
|
||||||
imagesInflightRef.current.delete(pageKey(chapterNum, p));
|
imagesInflightRef.current.delete(pageKey(chapterNum, p));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[chapters]
|
[chapterByNumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tracked separately from imagesInflightRef so rapid taps dedup against
|
||||||
|
// each other but don't block on a slow radius fetch already in flight.
|
||||||
|
const forceFetchPage = useCallback(
|
||||||
|
async (chapterNum: number, pageNum: number) => {
|
||||||
|
const chapter = chapterByNumber.get(chapterNum);
|
||||||
|
if (!chapter) return;
|
||||||
|
const key = pageKey(chapterNum, pageNum);
|
||||||
|
if (forceInflightRef.current.has(key)) return;
|
||||||
|
forceInflightRef.current.add(key);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/pages?chapter=${chapter.id}&offset=${pageNum - 1}&limit=1`
|
||||||
|
);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const batch: { number: number; imageUrl: string }[] = await res.json();
|
||||||
|
if (!Array.isArray(batch) || batch.length === 0) return;
|
||||||
|
setImages((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[pageKey(chapterNum, batch[0].number)]: batch[0].imageUrl,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// user can tap again
|
||||||
|
} finally {
|
||||||
|
forceInflightRef.current.delete(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[chapterByNumber]
|
||||||
);
|
);
|
||||||
|
|
||||||
const prefetchNextChapterMeta = useCallback(
|
const prefetchNextChapterMeta = useCallback(
|
||||||
@ -152,7 +202,9 @@ export function PageReader({
|
|||||||
metaInflightRef.current.add(next.number);
|
metaInflightRef.current.add(next.number);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/chapters/${next.id}/meta`);
|
const res = await fetch(`/api/chapters/${next.id}/meta`);
|
||||||
|
if (!res.ok) return;
|
||||||
const meta: PageMeta[] = await res.json();
|
const meta: PageMeta[] = await res.json();
|
||||||
|
if (!Array.isArray(meta)) return;
|
||||||
setChapterMetas((prev) => ({ ...prev, [next.number]: meta }));
|
setChapterMetas((prev) => ({ ...prev, [next.number]: meta }));
|
||||||
} catch {
|
} catch {
|
||||||
// will retry next observer fire
|
// will retry next observer fire
|
||||||
@ -186,43 +238,101 @@ export function PageReader({
|
|||||||
},
|
},
|
||||||
{ rootMargin: "1200px" }
|
{ rootMargin: "1200px" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
viewportObserverRef.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
let changed = false;
|
||||||
|
for (const e of entries) {
|
||||||
|
const el = e.target as HTMLDivElement;
|
||||||
|
const chNum = Number(el.dataset.chapter);
|
||||||
|
const pNum = Number(el.dataset.page);
|
||||||
|
if (!chNum || !pNum) continue;
|
||||||
|
const key = pageKey(chNum, pNum);
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
if (!visibleKeysRef.current.has(key)) {
|
||||||
|
visibleKeysRef.current.add(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!imagesRef.current[key] &&
|
||||||
|
!imagesInflightRef.current.has(key)
|
||||||
|
) {
|
||||||
|
forceFetchPage(chNum, pNum);
|
||||||
|
}
|
||||||
|
} else if (visibleKeysRef.current.delete(key)) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) setVisibleKeys(new Set(visibleKeysRef.current));
|
||||||
|
},
|
||||||
|
{ rootMargin: "0px" }
|
||||||
|
);
|
||||||
|
|
||||||
for (const el of pageElRef.current.values()) {
|
for (const el of pageElRef.current.values()) {
|
||||||
observerRef.current.observe(el);
|
observerRef.current.observe(el);
|
||||||
|
viewportObserverRef.current.observe(el);
|
||||||
}
|
}
|
||||||
return () => observerRef.current?.disconnect();
|
return () => {
|
||||||
}, [fetchImagesAround, prefetchNextChapterMeta, chapterByNumber]);
|
observerRef.current?.disconnect();
|
||||||
|
viewportObserverRef.current?.disconnect();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
fetchImagesAround,
|
||||||
|
forceFetchPage,
|
||||||
|
prefetchNextChapterMeta,
|
||||||
|
chapterByNumber,
|
||||||
|
]);
|
||||||
|
|
||||||
const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => {
|
const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => {
|
||||||
const observer = observerRef.current;
|
const observer = observerRef.current;
|
||||||
|
const viewportObserver = viewportObserverRef.current;
|
||||||
const prev = pageElRef.current.get(key);
|
const prev = pageElRef.current.get(key);
|
||||||
if (prev && observer) observer.unobserve(prev);
|
if (prev) {
|
||||||
|
observer?.unobserve(prev);
|
||||||
|
viewportObserver?.unobserve(prev);
|
||||||
|
}
|
||||||
if (el) {
|
if (el) {
|
||||||
pageElRef.current.set(key, el);
|
pageElRef.current.set(key, el);
|
||||||
if (observer) observer.observe(el);
|
observer?.observe(el);
|
||||||
|
viewportObserver?.observe(el);
|
||||||
} else {
|
} else {
|
||||||
pageElRef.current.delete(key);
|
pageElRef.current.delete(key);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sync scroll + rAF re-scroll: defends against browser scroll-restoration
|
// All reader Links use scroll={false} to preserve scroll during in-reader
|
||||||
// on hard reload (the sync pass handles soft nav where Link scroll={false}).
|
// nav (natural scroll between chapters updates URL without remount). On
|
||||||
|
// a fresh mount we must actively position the scroll: resume-to-saved
|
||||||
|
// if ?resume=1 AND the saved chapter matches; otherwise top.
|
||||||
const resumeDoneRef = useRef(false);
|
const resumeDoneRef = useRef(false);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (resumeDoneRef.current) return;
|
if (resumeDoneRef.current) return;
|
||||||
resumeDoneRef.current = true;
|
resumeDoneRef.current = true;
|
||||||
|
const instantTop = (top: number) =>
|
||||||
|
window.scrollTo({ top, behavior: "instant" as ScrollBehavior });
|
||||||
|
if (!resume) {
|
||||||
|
instantTop(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const p = readProgress(mangaSlug);
|
const p = readProgress(mangaSlug);
|
||||||
if (!p || p.chapter !== startChapterNumber || p.page <= 1) return;
|
if (!p || p.chapter !== startChapterNumber) {
|
||||||
|
instantTop(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p.page <= 1 && p.ratio <= 0) {
|
||||||
|
instantTop(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const scrollToResume = () => {
|
const scrollToResume = () => {
|
||||||
const el = pageElRef.current.get(pageKey(startChapterNumber, p.page));
|
const el = pageElRef.current.get(pageKey(startChapterNumber, p.page));
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
window.scrollTo({
|
instantTop(
|
||||||
top: el.offsetTop,
|
scrollOffsetFromRatio(el.offsetTop, el.offsetHeight, p.ratio)
|
||||||
behavior: "instant" as ScrollBehavior,
|
);
|
||||||
});
|
|
||||||
};
|
};
|
||||||
scrollToResume();
|
scrollToResume();
|
||||||
requestAnimationFrame(scrollToResume);
|
requestAnimationFrame(scrollToResume);
|
||||||
}, [mangaSlug, startChapterNumber]);
|
}, [mangaSlug, startChapterNumber, resume]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let rafId = 0;
|
let rafId = 0;
|
||||||
@ -233,22 +343,29 @@ export function PageReader({
|
|||||||
hiddenByScrollRef.current = true;
|
hiddenByScrollRef.current = true;
|
||||||
setShowUI(false);
|
setShowUI(false);
|
||||||
}
|
}
|
||||||
// Walk only the pages currently inside the 1200px viewport margin
|
// Pick the topmost page whose top edge is above y+80 (top edge of the
|
||||||
// (maintained by the observer) and pick the one with the greatest
|
// content below the sticky header); walking the small intersecting set.
|
||||||
// offsetTop still above y+80 — that's the topmost visible page.
|
let bestCh = 0;
|
||||||
let bestCh = currentChapterNum;
|
let bestPg = 0;
|
||||||
let bestPg = currentPageNum;
|
|
||||||
let bestTop = -1;
|
let bestTop = -1;
|
||||||
|
let bestEl: HTMLDivElement | null = null;
|
||||||
for (const { chNum, pNum, el } of intersectingPagesRef.current.values()) {
|
for (const { chNum, pNum, el } of intersectingPagesRef.current.values()) {
|
||||||
const top = el.offsetTop;
|
const top = el.offsetTop;
|
||||||
if (top <= y + 80 && top > bestTop) {
|
if (top <= y + 80 && top > bestTop) {
|
||||||
bestTop = top;
|
bestTop = top;
|
||||||
bestCh = chNum;
|
bestCh = chNum;
|
||||||
bestPg = pNum;
|
bestPg = pNum;
|
||||||
|
bestEl = el;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bestCh !== currentChapterNum) setCurrentChapterNum(bestCh);
|
if (!bestEl) return;
|
||||||
if (bestPg !== currentPageNum) setCurrentPageNum(bestPg);
|
currentRatioRef.current = calcScrollRatio(
|
||||||
|
y,
|
||||||
|
bestTop,
|
||||||
|
bestEl.offsetHeight
|
||||||
|
);
|
||||||
|
setCurrentChapterNum(bestCh);
|
||||||
|
setCurrentPageNum(bestPg);
|
||||||
};
|
};
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (rafId) return;
|
if (rafId) return;
|
||||||
@ -259,17 +376,44 @@ export function PageReader({
|
|||||||
window.removeEventListener("scroll", onScroll);
|
window.removeEventListener("scroll", onScroll);
|
||||||
if (rafId) cancelAnimationFrame(rafId);
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
};
|
};
|
||||||
}, [currentChapterNum, currentPageNum]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeProgress(mangaSlug, {
|
writeProgress(mangaSlug, {
|
||||||
chapter: currentChapterNum,
|
chapter: currentChapterNum,
|
||||||
page: currentPageNum,
|
page: currentPageNum,
|
||||||
|
ratio: currentRatioRef.current,
|
||||||
});
|
});
|
||||||
}, [mangaSlug, currentChapterNum, currentPageNum]);
|
}, [mangaSlug, currentChapterNum, currentPageNum]);
|
||||||
|
|
||||||
// Keep URL in sync with the chapter currently in the viewport so browser
|
// Aspect-ratio placeholders stay so layout is preserved; observer
|
||||||
// back / reload returns to the latest chapter, not the one first opened.
|
// re-fetches images on scrollback into an unmounted chapter.
|
||||||
|
useEffect(() => {
|
||||||
|
const keep = new Set<number>([currentChapterNum]);
|
||||||
|
if (currentPageNum <= KEEP_PREV_CHAPTER_PAGES) {
|
||||||
|
keep.add(currentChapterNum - 1);
|
||||||
|
}
|
||||||
|
for (const [ch, ctrl] of radiusAbortRef.current) {
|
||||||
|
if (!keep.has(ch)) {
|
||||||
|
ctrl.abort();
|
||||||
|
radiusAbortRef.current.delete(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setImages((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(prev)) {
|
||||||
|
const ch = Number(k.split("-")[0]);
|
||||||
|
if (keep.has(ch)) {
|
||||||
|
next[k] = v;
|
||||||
|
} else {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [currentChapterNum, currentPageNum]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = `/manga/${mangaSlug}/${currentChapterNum}`;
|
const url = `/manga/${mangaSlug}/${currentChapterNum}`;
|
||||||
if (window.location.pathname === url) return;
|
if (window.location.pathname === url) return;
|
||||||
@ -440,6 +584,7 @@ export function PageReader({
|
|||||||
{meta.map((p) => {
|
{meta.map((p) => {
|
||||||
const key = pageKey(chNum, p.number);
|
const key = pageKey(chNum, p.number);
|
||||||
const url = images[key];
|
const url = images[key];
|
||||||
|
const isVisible = visibleKeys.has(key);
|
||||||
const aspect =
|
const aspect =
|
||||||
p.width > 0 && p.height > 0
|
p.width > 0 && p.height > 0
|
||||||
? `${p.width} / ${p.height}`
|
? `${p.width} / ${p.height}`
|
||||||
@ -453,13 +598,18 @@ export function PageReader({
|
|||||||
className="relative leading-[0] w-full"
|
className="relative leading-[0] w-full"
|
||||||
style={{ aspectRatio: aspect }}
|
style={{ aspectRatio: aspect }}
|
||||||
>
|
>
|
||||||
{url && (
|
{url ? (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={`Page ${p.number}`}
|
alt={`Page ${p.number}`}
|
||||||
|
fetchPriority={isVisible ? "high" : "low"}
|
||||||
className="w-full h-auto block [-webkit-touch-callout:none]"
|
className="w-full h-auto block [-webkit-touch-callout:none]"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<LoadingLogo
|
||||||
|
onTap={() => forceFetchPage(chNum, p.number)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { readProgress, type ReadingProgress } from "@/lib/progress";
|
||||||
|
|
||||||
type ChapterLite = {
|
type ChapterLite = {
|
||||||
number: number;
|
number: number;
|
||||||
@ -13,45 +14,8 @@ type Props = {
|
|||||||
chapters: ChapterLite[];
|
chapters: ChapterLite[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReadingProgress = {
|
export { readProgress, writeProgress } from "@/lib/progress";
|
||||||
chapter: number;
|
export type { ReadingProgress } from "@/lib/progress";
|
||||||
page: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function storageKey(slug: string) {
|
|
||||||
return `sunnymh:last-read:${slug}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readProgress(slug: string): ReadingProgress | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
const raw = window.localStorage.getItem(storageKey(slug));
|
|
||||||
if (!raw) return null;
|
|
||||||
// New format: JSON { chapter, page }
|
|
||||||
if (raw.startsWith("{")) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as ReadingProgress;
|
|
||||||
if (
|
|
||||||
typeof parsed.chapter === "number" &&
|
|
||||||
typeof parsed.page === "number" &&
|
|
||||||
parsed.chapter > 0 &&
|
|
||||||
parsed.page > 0
|
|
||||||
) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Legacy format: bare chapter number
|
|
||||||
const n = Number(raw);
|
|
||||||
return Number.isFinite(n) && n > 0 ? { chapter: n, page: 1 } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeProgress(slug: string, progress: ReadingProgress) {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
window.localStorage.setItem(storageKey(slug), JSON.stringify(progress));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
|
export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
|
||||||
const [progress, setProgress] = useState<ReadingProgress | null>(null);
|
const [progress, setProgress] = useState<ReadingProgress | null>(null);
|
||||||
@ -67,10 +31,13 @@ export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
|
|||||||
? chapters.find((c) => c.number === progress.chapter)
|
? chapters.find((c) => c.number === progress.chapter)
|
||||||
: null;
|
: null;
|
||||||
const target = resumeChapter ?? first;
|
const target = resumeChapter ?? first;
|
||||||
|
const href = resumeChapter
|
||||||
|
? `/manga/${mangaSlug}/${target.number}?resume=1`
|
||||||
|
: `/manga/${mangaSlug}/${target.number}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/manga/${mangaSlug}/${target.number}`}
|
href={href}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
className="flex items-center justify-center gap-3 w-full py-3 mb-6 px-4 text-sm font-semibold bg-accent hover:bg-accent-hover text-white rounded-xl transition-colors active:scale-[0.98]"
|
className="flex items-center justify-center gap-3 w-full py-3 mb-6 px-4 text-sm font-semibold bg-accent hover:bg-accent-hover text-white rounded-xl transition-colors active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -11,4 +11,6 @@ services:
|
|||||||
R2_SECRET_KEY: ${R2_SECRET_KEY}
|
R2_SECRET_KEY: ${R2_SECRET_KEY}
|
||||||
R2_BUCKET: ${R2_BUCKET}
|
R2_BUCKET: ${R2_BUCKET}
|
||||||
R2_PUBLIC_URL: ${R2_PUBLIC_URL}
|
R2_PUBLIC_URL: ${R2_PUBLIC_URL}
|
||||||
|
HASHIDS_SALT: ${HASHIDS_SALT}
|
||||||
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
32
lib/api-guards.ts
Normal file
32
lib/api-guards.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { checkOrigin } from "@/lib/origin-check";
|
||||||
|
import { checkRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
type RateLimitOpts = {
|
||||||
|
key: string;
|
||||||
|
limit: number;
|
||||||
|
windowMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GuardOpts = {
|
||||||
|
origin?: boolean;
|
||||||
|
rateLimit?: RateLimitOpts;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Handler<TCtx> = (request: Request, ctx: TCtx) => Promise<Response>;
|
||||||
|
|
||||||
|
export function withGuards<TCtx>(
|
||||||
|
opts: GuardOpts,
|
||||||
|
handler: Handler<TCtx>
|
||||||
|
): Handler<TCtx> {
|
||||||
|
return async (request, ctx) => {
|
||||||
|
if (opts.origin !== false) {
|
||||||
|
const blocked = checkOrigin(request);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
}
|
||||||
|
if (opts.rateLimit) {
|
||||||
|
const blocked = checkRateLimit(request, opts.rateLimit);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
}
|
||||||
|
return handler(request, ctx);
|
||||||
|
};
|
||||||
|
}
|
||||||
15
lib/hashids.ts
Normal file
15
lib/hashids.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import Hashids from "hashids";
|
||||||
|
|
||||||
|
const salt = process.env.HASHIDS_SALT ?? "";
|
||||||
|
const hashids = new Hashids(salt, 8);
|
||||||
|
|
||||||
|
export function encodeId(n: number): string {
|
||||||
|
return hashids.encode(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeId(s: string): number | null {
|
||||||
|
const decoded = hashids.decode(s);
|
||||||
|
if (decoded.length !== 1) return null;
|
||||||
|
const n = Number(decoded[0]);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
37
lib/origin-check.ts
Normal file
37
lib/origin-check.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
const DEFAULT_ORIGINS = [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://localhost:3001",
|
||||||
|
"http://10.8.0.2:3000",
|
||||||
|
];
|
||||||
|
|
||||||
|
function allowedOrigins(): string[] {
|
||||||
|
const env = process.env.ALLOWED_ORIGINS;
|
||||||
|
if (!env) return DEFAULT_ORIGINS;
|
||||||
|
return env
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkOrigin(request: Request): Response | null {
|
||||||
|
const allowed = allowedOrigins();
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
if (origin) {
|
||||||
|
return allowed.includes(origin)
|
||||||
|
? null
|
||||||
|
: Response.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const referer = request.headers.get("referer");
|
||||||
|
if (referer) {
|
||||||
|
try {
|
||||||
|
const url = new URL(referer);
|
||||||
|
const base = `${url.protocol}//${url.host}`;
|
||||||
|
return allowed.includes(base)
|
||||||
|
? null
|
||||||
|
: Response.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
68
lib/progress.ts
Normal file
68
lib/progress.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
60
lib/rate-limit.ts
Normal file
60
lib/rate-limit.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
export type Bucket = { count: number; resetAt: number };
|
||||||
|
|
||||||
|
export type RateLimitStore = Pick<
|
||||||
|
Map<string, Bucket>,
|
||||||
|
"get" | "set" | "delete" | "size"
|
||||||
|
> & {
|
||||||
|
[Symbol.iterator](): IterableIterator<[string, Bucket]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RateLimitDeps = {
|
||||||
|
now?: () => number;
|
||||||
|
store?: RateLimitStore;
|
||||||
|
ipOf?: (request: Request) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultStore: RateLimitStore = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
function defaultIpOf(request: Request): string {
|
||||||
|
const fwd = request.headers.get("x-forwarded-for");
|
||||||
|
if (fwd) return fwd.split(",")[0].trim();
|
||||||
|
const real = request.headers.get("x-real-ip");
|
||||||
|
if (real) return real.trim();
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkRateLimit(
|
||||||
|
request: Request,
|
||||||
|
opts: { key: string; limit: number; windowMs: number },
|
||||||
|
deps: RateLimitDeps = {}
|
||||||
|
): Response | null {
|
||||||
|
const now = (deps.now ?? Date.now)();
|
||||||
|
const store = deps.store ?? defaultStore;
|
||||||
|
const ip = (deps.ipOf ?? defaultIpOf)(request);
|
||||||
|
const bucketKey = `${opts.key}:${ip}`;
|
||||||
|
const bucket = store.get(bucketKey);
|
||||||
|
|
||||||
|
if (!bucket || now >= bucket.resetAt) {
|
||||||
|
store.set(bucketKey, { count: 1, resetAt: now + opts.windowMs });
|
||||||
|
if (store.size > 10000) {
|
||||||
|
for (const [k, b] of store) {
|
||||||
|
if (b.resetAt <= now) store.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bucket.count >= opts.limit) {
|
||||||
|
const retryAfter = Math.ceil((bucket.resetAt - now) / 1000);
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Too many requests" },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { "Retry-After": String(retryAfter) },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.count += 1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
19
lib/scroll-ratio.ts
Normal file
19
lib/scroll-ratio.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export function calcScrollRatio(
|
||||||
|
scrollY: number,
|
||||||
|
elementTop: number,
|
||||||
|
elementHeight: number
|
||||||
|
): number {
|
||||||
|
const h = elementHeight || 1;
|
||||||
|
const raw = (scrollY - elementTop) / h;
|
||||||
|
if (raw < 0) return 0;
|
||||||
|
if (raw > 1) return 1;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scrollOffsetFromRatio(
|
||||||
|
elementTop: number,
|
||||||
|
elementHeight: number,
|
||||||
|
ratio: number
|
||||||
|
): number {
|
||||||
|
return elementTop + elementHeight * ratio;
|
||||||
|
}
|
||||||
7
package-lock.json
generated
7
package-lock.json
generated
@ -11,6 +11,7 @@
|
|||||||
"@aws-sdk/client-s3": "^3.1015.0",
|
"@aws-sdk/client-s3": "^3.1015.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"hashids": "^2.3.0",
|
||||||
"image-size": "^2.0.2",
|
"image-size": "^2.0.2",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
@ -6316,6 +6317,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hashids": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hashids/-/hashids-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-ljM73TE/avEhNnazxaj0Dw3BbEUuLC5yYCQ9RSkSUcT4ZSU6ZebdKCIBJ+xT/DnSYW36E9k82GH1Q6MydSIosQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
"@aws-sdk/client-s3": "^3.1015.0",
|
"@aws-sdk/client-s3": "^3.1015.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"hashids": "^2.3.0",
|
||||||
"image-size": "^2.0.2",
|
"image-size": "^2.0.2",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user