Add signed R2 URLs, batched page fetching, and 3D chapter badges
- Sign all image URLs server-side with 60s expiry presigned URLs - Add /api/pages endpoint for batched page fetching (7 per batch) - PageReader prefetches next batch when user scrolls to 3rd page - Move chapter count badge outside overflow-hidden for 3D effect - Fix missing URL signing on search and genre pages - Extract signCoverUrls helper to reduce duplication - Clamp API limit param to prevent abuse Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7dc39738c
commit
57255e2624
@ -1,4 +1,5 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { signCoverUrls } from "@/lib/r2";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@ -8,7 +9,10 @@ export async function GET() {
|
|||||||
_count: { select: { chapters: true } },
|
_count: { select: { chapters: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return Response.json(manga);
|
|
||||||
|
const signedManga = await signCoverUrls(manga);
|
||||||
|
|
||||||
|
return Response.json(signedManga);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
30
app/api/pages/route.ts
Normal file
30
app/api/pages/route.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { signUrl } from "@/lib/r2";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const chapterId = parseInt(searchParams.get("chapterId") ?? "", 10);
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (isNaN(chapterId)) {
|
||||||
|
return Response.json({ error: "Missing chapterId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = await prisma.page.findMany({
|
||||||
|
where: { chapterId },
|
||||||
|
orderBy: { number: "asc" },
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
select: { number: true, imageUrl: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedPages = await Promise.all(
|
||||||
|
pages.map(async (p) => ({
|
||||||
|
number: p.number,
|
||||||
|
imageUrl: await signUrl(p.imageUrl),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response.json(signedPages);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { signCoverUrls } from "@/lib/r2";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
@ -23,5 +24,5 @@ export async function GET(request: Request) {
|
|||||||
orderBy: { title: "asc" },
|
orderBy: { title: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(results);
|
return Response.json(await signCoverUrls(results));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { signCoverUrls } from "@/lib/r2";
|
||||||
import { GenreTabs } from "@/components/GenreTabs";
|
import { GenreTabs } from "@/components/GenreTabs";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
@ -15,11 +16,12 @@ export default async function GenrePage() {
|
|||||||
include: { _count: { select: { chapters: true } } },
|
include: { _count: { select: { chapters: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const genres = [...new Set(manga.map((m) => m.genre))].sort();
|
const signedManga = await signCoverUrls(manga);
|
||||||
|
const genres = [...new Set(signedManga.map((m) => m.genre))].sort();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 py-5">
|
<div className="max-w-6xl mx-auto px-4 py-5">
|
||||||
<GenreTabs manga={manga} genres={genres} />
|
<GenreTabs manga={signedManga} genres={genres} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export default async function ChapterReaderPage({ params }: Props) {
|
|||||||
chapters: {
|
chapters: {
|
||||||
orderBy: { number: "asc" },
|
orderBy: { number: "asc" },
|
||||||
include: {
|
include: {
|
||||||
pages: { orderBy: { number: "asc" } },
|
_count: { select: { pages: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -60,7 +60,8 @@ export default async function ChapterReaderPage({ params }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageReader
|
<PageReader
|
||||||
pages={currentChapter.pages}
|
chapterId={currentChapter.id}
|
||||||
|
totalPages={currentChapter._count.pages}
|
||||||
mangaSlug={manga.slug}
|
mangaSlug={manga.slug}
|
||||||
mangaTitle={manga.title}
|
mangaTitle={manga.title}
|
||||||
chapterNumber={currentChapter.number}
|
chapterNumber={currentChapter.number}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { signUrl } from "@/lib/r2";
|
||||||
import { ChapterList } from "@/components/ChapterList";
|
import { ChapterList } from "@/components/ChapterList";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
@ -36,6 +37,8 @@ export default async function MangaDetailPage({ params }: Props) {
|
|||||||
|
|
||||||
if (!manga) notFound();
|
if (!manga) notFound();
|
||||||
|
|
||||||
|
const signedCoverUrl = await signUrl(manga.coverUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto px-4 py-6">
|
<div className="max-w-3xl mx-auto px-4 py-6">
|
||||||
{/* Hero section */}
|
{/* Hero section */}
|
||||||
@ -43,7 +46,7 @@ export default async function MangaDetailPage({ params }: Props) {
|
|||||||
<div className="w-28 sm:w-36 shrink-0">
|
<div className="w-28 sm:w-36 shrink-0">
|
||||||
<div className="aspect-[3/4] rounded-xl overflow-hidden bg-card">
|
<div className="aspect-[3/4] rounded-xl overflow-hidden bg-card">
|
||||||
<img
|
<img
|
||||||
src={manga.coverUrl}
|
src={signedCoverUrl}
|
||||||
alt={manga.title}
|
alt={manga.title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { signCoverUrls } from "@/lib/r2";
|
||||||
import { TrendingCarousel } from "@/components/TrendingCarousel";
|
import { TrendingCarousel } from "@/components/TrendingCarousel";
|
||||||
import { GenreTabs } from "@/components/GenreTabs";
|
import { GenreTabs } from "@/components/GenreTabs";
|
||||||
|
|
||||||
@ -11,11 +12,13 @@ export default async function Home() {
|
|||||||
include: { _count: { select: { chapters: true } } },
|
include: { _count: { select: { chapters: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const signedManga = await signCoverUrls(manga);
|
||||||
|
|
||||||
// Top 10 for trending
|
// Top 10 for trending
|
||||||
const trending = manga.slice(0, 10);
|
const trending = signedManga.slice(0, 10);
|
||||||
|
|
||||||
// Extract unique genres
|
// Extract unique genres
|
||||||
const genres = [...new Set(manga.map((m) => m.genre))].sort();
|
const genres = [...new Set(signedManga.map((m) => m.genre))].sort();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 py-5 space-y-8">
|
<div className="max-w-6xl mx-auto px-4 py-5 space-y-8">
|
||||||
@ -23,7 +26,7 @@ export default async function Home() {
|
|||||||
<TrendingCarousel manga={trending} />
|
<TrendingCarousel manga={trending} />
|
||||||
|
|
||||||
{/* Genre browsing section — horizontal tabs + filtered grid */}
|
{/* Genre browsing section — horizontal tabs + filtered grid */}
|
||||||
<GenreTabs manga={manga} genres={genres} />
|
<GenreTabs manga={signedManga} genres={genres} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { signCoverUrls } from "@/lib/r2";
|
||||||
import { MangaGrid } from "@/components/MangaGrid";
|
import { MangaGrid } from "@/components/MangaGrid";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
@ -24,13 +25,15 @@ export default async function SearchPage({ searchParams }: Props) {
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const signedManga = await signCoverUrls(manga);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||||
<h1 className="text-xl font-bold mb-4">
|
<h1 className="text-xl font-bold mb-4">
|
||||||
{q ? `Results for "${q}"` : "Search"}
|
{q ? `Results for "${q}"` : "Search"}
|
||||||
</h1>
|
</h1>
|
||||||
{q ? (
|
{q ? (
|
||||||
<MangaGrid manga={manga} />
|
<MangaGrid manga={signedManga} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted text-center py-12">
|
<p className="text-muted text-center py-12">
|
||||||
Use the search bar above to find manga
|
Use the search bar above to find manga
|
||||||
|
|||||||
@ -66,7 +66,12 @@ export function GenreTabs({
|
|||||||
{filtered.length > 0 ? (
|
{filtered.length > 0 ? (
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3 sm:gap-4">
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3 sm:gap-4">
|
||||||
{filtered.map((m) => (
|
{filtered.map((m) => (
|
||||||
<Link key={m.slug} href={`/manga/${m.slug}`} className="group block">
|
<Link key={m.slug} href={`/manga/${m.slug}`} className="group block relative">
|
||||||
|
{m._count && m._count.chapters > 0 && (
|
||||||
|
<span className="absolute -top-2 -right-2 z-10 min-w-[30px] h-[30px] flex items-center justify-center px-1.5 text-sm font-bold bg-accent text-white rounded-lg shadow-[2px_4px_12px_rgba(0,0,0,0.5),0_1px_3px_rgba(0,0,0,0.3)] border border-white/20">
|
||||||
|
{m._count.chapters}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="relative aspect-[3/4] rounded-xl overflow-hidden bg-card">
|
<div className="relative aspect-[3/4] rounded-xl overflow-hidden bg-card">
|
||||||
<img
|
<img
|
||||||
src={m.coverUrl}
|
src={m.coverUrl}
|
||||||
@ -75,11 +80,6 @@ export function GenreTabs({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
||||||
{m._count && m._count.chapters > 0 && (
|
|
||||||
<span className="absolute top-2 right-2 px-1.5 py-0.5 text-[10px] font-bold bg-accent/90 text-white rounded-md">
|
|
||||||
{m._count.chapters} ch
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-2.5">
|
<div className="absolute bottom-0 left-0 right-0 p-2.5">
|
||||||
<h3 className="text-[12px] sm:text-[13px] font-semibold text-white leading-tight line-clamp-2">
|
<h3 className="text-[12px] sm:text-[13px] font-semibold text-white leading-tight line-clamp-2">
|
||||||
{m.title}
|
{m.title}
|
||||||
|
|||||||
@ -14,7 +14,12 @@ export function MangaCard({
|
|||||||
chapterCount,
|
chapterCount,
|
||||||
}: MangaCardProps) {
|
}: MangaCardProps) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/manga/${slug}`} className="group block">
|
<Link href={`/manga/${slug}`} className="group block relative">
|
||||||
|
{chapterCount !== undefined && (
|
||||||
|
<span className="absolute -top-2 -right-2 z-10 min-w-[30px] h-[30px] flex items-center justify-center px-1.5 text-sm font-bold bg-accent text-white rounded-lg shadow-[2px_4px_12px_rgba(0,0,0,0.5),0_1px_3px_rgba(0,0,0,0.3)] border border-white/20">
|
||||||
|
{chapterCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="relative aspect-[3/4] rounded-xl overflow-hidden bg-card">
|
<div className="relative aspect-[3/4] rounded-xl overflow-hidden bg-card">
|
||||||
<img
|
<img
|
||||||
src={coverUrl}
|
src={coverUrl}
|
||||||
@ -23,11 +28,6 @@ export function MangaCard({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||||
{chapterCount !== undefined && (
|
|
||||||
<span className="absolute top-2 right-2 px-2 py-0.5 text-[10px] font-semibold bg-accent/90 text-white rounded-full">
|
|
||||||
{chapterCount} ch
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||||
<h3 className="text-sm font-semibold text-white leading-tight line-clamp-2">
|
<h3 className="text-sm font-semibold text-white leading-tight line-clamp-2">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
type PageData = {
|
type PageData = {
|
||||||
@ -14,7 +14,8 @@ type ChapterInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PageReaderProps = {
|
type PageReaderProps = {
|
||||||
pages: PageData[];
|
chapterId: number;
|
||||||
|
totalPages: number;
|
||||||
mangaSlug: string;
|
mangaSlug: string;
|
||||||
mangaTitle: string;
|
mangaTitle: string;
|
||||||
chapterNumber: number;
|
chapterNumber: number;
|
||||||
@ -24,8 +25,12 @@ type PageReaderProps = {
|
|||||||
chapters: ChapterInfo[];
|
chapters: ChapterInfo[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BATCH_SIZE = 7;
|
||||||
|
const PREFETCH_AT = 3;
|
||||||
|
|
||||||
export function PageReader({
|
export function PageReader({
|
||||||
pages,
|
chapterId,
|
||||||
|
totalPages,
|
||||||
mangaSlug,
|
mangaSlug,
|
||||||
mangaTitle,
|
mangaTitle,
|
||||||
chapterNumber,
|
chapterNumber,
|
||||||
@ -36,7 +41,91 @@ export function PageReader({
|
|||||||
}: PageReaderProps) {
|
}: PageReaderProps) {
|
||||||
const [showUI, setShowUI] = useState(true);
|
const [showUI, setShowUI] = useState(true);
|
||||||
const [showDrawer, setShowDrawer] = useState(false);
|
const [showDrawer, setShowDrawer] = useState(false);
|
||||||
|
const [pages, setPages] = useState<PageData[]>([]);
|
||||||
const lastScrollY = useRef(0);
|
const lastScrollY = useRef(0);
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const doneRef = useRef(false);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const triggerIndicesRef = useRef<Set<number>>(new Set());
|
||||||
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
|
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
|
const fetchBatch = useCallback(async () => {
|
||||||
|
if (loadingRef.current || doneRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/pages?chapterId=${chapterId}&offset=${offsetRef.current}&limit=${BATCH_SIZE}`
|
||||||
|
);
|
||||||
|
const batch: PageData[] = await res.json();
|
||||||
|
if (batch.length === 0) {
|
||||||
|
doneRef.current = true;
|
||||||
|
} else {
|
||||||
|
const triggerIndex = offsetRef.current + PREFETCH_AT - 1;
|
||||||
|
triggerIndicesRef.current.add(triggerIndex);
|
||||||
|
|
||||||
|
// If trigger element is already mounted, observe it now
|
||||||
|
const existing = pageRefsRef.current.get(triggerIndex);
|
||||||
|
if (existing && observerRef.current) {
|
||||||
|
observerRef.current.observe(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPages((prev) => [...prev, ...batch]);
|
||||||
|
offsetRef.current += batch.length;
|
||||||
|
if (offsetRef.current >= totalPages) {
|
||||||
|
doneRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry on next intersection
|
||||||
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [chapterId, totalPages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
observerRef.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const index = Number(
|
||||||
|
(entry.target as HTMLElement).dataset.pageIndex
|
||||||
|
);
|
||||||
|
if (triggerIndicesRef.current.has(index)) {
|
||||||
|
triggerIndicesRef.current.delete(index);
|
||||||
|
fetchBatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" }
|
||||||
|
);
|
||||||
|
return () => observerRef.current?.disconnect();
|
||||||
|
}, [fetchBatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBatch();
|
||||||
|
}, [fetchBatch]);
|
||||||
|
|
||||||
|
const setPageRef = useCallback(
|
||||||
|
(index: number, el: HTMLDivElement | null) => {
|
||||||
|
const observer = observerRef.current;
|
||||||
|
if (!observer) return;
|
||||||
|
|
||||||
|
const prev = pageRefsRef.current.get(index);
|
||||||
|
if (prev) observer.unobserve(prev);
|
||||||
|
|
||||||
|
if (el) {
|
||||||
|
pageRefsRef.current.set(index, el);
|
||||||
|
if (triggerIndicesRef.current.has(index)) {
|
||||||
|
observer.observe(el);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pageRefsRef.current.delete(index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
@ -89,16 +178,20 @@ export function PageReader({
|
|||||||
|
|
||||||
{/* Pages - vertical scroll (webtoon style, best for mobile) */}
|
{/* Pages - vertical scroll (webtoon style, best for mobile) */}
|
||||||
<div
|
<div
|
||||||
className="max-w-4xl mx-auto"
|
className="max-w-4xl mx-auto leading-[0]"
|
||||||
onClick={() => setShowUI(!showUI)}
|
onClick={() => setShowUI(!showUI)}
|
||||||
>
|
>
|
||||||
{pages.map((page) => (
|
{pages.map((page, i) => (
|
||||||
<div key={page.number} className="relative">
|
<div
|
||||||
|
key={page.number}
|
||||||
|
className="relative leading-[0]"
|
||||||
|
data-page-index={i}
|
||||||
|
ref={(el) => setPageRef(i, el)}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={page.imageUrl}
|
src={page.imageUrl}
|
||||||
alt={`Page ${page.number}`}
|
alt={`Page ${page.number}`}
|
||||||
className="w-full h-auto block"
|
className="w-full h-auto block align-bottom -mb-px"
|
||||||
loading={page.number <= 3 ? "eager" : "lazy"}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -134,18 +227,7 @@ export function PageReader({
|
|||||||
onClick={() => setShowDrawer(true)}
|
onClick={() => setShowDrawer(true)}
|
||||||
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
|
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
{chapterTitle}
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
className="w-5 h-5"
|
|
||||||
>
|
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
|
||||||
<line x1="3" y1="12" x2="21" y2="12" />
|
|
||||||
<line x1="3" y1="18" x2="21" y2="18" />
|
|
||||||
</svg>
|
|
||||||
Ch. {chapterNumber}
|
|
||||||
</button>
|
</button>
|
||||||
{nextChapter ? (
|
{nextChapter ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
32
lib/r2.ts
32
lib/r2.ts
@ -1,4 +1,8 @@
|
|||||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
import {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
@ -22,3 +26,29 @@ export async function getPresignedUploadUrl(key: string) {
|
|||||||
export function getPublicUrl(key: string) {
|
export function getPublicUrl(key: string) {
|
||||||
return `${process.env.R2_PUBLIC_URL}/${key}`;
|
return `${process.env.R2_PUBLIC_URL}/${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPresignedReadUrl(key: string) {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: process.env.R2_BUCKET!,
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
|
return getSignedUrl(s3, command, { expiresIn: 60 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signUrl(publicUrl: string) {
|
||||||
|
const prefix = process.env.R2_PUBLIC_URL!;
|
||||||
|
if (!publicUrl.startsWith(prefix)) return publicUrl;
|
||||||
|
const key = publicUrl.replace(prefix, "").replace(/^\//, "");
|
||||||
|
return getPresignedReadUrl(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signCoverUrls<T extends { coverUrl: string }>(
|
||||||
|
items: T[]
|
||||||
|
): Promise<T[]> {
|
||||||
|
return Promise.all(
|
||||||
|
items.map(async (item) => ({
|
||||||
|
...item,
|
||||||
|
coverUrl: await signUrl(item.coverUrl),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user