init
This commit is contained in:
commit
9b45c4e3e8
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/app/generated/prisma
|
||||
chrome-profile
|
||||
18
.vscode/tasks.json
vendored
Normal file
18
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "tsc-check",
|
||||
"type": "shell",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"-e",
|
||||
"require('typescript').transpile('const x: number = 1;')"
|
||||
],
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"group": "build"
|
||||
}
|
||||
]
|
||||
}
|
||||
36
README.md
Normal file
36
README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
64
app/api/feed/route.ts
Normal file
64
app/api/feed/route.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
||||
|
||||
// Contract
|
||||
// Inputs: search params { before?: ISOString, limit?: number }
|
||||
// Output: { items: FeedItem[], nextCursor: ISOString | null }
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const limitParam = searchParams.get('limit');
|
||||
const beforeParam = searchParams.get('before');
|
||||
|
||||
const limit = Math.min(Math.max(Number(limitParam ?? '24'), 1), 60); // 1..60
|
||||
const before = beforeParam ? new Date(beforeParam) : null;
|
||||
|
||||
// fetch chunk from both tables
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: limit,
|
||||
include: { author: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: limit,
|
||||
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const merged: FeedItem[] = [
|
||||
...videos.map((v) => ({
|
||||
type: 'video' as const,
|
||||
aweme_id: v.aweme_id,
|
||||
created_at: v.created_at,
|
||||
desc: v.desc,
|
||||
video_url: v.video_url,
|
||||
cover_url: v.cover_url ?? null,
|
||||
width: v.width ?? null,
|
||||
height: v.height ?? null,
|
||||
author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null },
|
||||
likes: Number(v.digg_count),
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: 'image' as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: p.images?.[0]?.url ?? null,
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: { nickname: p.author.nickname, avatar_url: p.author.avatar_url ?? null },
|
||||
likes: Number(p.digg_count),
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => +new Date(b.created_at as any) - +new Date(a.created_at as any))
|
||||
.slice(0, limit);
|
||||
|
||||
const nextCursor = merged.length > 0 ? new Date(merged[merged.length - 1].created_at as any).toISOString() : null;
|
||||
const payload: FeedResponse = { items: merged, nextCursor };
|
||||
return NextResponse.json(payload);
|
||||
}
|
||||
602
app/aweme/[awemeId]/Client.tsx
Normal file
602
app/aweme/[awemeId]/Client.tsx
Normal file
@ -0,0 +1,602 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pause,
|
||||
Play,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Maximize,
|
||||
Minimize2,
|
||||
MessageSquare,
|
||||
ThumbsUp,
|
||||
MessageSquareText,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
} from "lucide-react";
|
||||
|
||||
type User = { nickname: string; avatar_url: string | null };
|
||||
type Comment = { cid: string; text: string; digg_count: number; created_at: string | Date; user: User };
|
||||
|
||||
type VideoData = {
|
||||
type: "video";
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
created_at: string | Date;
|
||||
duration_ms?: number | null;
|
||||
video_url: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
author: User;
|
||||
comments: Comment[];
|
||||
};
|
||||
|
||||
type ImageData = {
|
||||
type: "image";
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
created_at: string | Date;
|
||||
images: { id: string; url: string; width?: number; height?: number }[];
|
||||
music_url?: string | null;
|
||||
author: User;
|
||||
comments: Comment[];
|
||||
};
|
||||
|
||||
const SEGMENT_MS = 5000; // 图文每段 5s
|
||||
|
||||
export default function AwemeDetailClient(props: { data: VideoData | ImageData }) {
|
||||
const { data } = props;
|
||||
const isVideo = data.type === "video";
|
||||
|
||||
// ====== 布局 & 评论 ======
|
||||
const [open, setOpen] = useState(false); // 评论是否展开(竖屏为 bottom sheet;横屏为并排分栏)
|
||||
const comments = useMemo(() => data.comments ?? [], [data]);
|
||||
|
||||
// ====== 媒体引用 ======
|
||||
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// ====== 统一控制状态 ======
|
||||
const [isPlaying, setIsPlaying] = useState(true); // 视频=播放;图文=是否自动切换
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [volume, setVolume] = useState(1); // 视频音量 / 图文BGM音量
|
||||
const [rate, setRate] = useState(1); // 仅视频使用
|
||||
const [progress, setProgress] = useState(0); // 0..1 总进度
|
||||
const [rotation, setRotation] = useState(0); // 视频旋转角度:0/90/180/270
|
||||
|
||||
// ====== 图文专用(分段) ======
|
||||
const images = (data as any).images as ImageData["images"] | undefined;
|
||||
const totalSegments = images?.length ?? 0;
|
||||
const [idx, setIdx] = useState(0); // 当前图片索引
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 用 ref 解决“闪回”
|
||||
const segStartRef = useRef<number | null>(null); // 当前段开始时间戳
|
||||
const idxRef = useRef<number>(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const [segProgress, setSegProgress] = useState(0); // 段内 0..1
|
||||
|
||||
useEffect(() => { idxRef.current = idx; }, [idx]);
|
||||
|
||||
// ====== 视频:进度/播放/倍速/音量 ======
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onTime = () => {
|
||||
if (!v.duration || Number.isNaN(v.duration)) return;
|
||||
setProgress(v.currentTime / v.duration);
|
||||
};
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
|
||||
v.addEventListener("timeupdate", onTime);
|
||||
v.addEventListener("loadedmetadata", onTime);
|
||||
v.addEventListener("play", onPlay);
|
||||
v.addEventListener("pause", onPause);
|
||||
return () => {
|
||||
v.removeEventListener("timeupdate", onTime);
|
||||
v.removeEventListener("loadedmetadata", onTime);
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
};
|
||||
}, [isVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.volume = volume;
|
||||
}, [volume, isVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.playbackRate = rate;
|
||||
}, [rate, isVideo]);
|
||||
|
||||
// ====== 图文:BGM & 初次自动播放尝试 ======
|
||||
useEffect(() => {
|
||||
if (isVideo) return;
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.volume = volume;
|
||||
if (isPlaying) {
|
||||
el.play().catch(() => {/* 被策略阻止无妨,用户点播放即可 */ });
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
}, [isVideo]); // 初次挂载
|
||||
|
||||
useEffect(() => {
|
||||
if (isVideo) return;
|
||||
const el = audioRef.current;
|
||||
if (el) el.volume = volume;
|
||||
}, [volume, isVideo]);
|
||||
|
||||
// ====== 全屏状态监听 ======
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", onFsChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
||||
}, []);
|
||||
|
||||
// ====== 图文:自动切页(消除“闪回”)======
|
||||
useEffect(() => {
|
||||
if (isVideo || !images?.length) return;
|
||||
|
||||
if (segStartRef.current == null) segStartRef.current = performance.now();
|
||||
|
||||
const tick = (ts: number) => {
|
||||
if (!images?.length) return;
|
||||
|
||||
let start = segStartRef.current!;
|
||||
let localIdx = idxRef.current;
|
||||
|
||||
// 暂停时只更新 UI,不推进时间
|
||||
if (!isPlaying) {
|
||||
const elapsed = Math.max(0, ts - start);
|
||||
const localSeg = Math.min(1, elapsed / SEGMENT_MS);
|
||||
setSegProgress(localSeg);
|
||||
setProgress((localIdx + localSeg) / images.length);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
// 前进时间:处理跨多段情况(极少见,但更稳妥)
|
||||
let elapsed = ts - start;
|
||||
while (elapsed >= SEGMENT_MS) {
|
||||
elapsed -= SEGMENT_MS;
|
||||
localIdx = (localIdx + 1) % images.length;
|
||||
}
|
||||
segStartRef.current = ts - elapsed;
|
||||
|
||||
if (localIdx !== idxRef.current) {
|
||||
idxRef.current = localIdx;
|
||||
setIdx(localIdx);
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const localSeg = Math.max(0, Math.min(1, elapsed / SEGMENT_MS));
|
||||
setSegProgress(localSeg);
|
||||
setProgress((localIdx + localSeg) / images.length);
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
};
|
||||
}, [isVideo, images?.length, isPlaying]);
|
||||
|
||||
// 横向滚动同步 idx(且重置段起点)
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
const i = Math.round(el.scrollLeft / el.clientWidth);
|
||||
if (i !== idxRef.current) {
|
||||
idxRef.current = i;
|
||||
setIdx(i);
|
||||
segStartRef.current = performance.now();
|
||||
setSegProgress(0);
|
||||
setProgress(images && images.length ? i / images.length : 0);
|
||||
}
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [images?.length]);
|
||||
|
||||
// ====== 统一操作 ======
|
||||
const seekTo = (ratio: number) => {
|
||||
ratio = Math.min(1, Math.max(0, ratio));
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = v.duration * ratio;
|
||||
return;
|
||||
}
|
||||
if (!images?.length) return;
|
||||
const total = images.length;
|
||||
const exact = ratio * total;
|
||||
const targetIdx = Math.min(total - 1, Math.floor(exact));
|
||||
const remainder = exact - targetIdx;
|
||||
|
||||
idxRef.current = targetIdx;
|
||||
setIdx(targetIdx);
|
||||
segStartRef.current = performance.now() - remainder * SEGMENT_MS;
|
||||
setSegProgress(remainder);
|
||||
setProgress((targetIdx + remainder) / total);
|
||||
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: targetIdx * el.clientWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const togglePlay = async () => {
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) await v.play();
|
||||
else v.pause();
|
||||
return;
|
||||
}
|
||||
const el = audioRef.current;
|
||||
if (!isPlaying) {
|
||||
setIsPlaying(true);
|
||||
try { await el?.play(); } catch { }
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
el?.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = mediaContainerRef.current;
|
||||
if (!el) return;
|
||||
if (!document.fullscreenElement) {
|
||||
el.requestFullscreen().catch(() => { });
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => { });
|
||||
}
|
||||
};
|
||||
|
||||
const prevImg = () => {
|
||||
if (!images?.length) return;
|
||||
const next = Math.max(0, idxRef.current - 1);
|
||||
idxRef.current = next;
|
||||
setIdx(next);
|
||||
segStartRef.current = performance.now();
|
||||
setSegProgress(0);
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const nextImg = () => {
|
||||
if (!images?.length) return;
|
||||
const next = Math.min(images.length - 1, idxRef.current + 1);
|
||||
idxRef.current = next;
|
||||
setIdx(next);
|
||||
segStartRef.current = performance.now();
|
||||
setSegProgress(0);
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// ====== 侧栏(横屏)/ 抽屉(竖屏)样式(Tailwind)
|
||||
const asideClasses = [
|
||||
"z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white",
|
||||
// 竖屏:bottom sheet,从下向上弹出
|
||||
"portrait:fixed portrait:inset-x-0 portrait:bottom-0 portrait:w-full portrait:h-[min(80vh,88dvh)]",
|
||||
"portrait:transition-transform portrait:duration-200 portrait:ease-out",
|
||||
open ? "portrait:translate-y-0" : "portrait:translate-y-full",
|
||||
"portrait:border-t portrait:border-white/10",
|
||||
// 横屏:并排分栏,宽度过渡
|
||||
"landscape:relative landscape:h-full landscape:overflow-hidden",
|
||||
"landscape:transition-[width] landscape:duration-200 landscape:ease-out",
|
||||
open
|
||||
? "landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10"
|
||||
: "landscape:w-0",
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full">
|
||||
{/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
|
||||
<div className="relative h-full landscape:flex landscape:flex-row">
|
||||
{/* 主媒体区域 */}
|
||||
<section ref={mediaContainerRef} className="relative h-screen landscape:flex-1">
|
||||
<div
|
||||
className="relative h-screen overflow-hidden"
|
||||
>
|
||||
{isVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={(data as VideoData).video_url}
|
||||
className={[
|
||||
// 旋转 0/180:充满容器盒子,用 object-contain;
|
||||
// 旋转 90/270:用中心定位 + 100vh/100vw + object-cover,保证铺满全屏
|
||||
rotation % 180 === 0
|
||||
? "absolute inset-0 h-full w-full object-contain bg-black/70"
|
||||
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] object-contain bg-black/70",
|
||||
].join(" ")}
|
||||
style={{
|
||||
transform:
|
||||
rotation % 180 === 0
|
||||
? `rotate(${rotation}deg)`
|
||||
: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
playsInline
|
||||
autoPlay
|
||||
loop
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0">
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="absolute inset-0 overflow-x-auto overflow-y-hidden snap-x snap-mandatory flex no-scrollbar"
|
||||
>
|
||||
{(data as ImageData).images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="snap-center shrink-0 w-full h-screen relative bg-black/70"
|
||||
style={{ aspectRatio: img.width && img.height ? `${img.width}/${img.height}` : undefined }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={img.url} alt="image" className="absolute inset-0 w-full h-full object-contain" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 左右切图(多张时显示) */}
|
||||
{images && images.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
className="absolute top-1/2 -translate-y-1/2 left-3 w-[42px] h-[42px] grid place-items-center rounded-full bg-black/15 text-white border border-white/20 drop-shadow-lg cursor-pointer"
|
||||
onClick={prevImg}
|
||||
aria-label="上一张"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</button>
|
||||
<button
|
||||
className="absolute top-1/2 -translate-y-1/2 right-3 w-[42px] h-[42px] grid place-items-center rounded-full bg-black/15 text-white border border-white/20 drop-shadow-lg cursor-pointer"
|
||||
onClick={nextImg}
|
||||
aria-label="下一张"
|
||||
>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统一控制条:desc 在上、进度在下 */}
|
||||
<div className="absolute left-0 right-0 bottom-0 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
|
||||
{/* 描述行 */}
|
||||
|
||||
<div className="pointer-events-none flex items-center gap-2.5 mb-1">
|
||||
<img src={data.author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
|
||||
<span className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
|
||||
{data.author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.desc ? (
|
||||
<div className="pointer-events-none">
|
||||
<p className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
|
||||
{data.desc}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 进度条:图文=分段;视频=单段 */}
|
||||
{!isVideo && totalSegments > 0 ? (
|
||||
<div
|
||||
className="relative h-1.5 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
seekTo((e.clientX - rect.left) / rect.width);
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1.5 h-full">
|
||||
{Array.from({ length: totalSegments }).map((_, i) => {
|
||||
let fill = 0;
|
||||
if (i < idx) fill = 1;
|
||||
else if (i === idx) fill = segProgress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
aria-label={`第 ${i + 1} 段`}
|
||||
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="h-full origin-left bg-white"
|
||||
style={{ transform: `scaleX(${fill})` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative h-1 rounded-full bg-white/25 overflow-hidden cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
seekTo((e.clientX - rect.left) / rect.width);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="origin-left h-full bg-white"
|
||||
style={{ transform: `scaleX(${progress || 0})` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制按钮行 */}
|
||||
<div className="flex items-center justify-between gap-2.5">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||
</button>
|
||||
|
||||
{/* 倍速仅视频展示 */}
|
||||
{isVideo ? (
|
||||
<>
|
||||
<button
|
||||
className="h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={() => {
|
||||
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
|
||||
const i = steps.indexOf(rate);
|
||||
const next = steps[(i + 1) % steps.length];
|
||||
setRate(next);
|
||||
}}
|
||||
aria-label="切换倍速"
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
|
||||
{/* 旋转:向左/向右各 90° */}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={() => setRotation((r) => (r + 270) % 360)}
|
||||
aria-label="向左旋转 90 度"
|
||||
title="向左旋转 90 度"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={() => setVolume((v) => (v > 0 ? 0 : 1))}
|
||||
aria-label={volume > 0 ? "静音" : "取消静音"}
|
||||
>
|
||||
{volume > 0 ? <Volume2 size={18} /> : <VolumeX size={18} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-28 accent-white"
|
||||
aria-label="音量"
|
||||
/>
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={() => setRotation((r) => (r + 90) % 360)}
|
||||
aria-label="向右旋转 90 度"
|
||||
title="向右旋转 90 度"
|
||||
>
|
||||
<RotateCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label="切换全屏"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图文 BGM(隐藏控件,仅用于播放) */}
|
||||
{!isVideo && "music_url" in data && data.music_url ? (
|
||||
<audio ref={audioRef} src={data.music_url ?? undefined} loop preload="metadata" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 评论开关(竖屏:底部居中;横屏:右侧中部悬浮) */}
|
||||
<button
|
||||
className={[
|
||||
"z-10 grid place-items-center w-[54px] h-[54px] rounded-full ",
|
||||
"absolute right-4 top-2/3 -translate-y-1/2",
|
||||
].join(" ")}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="切换评论"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
||||
<MessageSquareText size={40} className="" />
|
||||
<span className="text-xl text-white">
|
||||
{comments.length > 0 ? `${comments.length}` : "暂无评论"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* 评论面板:竖屏 bottom sheet;横屏并排分栏 */}
|
||||
<aside className={asideClasses}>
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
|
||||
<button
|
||||
className="text-white/90 text-xs px-2 py-1 rounded-lg bg-white/15 border border-white/20"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<div className="text-white font-semibold">
|
||||
评论 {comments.length > 0 ? `(${comments.length})` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 overflow-auto">
|
||||
<header className="flex items-center gap-4 mb-5">
|
||||
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
|
||||
{data.author.avatar_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={data.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white/95 text-sm sm:text-base">{data.author.nickname}</div>
|
||||
<div className="text-xs text-white/50">发布于 {new Date(data.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul className="space-y-4 sm:space-y-5">
|
||||
{comments.map((c) => (
|
||||
<li key={c.cid} className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0">
|
||||
{c.user.avatar_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span>
|
||||
<span className="text-xs text-white/50">{new Date(c.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">{c.text}</p>
|
||||
<div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70">
|
||||
<ThumbsUp size={14} />
|
||||
<span>{c.digg_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{comments.length === 0 ? <li className="text-sm text-white/60">暂无评论</li> : null}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
app/aweme/[awemeId]/page.tsx
Normal file
80
app/aweme/[awemeId]/page.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import AwemeDetailClient from "./Client";
|
||||
|
||||
function ms(v?: number | null) {
|
||||
if (!v) return "";
|
||||
const s = Math.round(v / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) {
|
||||
const id = (await params).awemeId;
|
||||
|
||||
const [video, post] = await Promise.all([
|
||||
prisma.video.findUnique({
|
||||
where: { aweme_id: id },
|
||||
include: { author: true, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } },
|
||||
}),
|
||||
prisma.imagePost.findUnique({
|
||||
where: { aweme_id: id },
|
||||
include: { author: true, images: { orderBy: { order: "asc" } }, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } },
|
||||
})
|
||||
]);
|
||||
|
||||
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
||||
|
||||
const isVideo = !!video;
|
||||
const data = isVideo
|
||||
? {
|
||||
type: "video" as const,
|
||||
aweme_id: video!.aweme_id,
|
||||
desc: video!.desc,
|
||||
created_at: video!.created_at,
|
||||
duration_ms: video!.duration_ms,
|
||||
video_url: video!.video_url,
|
||||
width: video!.width ?? null,
|
||||
height: video!.height ?? null,
|
||||
author: { nickname: video!.author.nickname, avatar_url: video!.author.avatar_url },
|
||||
comments: video!.comments.map((c) => ({
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
created_at: c.created_at,
|
||||
digg_count: c.digg_count,
|
||||
user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url },
|
||||
})),
|
||||
}
|
||||
: {
|
||||
type: "image" as const,
|
||||
aweme_id: post!.aweme_id,
|
||||
desc: post!.desc,
|
||||
created_at: post!.created_at,
|
||||
images: post!.images.map((i) => ({ id: i.id, url: i.url, width: i.width ?? undefined, height: i.height ?? undefined })),
|
||||
music_url: post!.music_url,
|
||||
author: { nickname: post!.author.nickname, avatar_url: post!.author.avatar_url },
|
||||
comments: post!.comments.map((c) => ({
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
created_at: c.created_at,
|
||||
digg_count: c.digg_count,
|
||||
user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url },
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full overflow-hidden">
|
||||
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
|
||||
<div className="fixed left-3 top-3 z-30">
|
||||
<BackButton
|
||||
hrefFallback="/"
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25"
|
||||
/>
|
||||
</div>
|
||||
<AwemeDetailClient data={data as any} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
46
app/components/BackButton.tsx
Normal file
46
app/components/BackButton.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
type BackButtonProps = {
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
hrefFallback?: string; // default '/'
|
||||
children?: React.ReactNode; // custom icon/content; defaults to ArrowLeft
|
||||
};
|
||||
|
||||
/**
|
||||
* BackButton
|
||||
* - Primary: behaves like browser back (router.back()), preserving previous page state (e.g., scroll, filters)
|
||||
* - Fallback: if no history entry exists (e.g., opened directly), navigates to '/'
|
||||
* - Uses <Link> so that Ctrl/Cmd-click or middle-click opens the fallback URL in a new tab naturally
|
||||
*/
|
||||
export default function BackButton({ className, ariaLabel = '返回', hrefFallback = '/', children }: BackButtonProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>((e) => {
|
||||
// Respect modifier clicks (new tab/window) and non-left clicks
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
|
||||
// Prefer SPA back when we have some history to go back to
|
||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}
|
||||
// else: allow default <Link href> to navigate to fallback
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={hrefFallback}
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
>
|
||||
{children ?? <ArrowLeft size={18} />}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
195
app/components/FeedMasonry.tsx
Normal file
195
app/components/FeedMasonry.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import HoverVideo from './HoverVideo';
|
||||
import { ThumbsUp } from 'lucide-react';
|
||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
||||
|
||||
type Props = {
|
||||
initialItems: FeedItem[];
|
||||
initialCursor: string | null;
|
||||
};
|
||||
|
||||
export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
||||
// 哨兵与容器
|
||||
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ended, setEnded] = useState(!initialCursor);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4
|
||||
const getColumnCount = useCallback(() => {
|
||||
if (typeof window === 'undefined') return 1;
|
||||
const w = window.innerWidth;
|
||||
if (w >= 1280) return 4; // xl
|
||||
if (w >= 1024) return 3; // lg
|
||||
if (w >= 640) return 2; // sm
|
||||
return 1;
|
||||
}, []);
|
||||
const [columnCount, setColumnCount] = useState<number>(getColumnCount());
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setColumnCount(getColumnCount());
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [getColumnCount]);
|
||||
|
||||
// 估算卡片高度(用于分配到“最短列”)
|
||||
const estimateItemHeight = useCallback((item: FeedItem, colWidth: number) => {
|
||||
// 媒体区域高度
|
||||
let mediaH = 200; // fallback
|
||||
if (item.width && item.height) {
|
||||
mediaH = Math.max(80, (Number(item.height) / Number(item.width)) * colWidth);
|
||||
} else if (item.type === 'video') {
|
||||
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
||||
}
|
||||
// 文本 + 作者栏的高度粗估
|
||||
const textH = 48; // 标题+标签区域
|
||||
const authorH = 48; // 作者行
|
||||
const gap = 16; // 卡片内边距/间隙
|
||||
return mediaH + textH + authorH + gap;
|
||||
}, []);
|
||||
|
||||
// 维护列数据与列高
|
||||
const [columns, setColumns] = useState<FeedItem[][]>(() => {
|
||||
const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
||||
return cols;
|
||||
});
|
||||
const [colHeights, setColHeights] = useState<number[]>(() => Array.from({ length: columnCount }, () => 0));
|
||||
|
||||
// 初始化与当列数变化时重排
|
||||
useEffect(() => {
|
||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
||||
// 用 initialItems 重排
|
||||
const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
||||
const newHeights: number[] = Array.from({ length: columnCount }, () => 0);
|
||||
for (const item of initialItems) {
|
||||
// 找最短列
|
||||
let minIdx = 0;
|
||||
for (let i = 1; i < columnCount; i++) {
|
||||
if (newHeights[i] < newHeights[minIdx]) minIdx = i;
|
||||
}
|
||||
newCols[minIdx].push(item);
|
||||
newHeights[minIdx] += estimateItemHeight(item, colWidth || 300);
|
||||
}
|
||||
setColumns(newCols);
|
||||
setColHeights(newHeights);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columnCount]);
|
||||
|
||||
const fetchMore = useCallback(async () => {
|
||||
if (loading || ended) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (cursor) params.set('before', cursor);
|
||||
params.set('limit', '24');
|
||||
const res = await fetch(`/api/feed?${params.toString()}`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: FeedResponse = await res.json();
|
||||
// 将新数据按最短列分配
|
||||
setColumns((prevCols) => {
|
||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
||||
const cols = prevCols.map((c) => [...c]);
|
||||
const heights = [...colHeights];
|
||||
for (const item of data.items) {
|
||||
// 找当前最短列
|
||||
let minIdx = 0;
|
||||
for (let i = 1; i < columnCount; i++) {
|
||||
if (heights[i] < heights[minIdx]) minIdx = i;
|
||||
}
|
||||
cols[minIdx].push(item);
|
||||
heights[minIdx] += estimateItemHeight(item, colWidth || 300);
|
||||
}
|
||||
setColHeights(heights);
|
||||
return cols;
|
||||
});
|
||||
setCursor(data.nextCursor);
|
||||
if (!data.nextCursor || data.items.length === 0) setEnded(true);
|
||||
} catch (e) {
|
||||
console.error('fetch more feed failed', e);
|
||||
// 失败也不要死循环
|
||||
setEnded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cursor, ended, loading, columnCount, colHeights, estimateItemHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting) {
|
||||
fetchMore();
|
||||
}
|
||||
}, { rootMargin: '800px 0px 800px 0px' });
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [fetchMore]);
|
||||
|
||||
const renderCard = useCallback((item: FeedItem) => (
|
||||
<Link key={item.aweme_id} href={`/aweme/${item.aweme_id}`} className="mb-4 block group">
|
||||
<article className="relative overflow-hidden rounded-2xl shadow-sm ring-1 ring-black/5 bg-white/80 dark:bg-zinc-900/60 backdrop-blur-sm transition-transform duration-300 group-hover:-translate-y-1">
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ aspectRatio: `${(item.width && item.height) ? `${item.width}/${item.height}` : ''}` as any }}
|
||||
>
|
||||
{item.type === 'video' ? (
|
||||
<HoverVideo
|
||||
videoUrl={(item as any).video_url}
|
||||
coverUrl={item.cover_url}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
loading="lazy"
|
||||
src={item.cover_url || '/placeholder.svg'}
|
||||
alt={item.desc?.slice(0, 20) || 'image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
||||
{item.desc}
|
||||
</p>
|
||||
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
|
||||
{item.type === 'video' ? '视频' : '图文'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200">
|
||||
{item.author.avatar_url ? (
|
||||
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{item.author.nickname}</span>
|
||||
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300">{item.likes} </span><ThumbsUp size={16} style={{ color: 'var(--color-zinc-700)' }} />
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Masonry(按列渲染,动态分配到最短列) */}
|
||||
<div ref={containerRef} className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}>
|
||||
{columns.map((col, idx) => (
|
||||
<div key={idx} className="flex flex-col">
|
||||
{col.map((item) => renderCard(item))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div ref={sentinelRef} className="h-10 flex items-center justify-center text-sm text-zinc-500">
|
||||
{ended ? '没有更多了' : (loading ? '加载中…' : '下拉加载更多')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
app/components/HoverVideo.tsx
Normal file
66
app/components/HoverVideo.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
type HoverVideoProps = {
|
||||
videoUrl: string;
|
||||
coverUrl?: string | null;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* 鼠标移入才加载视频并自动播放,移出暂停并释放资源;默认显示封面。
|
||||
*/
|
||||
export default function HoverVideo({ videoUrl, coverUrl, className, style }: HoverVideoProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const onEnter = useCallback(() => {
|
||||
setActive(true);
|
||||
// 播放在下一帧触发,避免 ref 尚未赋值
|
||||
requestAnimationFrame(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
v.play().catch(() => {});
|
||||
});
|
||||
}, [videoUrl]);
|
||||
|
||||
const onLeave = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (v) {
|
||||
try {
|
||||
v.pause();
|
||||
v.load();
|
||||
} catch {}
|
||||
}
|
||||
setActive(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={className} style={style} onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
||||
{/* 封面始终渲染在底层 */}
|
||||
<img
|
||||
src={coverUrl || '/placeholder.svg'}
|
||||
alt="cover"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
loading='lazy'
|
||||
/>
|
||||
|
||||
{/* 仅在激活后渲染视频;初始不设置 src,防止提前加载 */}
|
||||
{active ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
autoPlay
|
||||
src={videoUrl}
|
||||
preload="none"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
170
app/fetcher/index.ts
Normal file
170
app/fetcher/index.ts
Normal file
@ -0,0 +1,170 @@
|
||||
// src/scrapeDouyin.ts
|
||||
import { chromium, type Response } from 'playwright';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||
import { createCamelCompatibleProxy } from '@/app/fetcher/utils';
|
||||
import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary } from '@/app/fetcher/network';
|
||||
import { pickBestPlayAddr, extractFirstFrame } from '@/app/fetcher/media';
|
||||
import { handleImagePost } from '@/app/fetcher/uploader';
|
||||
import { saveToDB, saveImagePostToDB } from '@/app/fetcher/persist';
|
||||
|
||||
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/';
|
||||
const COMMENT_PATH = '/aweme/v1/web/comment/list/';
|
||||
const POST_PATH = '/aweme/v1/web/aweme/post/'
|
||||
export async function scrapeDouyin(url: string) {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
console.log("Launch chromium");
|
||||
|
||||
const context = await chromium.launchPersistentContext('chrome-profile/douyin', { headless: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.addInitScript(() => {
|
||||
// 建一个全局容器存捕获的数据
|
||||
(window as any).__pace_captured__ = [];
|
||||
|
||||
// 用 Proxy 包装一个数组,拦截 push
|
||||
const captured = (window as any).__pace_captured__;
|
||||
const proxyArr = new Proxy([] as any[], {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'push') {
|
||||
return (...items: any[]) => {
|
||||
try { captured.push(...items); } catch { }
|
||||
return Array.prototype.push.apply(target, items);
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
set(target, prop, value, receiver) {
|
||||
// 兼容站点可能直接赋初始数组: self.__pace_f = [a,b]
|
||||
if (prop === 'length') return Reflect.set(target, prop, value, receiver);
|
||||
return Reflect.set(target, prop, value, receiver);
|
||||
}
|
||||
});
|
||||
|
||||
// 把 self/window 上的同名队列都指向我们的 proxy
|
||||
// 有些站点用 self,有些用 window
|
||||
(self as any).__pace_f = proxyArr;
|
||||
(window as any).__pace_f = proxyArr;
|
||||
});
|
||||
|
||||
try {
|
||||
// 先注册“先到先得”的监听,再导航,避免漏包
|
||||
const firstTypePromise = waitForFirstResponse(context, [
|
||||
{ key: 'detail', test: (r: Response) => r.url().includes(DETAIL_PATH) && r.status() === 200 },
|
||||
{ key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 },
|
||||
], 20_000); // 整体 20s 兜底超时,不逐个等待
|
||||
|
||||
// 评论只做短时“有就用、没有不等”的监听
|
||||
const commentPromise = waitForResponseWithTimeout(
|
||||
context,
|
||||
(r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200,
|
||||
8_000
|
||||
).catch(() => null);
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
|
||||
const firstType = await firstTypePromise; // { key, response } | null
|
||||
const commentRes = await commentPromise; // Response | null
|
||||
|
||||
if (!firstType) {
|
||||
console.warn('无法判定作品类型(未捕获详情或图文接口)');
|
||||
const md = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1]
|
||||
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ''))
|
||||
// return {aweme: { detail: {} } };
|
||||
});
|
||||
let aweme_mem = md.aweme.detail as DouyinImageAweme;
|
||||
if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情');
|
||||
|
||||
//@ts-ignore
|
||||
aweme_mem.author = aweme_mem.authorInfo
|
||||
const comments = commentRes ? (await safeJson<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
||||
|
||||
const aweme = createCamelCompatibleProxy(aweme_mem);
|
||||
|
||||
const uploads = await handleImagePost(context, aweme);
|
||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads);
|
||||
return { type: "image", ...saved };
|
||||
}
|
||||
|
||||
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
|
||||
if (firstType.key === 'post') {
|
||||
// 图文作品
|
||||
const postJson = await safeJson<DouyinPostListResponse>(firstType.response);
|
||||
if (!postJson?.aweme_list?.length) throw new Error('图文作品响应为空');
|
||||
|
||||
const currentURL = page.url();
|
||||
const target_aweme_id = currentURL.split('/').at(-1);
|
||||
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
|
||||
let aweme = awemeList.find((pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id);
|
||||
if (!aweme) {
|
||||
console.warn(`图文作品响应中未找到对应作品,look for aweme_id=${target_aweme_id}, have ${postJson.aweme_list.map(pt => pt.aweme_id).join(', ')}`);
|
||||
// Try read from memory
|
||||
// await new Promise(resolve => setTimeout(resolve, 1000000));
|
||||
const md = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1]
|
||||
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ''))
|
||||
// return {aweme: { detail: {} } };
|
||||
});
|
||||
aweme = md.aweme.detail as DouyinImageAweme;
|
||||
}
|
||||
// console.log(aweme);
|
||||
// await new Promise(resolve => setTimeout(resolve, 1000000));
|
||||
console.log(aweme);
|
||||
|
||||
|
||||
const comments = commentRes ? (await safeJson<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
||||
|
||||
const uploads = await handleImagePost(context, aweme);
|
||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads);
|
||||
return { type: "image", ...saved };
|
||||
} else if (firstType.key === 'detail') {
|
||||
// 视频作品
|
||||
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!;
|
||||
const comments = commentRes ? (await safeJson<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
||||
|
||||
// 找到比特率最高的 url
|
||||
const bestPlayAddr = pickBestPlayAddr(
|
||||
detail?.aweme_detail?.video.bit_rate
|
||||
);
|
||||
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
||||
|
||||
console.log('Best video URL:', bestVUrl);
|
||||
|
||||
// 下载视频并上传至 MinIO,获取外链
|
||||
let uploadedUrl: string | undefined;
|
||||
let coverUrl: string | undefined;
|
||||
if (bestVUrl && detail?.aweme_detail) {
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl);
|
||||
const awemeId = detail.aweme_detail.aweme_id;
|
||||
const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos');
|
||||
uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
console.log('Uploaded to MinIO:', uploadedUrl);
|
||||
|
||||
// 提取首帧作为封面并上传
|
||||
try {
|
||||
const cover = await extractFirstFrame(buffer);
|
||||
if (cover) {
|
||||
const coverName = generateUniqueFileName(`${awemeId}.jpg`, 'douyin/covers');
|
||||
coverUrl = await uploadFile(cover.buffer, coverName, { 'Content-Type': cover.contentType });
|
||||
console.log('Cover uploaded to MinIO:', coverUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Extract first frame failed, skip cover:', (e as Error)?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl);
|
||||
return { type: "video", ...saved };
|
||||
} else {
|
||||
throw new Error('无法判定作品类型(未命中详情或图文接口)');
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
57
app/fetcher/media.ts
Normal file
57
app/fetcher/media.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) {
|
||||
if (!variants?.length) return null;
|
||||
|
||||
const best = variants.reduce((best, cur) => {
|
||||
const b1 = best?.bit_rate ?? -1;
|
||||
const b2 = cur?.bit_rate ?? -1;
|
||||
return b2 > b1 ? cur : best;
|
||||
});
|
||||
|
||||
return best?.play_addr ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
|
||||
*/
|
||||
export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||
const ffmpegCmd = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
const tmpDir = os.tmpdir();
|
||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||
const outPath = path.join(tmpDir, `${base}.jpg`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(inPath, videoBuffer);
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-ss', '0',
|
||||
'-i', inPath,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '2',
|
||||
'-f', 'image2',
|
||||
'-y',
|
||||
outPath,
|
||||
];
|
||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||
const img = await fs.readFile(outPath);
|
||||
return { buffer: img, contentType: 'image/jpeg', ext: 'jpg' };
|
||||
} catch (e: any) {
|
||||
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) {
|
||||
console.warn('系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。');
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
try { await fs.unlink(inPath); } catch { }
|
||||
try { await fs.unlink(outPath); } catch { }
|
||||
}
|
||||
}
|
||||
130
app/fetcher/network.ts
Normal file
130
app/fetcher/network.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import type { BrowserContext, Response } from 'playwright';
|
||||
|
||||
export async function safeJson<T>(res: Response): Promise<T | null> {
|
||||
const ctype = res.headers()['content-type'] || '';
|
||||
if (ctype.includes('application/json')) {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
const t = await res.text();
|
||||
try {
|
||||
return JSON.parse(t) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Playwright 的 APIRequestContext 下载二进制内容
|
||||
* - 使用指定 headers 模拟浏览器请求
|
||||
* - referrer 使用链接本身
|
||||
*/
|
||||
export async function downloadBinary(
|
||||
context: BrowserContext,
|
||||
url: string
|
||||
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
|
||||
console.log('Download bin:', url);
|
||||
|
||||
const headers = {
|
||||
referer: url,
|
||||
} as Record<string, string>;
|
||||
|
||||
const res = await context.request.get(url, {
|
||||
headers,
|
||||
maxRedirects: 3,
|
||||
timeout: 240_000,
|
||||
failOnStatusCode: true,
|
||||
});
|
||||
|
||||
if (!res.ok()) {
|
||||
throw new Error(`下载内容失败: ${res.status()} ${res.statusText()}`);
|
||||
}
|
||||
|
||||
const buffer = await res.body();
|
||||
const contentType = res.headers()['content-type'] || 'application/octet-stream';
|
||||
const ext = (contentType.split('/')[1] || 'bin').split(';')[0] || 'bin';
|
||||
return { buffer, contentType, ext };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在多个候选匹配中“先到先得”地返回首个命中的 Response。
|
||||
* - 不为每个候选单独设长超时,改用整体兜底超时,避免无意义等待。
|
||||
*/
|
||||
export function waitForFirstResponse(
|
||||
context: BrowserContext,
|
||||
candidates: { key: string; test: (r: Response) => boolean }[],
|
||||
timeoutMs = 20_000
|
||||
): Promise<{ key: string; response: Response } | null> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const handler = (res: Response) => {
|
||||
if (resolved) return;
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
if (c.test(res)) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve({ key: c.key, response: res });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore predicate errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
context.off('response', handler);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
|
||||
context.on('response', handler);
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待符合条件的单个 Response,带短超时;用于评论等“可有可无”的数据。
|
||||
*/
|
||||
export function waitForResponseWithTimeout(
|
||||
context: BrowserContext,
|
||||
predicate: (r: Response) => boolean,
|
||||
timeoutMs = 5_000
|
||||
): Promise<Response> {
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const handler = (res: Response) => {
|
||||
try {
|
||||
if (predicate(res)) {
|
||||
cleanup();
|
||||
resolve(res);
|
||||
}
|
||||
} catch {
|
||||
// ignore predicate errors
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
context.off('response', handler);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
|
||||
context.on('response', handler);
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('timeout'));
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
263
app/fetcher/persist.ts
Normal file
263
app/fetcher/persist.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { uploadAvatarFromUrl } from './uploader';
|
||||
import { firstUrl } from './utils';
|
||||
|
||||
export async function saveToDB(
|
||||
context: BrowserContext,
|
||||
detailResp: DouyinVideoDetailResponse,
|
||||
commentResp: DouyinCommentResponse,
|
||||
videoUrl?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
coverUrl?: string
|
||||
) {
|
||||
if (!detailResp?.aweme_detail) throw new Error('视频详情为空');
|
||||
const d = detailResp.aweme_detail;
|
||||
|
||||
// 1) Upsert Author
|
||||
const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list);
|
||||
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${d.author.sec_uid}`);
|
||||
const author = await prisma.author.upsert({
|
||||
where: { sec_uid: d.author.sec_uid },
|
||||
create: {
|
||||
sec_uid: d.author.sec_uid,
|
||||
uid: d.author.uid,
|
||||
nickname: d.author.nickname,
|
||||
signature: d.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt(d.author.follower_count || 0),
|
||||
total_favorited: BigInt(d.author.total_favorited || 0),
|
||||
unique_id: d.author.unique_id ?? null,
|
||||
short_id: d.author.short_id ?? null,
|
||||
},
|
||||
update: {
|
||||
uid: d.author.uid,
|
||||
nickname: d.author.nickname,
|
||||
signature: d.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt(d.author.follower_count || 0),
|
||||
total_favorited: BigInt(d.author.total_favorited || 0),
|
||||
unique_id: d.author.unique_id ?? null,
|
||||
short_id: d.author.short_id ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// 2) Upsert Video
|
||||
const video = await prisma.video.upsert({
|
||||
where: { aweme_id: d.aweme_id },
|
||||
create: {
|
||||
aweme_id: d.aweme_id,
|
||||
desc: d.desc,
|
||||
preview_title: d.preview_title ?? null,
|
||||
duration_ms: d.duration,
|
||||
created_at: new Date((d.create_time || 0) * 1000),
|
||||
share_url: d.share_url,
|
||||
digg_count: BigInt(d.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(d.statistics?.comment_count || 0),
|
||||
share_count: BigInt(d.statistics?.share_count || 0),
|
||||
collect_count: BigInt(d.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
tags: (d.tags?.map(t => t.tag_name) ?? []),
|
||||
video_url: videoUrl ?? '',
|
||||
width: width ?? null,
|
||||
height: height ?? null,
|
||||
cover_url: coverUrl ?? null,
|
||||
},
|
||||
update: {
|
||||
desc: d.desc,
|
||||
preview_title: d.preview_title ?? null,
|
||||
duration_ms: d.duration,
|
||||
created_at: new Date((d.create_time || 0) * 1000),
|
||||
share_url: d.share_url,
|
||||
digg_count: BigInt(d.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(d.statistics?.comment_count || 0),
|
||||
share_count: BigInt(d.statistics?.share_count || 0),
|
||||
collect_count: BigInt(d.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
...(videoUrl ? { video_url: videoUrl } : {}),
|
||||
...(width ? { width } : {}),
|
||||
...(height ? { height } : {}),
|
||||
...(coverUrl ? { cover_url: coverUrl } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// 3) Upsert Comments + CommentUser
|
||||
const comments = commentResp?.comments ?? [];
|
||||
for (const c of comments) {
|
||||
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
||||
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`;
|
||||
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint);
|
||||
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
||||
const finalAvatarKey = finalAvatar ?? '';
|
||||
const cu = await prisma.commentUser.upsert({
|
||||
where: {
|
||||
nickname_avatar_url: {
|
||||
nickname: c.user?.nickname || '未知用户',
|
||||
avatar_url: finalAvatarKey,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
nickname: c.user?.nickname || '未知用户',
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
update: {
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.comment.upsert({
|
||||
where: { cid: c.cid },
|
||||
create: {
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
videoId: video.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
update: {
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
videoId: video.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { aweme_id: video.aweme_id, author_sec_uid: author.sec_uid, comment_count: comments.length };
|
||||
}
|
||||
|
||||
export async function saveImagePostToDB(
|
||||
context: BrowserContext,
|
||||
aweme: DouyinImageAweme,
|
||||
commentResp: DouyinCommentResponse,
|
||||
uploads: { images: { url: string; width?: number; height?: number }[]; musicUrl?: string }
|
||||
) {
|
||||
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失');
|
||||
|
||||
// Upsert Author(与视频一致)
|
||||
const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list);
|
||||
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${aweme.author.sec_uid}`);
|
||||
const author = await prisma.author.upsert({
|
||||
where: { sec_uid: aweme.author.sec_uid },
|
||||
create: {
|
||||
sec_uid: aweme.author.sec_uid,
|
||||
uid: aweme.author.uid,
|
||||
nickname: aweme.author.nickname,
|
||||
signature: aweme.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt((aweme.author as any).follower_count || 0),
|
||||
total_favorited: BigInt((aweme.author as any).total_favorited || 0),
|
||||
unique_id: (aweme.author as any).unique_id ?? null,
|
||||
short_id: (aweme.author as any).short_id ?? null,
|
||||
},
|
||||
update: {
|
||||
uid: aweme.author.uid,
|
||||
nickname: aweme.author.nickname,
|
||||
signature: aweme.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt((aweme.author as any).follower_count || 0),
|
||||
total_favorited: BigInt((aweme.author as any).total_favorited || 0),
|
||||
unique_id: (aweme.author as any).unique_id ?? null,
|
||||
short_id: (aweme.author as any).short_id ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Upsert ImagePost
|
||||
const imagePost = await prisma.imagePost.upsert({
|
||||
where: { aweme_id: aweme.aweme_id },
|
||||
create: {
|
||||
aweme_id: aweme.aweme_id,
|
||||
desc: aweme.desc,
|
||||
created_at: new Date((aweme.create_time || 0) * 1000),
|
||||
share_url: aweme.share_url || '',
|
||||
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
||||
share_count: BigInt(aweme.statistics?.share_count || 0),
|
||||
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
||||
music_url: uploads.musicUrl ?? null,
|
||||
},
|
||||
update: {
|
||||
desc: aweme.desc,
|
||||
created_at: new Date((aweme.create_time || 0) * 1000),
|
||||
share_url: aweme.share_url,
|
||||
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
||||
share_count: BigInt(aweme.statistics?.share_count || 0),
|
||||
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
||||
music_url: uploads.musicUrl ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Upsert ImageFiles(按顺序)
|
||||
for (let i = 0; i < uploads.images.length; i++) {
|
||||
const { url, width, height } = uploads.images[i];
|
||||
await prisma.imageFile.upsert({
|
||||
where: { postId_order: { postId: imagePost.aweme_id, order: i } },
|
||||
create: {
|
||||
postId: imagePost.aweme_id,
|
||||
order: i,
|
||||
url,
|
||||
width: typeof width === 'number' ? width : null,
|
||||
height: typeof height === 'number' ? height : null,
|
||||
},
|
||||
update: {
|
||||
url,
|
||||
width: typeof width === 'number' ? width : null,
|
||||
height: typeof height === 'number' ? height : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 评论入库:关联到 ImagePost
|
||||
const comments = commentResp?.comments ?? [];
|
||||
for (const c of comments) {
|
||||
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
||||
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`;
|
||||
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint);
|
||||
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
||||
const finalAvatarKey = finalAvatar ?? '';
|
||||
const cu = await prisma.commentUser.upsert({
|
||||
where: {
|
||||
nickname_avatar_url: {
|
||||
nickname: c.user?.nickname || '未知用户',
|
||||
avatar_url: finalAvatarKey,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
nickname: c.user?.nickname || '未知用户',
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
update: {
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.comment.upsert({
|
||||
where: { cid: c.cid },
|
||||
create: {
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
imagePostId: imagePost.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
update: {
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
imagePostId: imagePost.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { aweme_id: imagePost.aweme_id, author_sec_uid: author.sec_uid, image_count: uploads.images.length, comment_count: comments.length };
|
||||
}
|
||||
17
app/fetcher/route.ts
Normal file
17
app/fetcher/route.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { scrapeDouyin } from '.';
|
||||
|
||||
async function handleDouyinScrape(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const videoUrl = searchParams.get('url');
|
||||
if (!videoUrl) {
|
||||
return NextResponse.json({ error: '缺少视频URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 调用爬虫函数
|
||||
const result = await scrapeDouyin(videoUrl);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
export const GET = handleDouyinScrape
|
||||
138
app/fetcher/types.d.ts
vendored
Normal file
138
app/fetcher/types.d.ts
vendored
Normal file
@ -0,0 +1,138 @@
|
||||
/** 抖音评论响应(精简版) */
|
||||
interface DouyinCommentResponse {
|
||||
status_code: number;
|
||||
comments: DouyinComment[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 单条评论(精简版) */
|
||||
interface DouyinComment {
|
||||
cid: string;
|
||||
text: string; // 评论内容
|
||||
digg_count: number; // 点赞数
|
||||
create_time: number; // 创建时间(时间戳)
|
||||
user: DouyinUser; // 评论用户
|
||||
}
|
||||
|
||||
/** 用户信息(精简版) */
|
||||
interface DouyinUser {
|
||||
nickname: string; // 用户昵称
|
||||
avatar_thumb: {
|
||||
url_list: string[]; // 头像链接(可取第一个)
|
||||
};
|
||||
}
|
||||
|
||||
/** 抖音视频详情响应(精简版) */
|
||||
interface DouyinVideoDetailResponse {
|
||||
status_code: number;
|
||||
aweme_detail: DouyinVideoDetail;
|
||||
}
|
||||
/** 作者信息(精简版) */
|
||||
interface DouyinAuthor {
|
||||
uid: string; // 用户ID
|
||||
sec_uid: string; // 安全UID
|
||||
nickname: string; // 用户昵称
|
||||
signature: string; // 个性签名
|
||||
avatar_thumb: {
|
||||
url_list: string[]; // 头像URL(可取第一个)
|
||||
};
|
||||
follower_count: number; // 粉丝数
|
||||
total_favorited: number; // 获赞总数
|
||||
unique_id: string; // 抖音号
|
||||
short_id: string; // 短ID
|
||||
}
|
||||
/** 视频详情 */
|
||||
interface DouyinVideoDetail {
|
||||
aweme_id: string; // 视频ID
|
||||
desc: string; // 视频描述
|
||||
preview_title?: string; // 视频标题(有些字段中叫 preview_title)
|
||||
duration: number; // 视频时长(毫秒)
|
||||
create_time: number; // 创建时间(时间戳)
|
||||
share_url: string; // 视频分享链接
|
||||
|
||||
statistics: {
|
||||
digg_count: number; // 点赞数
|
||||
comment_count: number; // 评论数
|
||||
share_count: number; // 分享数
|
||||
collect_count: number; // 收藏数
|
||||
};
|
||||
|
||||
author: DouyinAuthor; // 作者信息
|
||||
video: VideoPlayBasic;
|
||||
tags: VideoTagBasic[];
|
||||
}
|
||||
|
||||
/** 视频播放信息 */
|
||||
interface VideoPlayBasic {
|
||||
/** 多种清晰度/编码的可播放变体(选其一即可) */
|
||||
bit_rate: PlayVariant[];
|
||||
/** 大缩略图(如果需要封面) */
|
||||
big_thumb_url?: string;
|
||||
}
|
||||
|
||||
/** 单个清晰度变体(来自 bit_rate[*] + play_addr) */
|
||||
interface PlayVariant {
|
||||
format: string; // mp4 等
|
||||
FPS: number;
|
||||
bit_rate: number; // bit_rate.bit_rate
|
||||
|
||||
/** 直连播放地址(最关键) */
|
||||
play_addr: {
|
||||
uri: string;
|
||||
url_list: string[];
|
||||
width: number;
|
||||
height: number;
|
||||
data_size: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 视频标签(精简) */
|
||||
interface VideoTagBasic {
|
||||
tag_id: number;
|
||||
tag_name: string;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
/** 图文作品列表响应(POST_PATH) */
|
||||
interface DouyinPostListResponse {
|
||||
status_code: number;
|
||||
aweme_list: DouyinImageAweme[];
|
||||
}
|
||||
|
||||
/** 图文作品(精简必要字段) */
|
||||
interface DouyinImageAweme {
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
create_time: number; // 秒
|
||||
share_url: string;
|
||||
statistics: {
|
||||
digg_count: number;
|
||||
comment_count: number;
|
||||
share_count: number;
|
||||
collect_count: number;
|
||||
};
|
||||
author: DouyinAuthor; // 复用视频作者类型(需包含 sec_uid)
|
||||
images: DouyinImageInfo[]; // 图片列表
|
||||
music?: DouyinMusicBasic; // 背景音乐(可选)
|
||||
video_tag?: VideoTagBasic[]; // 标签
|
||||
}
|
||||
|
||||
/** 图文作品的单张图片信息(精简) */
|
||||
interface DouyinImageInfo {
|
||||
url_list: string[]; // 多种格式(webp/jpeg)
|
||||
download_url_list?: string[]; // 可能带水印
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** 音乐基本信息(精简) */
|
||||
interface DouyinMusicBasic {
|
||||
id?: number | string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
album?: string;
|
||||
play_url: {
|
||||
uri?: string;
|
||||
url_list: string[]; // 真实可下载地址
|
||||
};
|
||||
}
|
||||
59
app/fetcher/uploader.ts
Normal file
59
app/fetcher/uploader.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||
import { downloadBinary } from './network';
|
||||
import { pickFirstUrl } from './utils';
|
||||
|
||||
/**
|
||||
* 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。
|
||||
*/
|
||||
export async function uploadAvatarFromUrl(
|
||||
context: BrowserContext,
|
||||
srcUrl?: string | null,
|
||||
nameHint?: string
|
||||
): Promise<string | undefined> {
|
||||
if (!srcUrl) return undefined;
|
||||
try {
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
||||
const safeExt = ext || 'jpg';
|
||||
const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`;
|
||||
const fileName = generateUniqueFileName(baseName, 'douyin/avatars');
|
||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
return uploaded;
|
||||
} catch (e) {
|
||||
console.warn('[avatar] 上传失败,使用原始链接:', (e as Error)?.message || e);
|
||||
return srcUrl || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载图文作品的图片和音乐并上传到 MinIO */
|
||||
export async function handleImagePost(
|
||||
context: BrowserContext,
|
||||
aweme: DouyinImageAweme
|
||||
): Promise<{ images: { url: string; width?: number; height?: number }[]; musicUrl?: string }> {
|
||||
const awemeId = aweme.aweme_id;
|
||||
const uploadedImages: { url: string; width?: number; height?: number }[] = [];
|
||||
|
||||
// 下载图片(顺序保持)
|
||||
for (let i = 0; i < (aweme.images?.length || 0); i++) {
|
||||
const img = aweme.images[i];
|
||||
const url = pickFirstUrl(img?.url_list);
|
||||
if (!url) continue;
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, url);
|
||||
const safeExt = ext || 'jpg';
|
||||
const fileName = generateUniqueFileName(`${awemeId}/${i}.${safeExt}`, 'douyin/images');
|
||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height });
|
||||
}
|
||||
|
||||
// 下载音乐(可选)
|
||||
let musicUrl: string | undefined;
|
||||
const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list);
|
||||
if (audioSrc) {
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, audioSrc);
|
||||
const safeExt = ext || 'mp3';
|
||||
const fileName = generateUniqueFileName(`${awemeId}.${safeExt}`, 'douyin/audios');
|
||||
musicUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
}
|
||||
|
||||
return { images: uploadedImages, musicUrl };
|
||||
}
|
||||
63
app/fetcher/utils.ts
Normal file
63
app/fetcher/utils.ts
Normal file
@ -0,0 +1,63 @@
|
||||
export function toCamelCaseKey(key: string): string {
|
||||
return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function toSnakeCaseKey(key: string): string {
|
||||
return key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个代理对象,使得读取属性时同时兼容 snake_case 与 camelCase 键名。
|
||||
* 访问顺序:原名 -> camelCase -> snake_case
|
||||
*/
|
||||
export function createCamelCompatibleProxy<T extends object>(root: T): T {
|
||||
const seen = new WeakMap<object, any>();
|
||||
|
||||
const wrap = (value: any): any => {
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (seen.has(value)) return seen.get(value);
|
||||
const proxied = new Proxy(value, handler);
|
||||
seen.set(value, proxied);
|
||||
return proxied;
|
||||
};
|
||||
|
||||
const handler: ProxyHandler<any> = {
|
||||
get(target, prop, receiver) {
|
||||
// 非字符串属性(如 Symbol、数字索引)直接透传
|
||||
if (typeof prop !== 'string') {
|
||||
return wrap(Reflect.get(target, prop, receiver));
|
||||
}
|
||||
|
||||
const primary = prop;
|
||||
|
||||
if (primary in target) return wrap(Reflect.get(target, primary, receiver));
|
||||
|
||||
const camel = toCamelCaseKey(primary);
|
||||
if (camel in target) return wrap(Reflect.get(target, camel, receiver));
|
||||
|
||||
const snake = toSnakeCaseKey(primary);
|
||||
if (snake in target) return wrap(Reflect.get(target, snake, receiver));
|
||||
|
||||
return wrap(Reflect.get(target, prop, receiver));
|
||||
},
|
||||
has(target, prop) {
|
||||
if (typeof prop !== 'string') return prop in target;
|
||||
const primary = prop === 'auther' ? 'autherInfo' : prop;
|
||||
return (
|
||||
primary in target ||
|
||||
toCamelCaseKey(primary) in target ||
|
||||
toSnakeCaseKey(primary) in target
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return wrap(root);
|
||||
}
|
||||
|
||||
/** 选择首个可用 URL */
|
||||
export function pickFirstUrl(list?: string[]) {
|
||||
return Array.isArray(list) && list.length ? list[0] : undefined;
|
||||
}
|
||||
|
||||
// 别名,兼容旧命名
|
||||
export const firstUrl = pickFirstUrl;
|
||||
31
app/globals.css
Normal file
31
app/globals.css
Normal file
@ -0,0 +1,31 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #161823; /* theme background */
|
||||
--foreground: #ededed;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #161823;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* 滚动条隐藏 */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
34
app/layout.tsx
Normal file
34
app/layout.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
60
app/page.tsx
Normal file
60
app/page.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import FeedMasonry from "./components/FeedMasonry";
|
||||
import type { FeedItem } from "./types/feed";
|
||||
|
||||
export default async function Home() {
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 60,
|
||||
include: { author: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 60,
|
||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const feed: FeedItem[] = [
|
||||
...videos.map((v) => ({
|
||||
type: "video" as const,
|
||||
aweme_id: v.aweme_id,
|
||||
created_at: v.created_at,
|
||||
desc: v.desc,
|
||||
video_url: v.video_url,
|
||||
cover_url: v.cover_url ?? null,
|
||||
width: v.width ?? null,
|
||||
height: v.height ?? null,
|
||||
author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null },
|
||||
likes: Number(v.digg_count)
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: p.images?.[0]?.url ?? null,
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: { nickname: p.author.nickname, avatar_url: p.author.avatar_url ?? null },
|
||||
likes: Number(p.digg_count)
|
||||
})),
|
||||
]
|
||||
//.sort(() => Math.random() - 0.5)
|
||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full px-4 py-8 md:py-12">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight mb-6">
|
||||
作品集
|
||||
</h1>
|
||||
|
||||
{(() => {
|
||||
const initial = feed.slice(0, 24);
|
||||
const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null;
|
||||
return <FeedMasonry initialItems={initial} initialCursor={cursor} />;
|
||||
})()}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
257
app/tasks/page.tsx
Normal file
257
app/tasks/page.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, CheckCircle2, Clock, ExternalLink, Link2, Loader2, PlayCircle, Plus, Square, Trash2, X } from "lucide-react";
|
||||
|
||||
type TaskStatus = "pending" | "running" | "success" | "error";
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
url: string;
|
||||
status: TaskStatus;
|
||||
startedAt?: number;
|
||||
finishedAt?: number;
|
||||
error?: string;
|
||||
result?: any;
|
||||
};
|
||||
|
||||
const extractDouyinLinks = (text: string): string[] => {
|
||||
if (!text) return [];
|
||||
const regex = /https?:\/\/v\.douyin\.com\/\S+/g; // 粗略匹配直到空白
|
||||
const matches = text.match(regex) ?? [];
|
||||
const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号
|
||||
const cleaned = matches
|
||||
.map((m) => m.replace(trailing, ""))
|
||||
.map((m) => m.endsWith("/") ? m : m); // 保持原样,通常短链以 / 结尾
|
||||
// 去重
|
||||
return Array.from(new Set(cleaned));
|
||||
};
|
||||
|
||||
export default function TasksPage() {
|
||||
const [input, setInput] = useState("");
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const controllers = useRef<Map<string, AbortController>>(new Map());
|
||||
const [openDetails, setOpenDetails] = useState<Set<string>>(new Set());
|
||||
|
||||
const inProgressUrls = useMemo(
|
||||
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)),
|
||||
[tasks]
|
||||
);
|
||||
|
||||
const addTasks = useCallback((urls: string[]) => {
|
||||
if (!urls.length) return;
|
||||
const now = Date.now();
|
||||
setTasks((prev) => {
|
||||
const existing = new Set(prev.map((t) => t.id));
|
||||
const notDuplicated = urls.filter(u => !inProgressUrls.has(u));
|
||||
const next: Task[] = [...prev];
|
||||
for (const url of notDuplicated) {
|
||||
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
next.push({ id, url, status: "pending" });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [inProgressUrls]);
|
||||
|
||||
const handleSubmit = useCallback((e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const urls = extractDouyinLinks(input);
|
||||
if (!urls.length) {
|
||||
alert("未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本");
|
||||
return;
|
||||
}
|
||||
addTasks(urls);
|
||||
setInput("");
|
||||
}, [input, addTasks]);
|
||||
|
||||
const startTask = useCallback(async (task: Task) => {
|
||||
// 若已存在控制器,避免重复启动
|
||||
if (controllers.current.has(task.id)) return;
|
||||
const ctrl = new AbortController();
|
||||
controllers.current.set(task.id, ctrl);
|
||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now() } : t));
|
||||
try {
|
||||
const res = await fetch(`/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `请求失败: ${res.status}`);
|
||||
}
|
||||
const data = await res.json().catch(() => undefined);
|
||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "success", finishedAt: Date.now(), result: data } : t));
|
||||
} catch (err: any) {
|
||||
const msg = err?.name === 'AbortError' ? '已取消' : (err?.message || String(err));
|
||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "error", finishedAt: Date.now(), error: msg } : t));
|
||||
} finally {
|
||||
controllers.current.delete(task.id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发)
|
||||
useEffect(() => {
|
||||
const pending = tasks.filter(t => t.status === "pending");
|
||||
pending.forEach((t) => startTask(t));
|
||||
}, [tasks, startTask]);
|
||||
|
||||
const cancelTask = useCallback((id: string) => {
|
||||
const ctrl = controllers.current.get(id);
|
||||
if (ctrl) ctrl.abort();
|
||||
controllers.current.delete(id);
|
||||
}, []);
|
||||
|
||||
const clearFinished = useCallback(() => {
|
||||
setTasks(prev => prev.filter(t => t.status === "pending" || t.status === "running"));
|
||||
}, []);
|
||||
|
||||
const toggleOpen = useCallback((id: string) => {
|
||||
setOpenDetails(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
})
|
||||
}, []);
|
||||
|
||||
const extractedCount = useMemo(() => extractDouyinLinks(input).length, [input]);
|
||||
|
||||
const formatDuration = (t?: number) => {
|
||||
if (!t) return "";
|
||||
const secs = Math.max(0, Math.round((Date.now() - t) / 1000));
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}m ${s}s`;
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status }: { status: TaskStatus }) => {
|
||||
const base = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
|
||||
if (status === 'running') return <span className={`${base} bg-indigo-500/15 text-indigo-300`}><Loader2 className="h-3.5 w-3.5 animate-spin"/> 进行中</span>;
|
||||
if (status === 'pending') return <span className={`${base} bg-yellow-500/15 text-yellow-300`}><Clock className="h-3.5 w-3.5"/> 待开始</span>;
|
||||
if (status === 'success') return <span className={`${base} bg-emerald-500/15 text-emerald-300`}><CheckCircle2 className="h-3.5 w-3.5"/> 完成</span>;
|
||||
return <span className={`${base} bg-red-500/15 text-red-300`}><AlertTriangle className="h-3.5 w-3.5"/> 失败</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-dvh overflow-hidden bg-neutral-950 text-neutral-100">
|
||||
{/* 背景渐变(明确置于内容层后面) */}
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 opacity-40 [mask-image:radial-gradient(60%_60%_at_50%_0%,#000_30%,transparent_70%)]">
|
||||
<div className="absolute -top-1/2 left-1/2 h-[120vh] w-[120vh] -translate-x-1/2 rounded-full bg-[conic-gradient(at_50%_50%,#312e81_0deg,#0ea5e9_120deg,#10b981_240deg,#312e81_360deg)] blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-4xl px-5 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
||||
抖音采集任务
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-neutral-400">粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。</p>
|
||||
</div>
|
||||
|
||||
{/* 输入卡片 */}
|
||||
<form onSubmit={handleSubmit} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 rounded-md bg-neutral-900/60 p-2 ring-1 ring-white/5">
|
||||
<Link2 className="h-5 w-5 text-neutral-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<textarea
|
||||
className="w-full min-h-28 resize-y rounded-md border border-neutral-800/60 bg-neutral-900/60 px-3 py-2 text-sm placeholder:text-neutral-500 focus:border-indigo-700/50 focus:outline-none focus:ring-2 focus:ring-indigo-700/30"
|
||||
placeholder="例如:2.51 05/08 f@b.AT EUy:/ 无意之中... https://v.douyin.com/dP22IOH8uAI/ 复制此链接..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>已提取 {extractedCount} 个短链</span>
|
||||
<span className="hidden sm:inline">快捷键:Ctrl / Cmd + Enter 提交</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button type="submit" className="inline-flex items-center gap-2 rounded-md bg-indigo-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400/40">
|
||||
<Plus className="h-4 w-4" /> 添加任务
|
||||
</button>
|
||||
<button type="button" onClick={clearFinished} className="inline-flex items-center gap-2 rounded-md bg-neutral-800 px-3 py-2 text-sm text-neutral-200 transition-colors hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-white/10">
|
||||
<Trash2 className="h-4 w-4" /> 清除已完成
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">进行中的任务</h2>
|
||||
<span className="text-xs text-neutral-400">共 {tasks.length} 个</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{tasks.map((t) => {
|
||||
const isOpen = openDetails.has(t.id);
|
||||
return (
|
||||
<li key={t.id} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={t.status} />
|
||||
{t.status === 'running' && (
|
||||
<span className="text-xs text-neutral-400">已耗时 {formatDuration(t.startedAt)}</span>
|
||||
)}
|
||||
{t.status === 'success' && (
|
||||
<span className="text-xs text-neutral-400">用时 {t.startedAt ? formatDuration(t.startedAt) : '--'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-200">
|
||||
<span className="truncate" title={t.url}>{t.url}</span>
|
||||
<a className="shrink-0 text-neutral-400 hover:text-neutral-200" href={t.url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 space-x-2">
|
||||
{t.status === 'running' && (
|
||||
<button onClick={() => cancelTask(t.id)} className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600">
|
||||
<Square className="h-3.5 w-3.5"/> 取消
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => toggleOpen(t.id)} className="inline-flex items-center gap-1 rounded-md bg-neutral-800 px-2 py-1 text-xs text-neutral-200 transition-colors hover:bg-neutral-700">
|
||||
{isOpen ? <X className="h-3.5 w-3.5"/> : <PlayCircle className="h-3.5 w-3.5" />} {isOpen ? '收起' : '详情'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{t.status === 'running' && (
|
||||
<div className="mt-3 h-1 w-full overflow-hidden rounded bg-neutral-800">
|
||||
<div className="h-full w-1/3 animate-[progress_1.2s_ease_infinite] rounded bg-indigo-500/70" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
|
||||
{t.status === 'error' && (
|
||||
<div className="mb-2 inline-flex items-center gap-2 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-200">
|
||||
<AlertTriangle className="h-4 w-4"/> {t.error}
|
||||
</div>
|
||||
)}
|
||||
{typeof t.result !== 'undefined' && (
|
||||
<pre className="max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre>
|
||||
)}
|
||||
{typeof t.result === 'undefined' && t.status !== 'error' && (
|
||||
<div className="text-xs text-neutral-400">暂无输出</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<li className="rounded-xl border border-dashed border-white/10 bg-white/[0.03] p-8 text-center text-sm text-neutral-400">
|
||||
暂无任务,粘贴包含 Douyin 短链的文本后点击“添加任务”。
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 进度条动画 keyframes */}
|
||||
<style>{`@keyframes progress{0%{transform:translateX(-100%)}50%{transform:translateX(10%)}100%{transform:translateX(120%)}}`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/types/feed.ts
Normal file
21
app/types/feed.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export type FeedItem =
|
||||
| ({
|
||||
type: "video";
|
||||
video_url: string;
|
||||
} | {
|
||||
type: "image";
|
||||
}) & {
|
||||
likes: number;
|
||||
author: { nickname: string; avatar_url: string | null };
|
||||
aweme_id: string;
|
||||
created_at: Date | string;
|
||||
desc: string;
|
||||
cover_url: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
};
|
||||
|
||||
export interface FeedResponse {
|
||||
items: FeedItem[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
37
biome.json
Normal file
37
biome.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noUnknownAtRules": "off"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"next": "recommended",
|
||||
"react": "recommended"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
452
bun.lock
Normal file
452
bun.lock
Normal file
@ -0,0 +1,452 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "douyin-archive",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "15.5.6",
|
||||
"playwright": "^1.56.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"prisma": "^6.16.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.2.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.0", "@biomejs/cli-darwin-x64": "2.2.0", "@biomejs/cli-linux-arm64": "2.2.0", "@biomejs/cli-linux-arm64-musl": "2.2.0", "@biomejs/cli-linux-x64": "2.2.0", "@biomejs/cli-linux-x64-musl": "2.2.0", "@biomejs/cli-win32-arm64": "2.2.0", "@biomejs/cli-win32-x64": "2.2.0" }, "bin": { "biome": "bin/biome" } }, "sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.3" }, "os": "darwin", "cpu": "x64" }, "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.3" }, "os": "linux", "cpu": "arm" }, "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.3" }, "os": "linux", "cpu": "s390x" }, "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.4", "", { "dependencies": { "@emnapi/runtime": "^1.5.0" }, "cpu": "none" }, "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.4", "", { "os": "win32", "cpu": "x64" }, "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig=="],
|
||||
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@next/env": ["@next/env@15.5.6", "", {}, "sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ=="],
|
||||
|
||||
"@prisma/client": ["@prisma/client@6.17.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw=="],
|
||||
|
||||
"@prisma/config": ["@prisma/config@6.17.1", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.16.12", "empathic": "2.0.0" } }, "sha512-fs8wY6DsvOCzuiyWVckrVs1LOcbY4LZNz8ki4uUIQ28jCCzojTGqdLhN2Jl5lDnC1yI8/gNIKpsWDM8pLhOdwA=="],
|
||||
|
||||
"@prisma/debug": ["@prisma/debug@6.17.1", "", {}, "sha512-Vf7Tt5Wh9XcndpbmeotuqOMLWPTjEKCsgojxXP2oxE1/xYe7PtnP76hsouG9vis6fctX+TxgmwxTuYi/+xc7dQ=="],
|
||||
|
||||
"@prisma/engines": ["@prisma/engines@6.17.1", "", { "dependencies": { "@prisma/debug": "6.17.1", "@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac", "@prisma/fetch-engine": "6.17.1", "@prisma/get-platform": "6.17.1" } }, "sha512-D95Ik3GYZkqZ8lSR4EyFOJ/tR33FcYRP8kK61o+WMsyD10UfJwd7+YielflHfKwiGodcqKqoraWw8ElAgMDbPw=="],
|
||||
|
||||
"@prisma/engines-version": ["@prisma/engines-version@6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac", "", {}, "sha512-17140E3huOuD9lMdJ9+SF/juOf3WR3sTJMVyyenzqUPbuH+89nPhSWcrY+Mf7tmSs6HvaO+7S+HkELinn6bhdg=="],
|
||||
|
||||
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.17.1", "", { "dependencies": { "@prisma/debug": "6.17.1", "@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac", "@prisma/get-platform": "6.17.1" } }, "sha512-AYZiHOs184qkDMiTeshyJCtyL4yERkjfTkJiSJdYuSfc24m94lTNL5+GFinZ6vVz+ktX4NJzHKn1zIFzGTWrWg=="],
|
||||
|
||||
"@prisma/get-platform": ["@prisma/get-platform@6.17.1", "", { "dependencies": { "@prisma/debug": "6.17.1" } }, "sha512-AKEn6fsfz0r482S5KRDFlIGEaq9wLNcgalD1adL+fPcFFblIKs1sD81kY/utrHdqKuVC6E1XSRpegDK3ZLL4Qg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.14", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.5.1" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.14", "@tailwindcss/oxide-darwin-arm64": "4.1.14", "@tailwindcss/oxide-darwin-x64": "4.1.14", "@tailwindcss/oxide-freebsd-x64": "4.1.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", "@tailwindcss/oxide-linux-x64-musl": "4.1.14", "@tailwindcss/oxide-wasm32-wasi": "4.1.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" } }, "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.14", "", { "os": "android", "cpu": "arm64" }, "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14", "", { "os": "linux", "cpu": "arm" }, "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.14", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.5", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA=="],
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.14", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "postcss": "^8.4.41", "tailwindcss": "4.1.14" } }, "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.22", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
|
||||
|
||||
"@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
|
||||
|
||||
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
||||
|
||||
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
|
||||
|
||||
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"effect": ["effect@3.16.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg=="],
|
||||
|
||||
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
|
||||
|
||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
|
||||
|
||||
"filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
|
||||
|
||||
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
|
||||
|
||||
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
|
||||
|
||||
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
|
||||
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"next": ["next@15.5.6", "", { "dependencies": { "@next/env": "15.5.6", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.6", "@next/swc-darwin-x64": "15.5.6", "@next/swc-linux-arm64-gnu": "15.5.6", "@next/swc-linux-arm64-musl": "15.5.6", "@next/swc-linux-x64-gnu": "15.5.6", "@next/swc-linux-x64-musl": "15.5.6", "@next/swc-win32-arm64-msvc": "15.5.6", "@next/swc-win32-x64-msvc": "15.5.6", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ=="],
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||
|
||||
"playwright": ["playwright@1.56.1", "", { "dependencies": { "playwright-core": "1.56.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"prisma": ["prisma@6.17.1", "", { "dependencies": { "@prisma/config": "6.17.1", "@prisma/engines": "6.17.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g=="],
|
||||
|
||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||
|
||||
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
|
||||
|
||||
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
|
||||
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
||||
|
||||
"stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="],
|
||||
|
||||
"stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="],
|
||||
|
||||
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="],
|
||||
|
||||
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="],
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
|
||||
|
||||
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
}
|
||||
}
|
||||
317
lib/minio-examples.ts
Normal file
317
lib/minio-examples.ts
Normal file
@ -0,0 +1,317 @@
|
||||
/**
|
||||
* MinIO 工具函数使用示例
|
||||
* 这个文件展示了如何使用 minio.ts 中的各种函数
|
||||
*/
|
||||
|
||||
import {
|
||||
uploadFile,
|
||||
getFileUrl,
|
||||
deleteFile,
|
||||
listFiles,
|
||||
generateUniqueFileName,
|
||||
validateFileType,
|
||||
validateFileSize,
|
||||
downloadFile,
|
||||
fileExists,
|
||||
getFileInfo,
|
||||
} from './minio';
|
||||
|
||||
// ========================================
|
||||
// 1. 上传文件示例
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 上传用户头像
|
||||
*/
|
||||
async function uploadAvatar(file: File, userId: string) {
|
||||
// 验证文件类型
|
||||
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
if (!validateFileType(file.name, allowedTypes)) {
|
||||
throw new Error('不支持的图片格式');
|
||||
}
|
||||
|
||||
// 验证文件大小(5MB)
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
if (!validateFileSize(file.size, maxSize)) {
|
||||
throw new Error('文件大小超过限制(最大5MB)');
|
||||
}
|
||||
|
||||
// 生成唯一文件名,存储在 avatars 目录下
|
||||
const path = generateUniqueFileName(file.name, `avatars/${userId}`);
|
||||
|
||||
// 上传文件
|
||||
const url = await uploadFile(file, path, {
|
||||
'Content-Type': file.type,
|
||||
'User-Id': userId,
|
||||
});
|
||||
|
||||
return { url, path };
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文章封面图
|
||||
*/
|
||||
async function uploadPostCover(file: File, postId: string) {
|
||||
const allowedTypes = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
if (!validateFileType(file.name, allowedTypes)) {
|
||||
throw new Error('不支持的图片格式');
|
||||
}
|
||||
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (!validateFileSize(file.size, maxSize)) {
|
||||
throw new Error('文件大小超过限制(最大10MB)');
|
||||
}
|
||||
|
||||
// 按日期组织文件
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
const path = generateUniqueFileName(
|
||||
file.name,
|
||||
`posts/${year}/${month}/covers`
|
||||
);
|
||||
|
||||
const url = await uploadFile(file, path);
|
||||
|
||||
return { url, path };
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文章内容中的图片
|
||||
*/
|
||||
async function uploadPostImage(file: File, postId: string) {
|
||||
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
if (!validateFileType(file.name, allowedTypes)) {
|
||||
throw new Error('不支持的图片格式');
|
||||
}
|
||||
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
if (!validateFileSize(file.size, maxSize)) {
|
||||
throw new Error('文件大小超过限制(最大5MB)');
|
||||
}
|
||||
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
const path = generateUniqueFileName(
|
||||
file.name,
|
||||
`posts/${year}/${month}/images`
|
||||
);
|
||||
|
||||
const url = await uploadFile(file, path);
|
||||
|
||||
return { url, path };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 2. 获取文件URL示例
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 获取文件的公共访问URL
|
||||
*/
|
||||
function getPublicFileUrl(path: string) {
|
||||
return getFileUrl(path);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 3. 删除文件示例
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 删除用户头像(更换头像时)
|
||||
*/
|
||||
async function deleteAvatar(avatarPath: string) {
|
||||
try {
|
||||
await deleteFile(avatarPath);
|
||||
console.log('头像删除成功');
|
||||
} catch (error) {
|
||||
console.error('删除头像失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文章时,删除相关的所有图片
|
||||
*/
|
||||
async function deletePostImages(postId: string) {
|
||||
try {
|
||||
// 列出文章相关的所有图片
|
||||
const files = await listFiles(`posts/`, true);
|
||||
|
||||
// 过滤出该文章的图片(根据实际情况调整逻辑)
|
||||
const postFiles = files.filter(file =>
|
||||
file.name?.includes(postId)
|
||||
);
|
||||
|
||||
// 批量删除
|
||||
const paths = postFiles.map(f => f.name).filter((name): name is string => !!name);
|
||||
|
||||
if (paths.length > 0) {
|
||||
const { deleteFiles } = await import('./minio');
|
||||
await deleteFiles(paths);
|
||||
console.log(`删除了 ${paths.length} 个文件`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除文章图片失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 4. 列出文件示例
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 获取用户的所有头像历史
|
||||
*/
|
||||
async function getUserAvatars(userId: string) {
|
||||
try {
|
||||
const files = await listFiles(`avatars/${userId}/`, false);
|
||||
|
||||
return files.map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
url: file.name ? getFileUrl(file.name) : null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('获取用户头像列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某月的所有文章封面
|
||||
*/
|
||||
async function getPostCoversByMonth(year: number, month: number) {
|
||||
try {
|
||||
const monthStr = String(month).padStart(2, '0');
|
||||
const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false);
|
||||
|
||||
return files.map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
url: file.name ? getFileUrl(file.name) : null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('获取封面列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 5. 检查文件是否存在
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 检查头像是否存在
|
||||
*/
|
||||
async function checkAvatarExists(avatarPath: string): Promise<boolean> {
|
||||
return await fileExists(avatarPath);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 6. 获取文件信息
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 获取文件详细信息
|
||||
*/
|
||||
async function getFileDetails(path: string) {
|
||||
try {
|
||||
const info = await getFileInfo(path);
|
||||
|
||||
return {
|
||||
size: info.size,
|
||||
lastModified: info.lastModified,
|
||||
etag: info.etag,
|
||||
contentType: info.metaData?.['content-type'],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取文件信息失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 7. 下载文件示例
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 下载文件到本地
|
||||
*/
|
||||
async function downloadFileToBuffer(path: string): Promise<Buffer> {
|
||||
try {
|
||||
return await downloadFile(path);
|
||||
} catch (error) {
|
||||
console.error('下载文件失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 8. 在 API Route 中使用示例
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Next.js API Route 示例:上传文件
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* // app/api/upload/route.ts
|
||||
* import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||
*
|
||||
* export async function POST(request: Request) {
|
||||
* const formData = await request.formData();
|
||||
* const file = formData.get('file') as File;
|
||||
*
|
||||
* if (!file) {
|
||||
* return Response.json({ error: '没有文件' }, { status: 400 });
|
||||
* }
|
||||
*
|
||||
* const path = generateUniqueFileName(file.name, 'uploads');
|
||||
* const url = await uploadFile(file, path);
|
||||
*
|
||||
* return Response.json({ url, path });
|
||||
* }
|
||||
*/
|
||||
|
||||
/**
|
||||
* Next.js API Route 示例:删除文件
|
||||
*
|
||||
* // app/api/delete/route.ts
|
||||
* import { deleteFile } from '@/lib/minio';
|
||||
*
|
||||
* export async function DELETE(request: Request) {
|
||||
* const { path } = await request.json();
|
||||
*
|
||||
* if (!path) {
|
||||
* return Response.json({ error: '缺少文件路径' }, { status: 400 });
|
||||
* }
|
||||
*
|
||||
* await deleteFile(path);
|
||||
*
|
||||
* return Response.json({ success: true });
|
||||
* }
|
||||
*/
|
||||
|
||||
// ========================================
|
||||
// 导出示例函数
|
||||
// ========================================
|
||||
|
||||
export {
|
||||
uploadAvatar,
|
||||
uploadPostCover,
|
||||
uploadPostImage,
|
||||
getPublicFileUrl,
|
||||
deleteAvatar,
|
||||
deletePostImages,
|
||||
getUserAvatars,
|
||||
getPostCoversByMonth,
|
||||
checkAvatarExists,
|
||||
getFileDetails,
|
||||
downloadFileToBuffer,
|
||||
};
|
||||
340
lib/minio.ts
Normal file
340
lib/minio.ts
Normal file
@ -0,0 +1,340 @@
|
||||
import * as Minio from 'minio';
|
||||
|
||||
// MinIO 客户端配置
|
||||
const useSSL = process.env.MINIO_USE_SSL === 'true';
|
||||
const port = Number(process.env.MINIO_PORT) || 9000;
|
||||
|
||||
// 当使用标准HTTPS端口(443)或HTTP端口(80)时,MinIO客户端不需要指定端口
|
||||
const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80);
|
||||
|
||||
const minioClient = new Minio.Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
...(shouldOmitPort ? {} : { port }),
|
||||
useSSL,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || '',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || '',
|
||||
pathStyle: true, // 使用路径风格,对反向代理更友好
|
||||
});
|
||||
|
||||
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'home-page';
|
||||
|
||||
/**
|
||||
* 初始化 MinIO Bucket(确保 bucket 存在)
|
||||
*/
|
||||
export async function initBucket(): Promise<void> {
|
||||
try {
|
||||
const exists = await minioClient.bucketExists(BUCKET_NAME);
|
||||
if (!exists) {
|
||||
await minioClient.makeBucket(BUCKET_NAME, 'us-east-1');
|
||||
console.log(`Bucket ${BUCKET_NAME} created successfully`);
|
||||
}
|
||||
// 设置公共读取策略(可选)
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: ['*'] },
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
|
||||
} catch (error) {
|
||||
console.error('Error initializing bucket:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 上传文件到 MinIO
|
||||
* @param file - File 对象或 Buffer
|
||||
* @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png')
|
||||
* @param metadata - 可选的元数据
|
||||
* @returns 文件的访问URL
|
||||
*/
|
||||
export async function uploadFile(
|
||||
file: File | Buffer,
|
||||
path: string,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<string> {
|
||||
try {
|
||||
await initBucket();
|
||||
|
||||
let buffer: Buffer;
|
||||
let contentType: string;
|
||||
|
||||
if (file instanceof File) {
|
||||
buffer = Buffer.from(await file.arrayBuffer());
|
||||
contentType = file.type || 'application/octet-stream';
|
||||
} else {
|
||||
buffer = file;
|
||||
contentType = metadata?.['Content-Type'] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
const metaData = {
|
||||
'Content-Type': contentType,
|
||||
...metadata,
|
||||
};
|
||||
|
||||
await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, metaData);
|
||||
|
||||
return getFileUrl(path);
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取文件的公共访问URL
|
||||
* @param path - 文件路径
|
||||
* @returns 文件的访问URL
|
||||
*/
|
||||
export function getFileUrl(path: string): string {
|
||||
const endpoint = process.env.MINIO_REAL_DOMAIN;
|
||||
|
||||
return `${endpoint}/${BUCKET_NAME}/${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的临时访问URL(带签名,用于私有文件)
|
||||
* @param path - 文件路径
|
||||
* @param expirySeconds - 过期时间(秒),默认7天
|
||||
* @returns 临时访问URL
|
||||
*/
|
||||
export async function getPresignedUrl(
|
||||
path: string,
|
||||
expirySeconds: number = 7 * 24 * 60 * 60
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await minioClient.presignedGetObject(BUCKET_NAME, path, expirySeconds);
|
||||
} catch (error) {
|
||||
console.error('Error generating presigned URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* @param path - 文件路径
|
||||
* @returns 文件的 Buffer
|
||||
*/
|
||||
export async function downloadFile(path: string): Promise<Buffer> {
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
const stream = await minioClient.getObject(BUCKET_NAME, path);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => chunks.push(chunk));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件流
|
||||
* @param path - 文件路径
|
||||
* @returns 文件流
|
||||
*/
|
||||
export async function getFileStream(path: string): Promise<NodeJS.ReadableStream> {
|
||||
try {
|
||||
return await minioClient.getObject(BUCKET_NAME, path);
|
||||
} catch (error) {
|
||||
console.error('Error getting file stream:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param path - 文件路径
|
||||
*/
|
||||
export async function deleteFile(path: string): Promise<void> {
|
||||
try {
|
||||
await minioClient.removeObject(BUCKET_NAME, path);
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除文件
|
||||
* @param paths - 文件路径数组
|
||||
*/
|
||||
export async function deleteFiles(paths: string[]): Promise<void> {
|
||||
try {
|
||||
await minioClient.removeObjects(BUCKET_NAME, paths);
|
||||
} catch (error) {
|
||||
console.error('Error deleting files:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param path - 文件路径
|
||||
* @returns 文件是否存在
|
||||
*/
|
||||
export async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await minioClient.statObject(BUCKET_NAME, path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
* @param path - 文件路径
|
||||
* @returns 文件元数据
|
||||
*/
|
||||
export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
|
||||
try {
|
||||
return await minioClient.statObject(BUCKET_NAME, path);
|
||||
} catch (error) {
|
||||
console.error('Error getting file info:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出指定路径下的所有文件
|
||||
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
|
||||
* @param recursive - 是否递归列出子目录
|
||||
* @returns 文件列表
|
||||
*/
|
||||
export async function listFiles(
|
||||
prefix: string = '',
|
||||
recursive: boolean = false
|
||||
): Promise<(Minio.BucketItem & { endpoint: string })[]> {
|
||||
try {
|
||||
const files: (Minio.BucketItem & { endpoint: string })[] = [];
|
||||
const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (obj) => {
|
||||
if (obj.name) {
|
||||
files.push({ endpoint: `${process.env.MINIO_REAL_DOMAIN}/${BUCKET_NAME}`, ...obj, } as Minio.BucketItem & { endpoint: string });
|
||||
}
|
||||
});
|
||||
stream.on('end', () => resolve(files));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error listing files:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文件
|
||||
* @param sourcePath - 源文件路径
|
||||
* @param destPath - 目标文件路径
|
||||
*/
|
||||
export async function copyFile(sourcePath: string, destPath: string): Promise<void> {
|
||||
try {
|
||||
const conds = new Minio.CopyConditions();
|
||||
await minioClient.copyObject(
|
||||
BUCKET_NAME,
|
||||
destPath,
|
||||
`/${BUCKET_NAME}/${sourcePath}`,
|
||||
conds
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error copying file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一文件名
|
||||
* @param originalName - 原始文件名
|
||||
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
|
||||
* @returns 带路径的唯一文件名
|
||||
*/
|
||||
export function generateUniqueFileName(originalName: string, prefix: string = ''): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const ext = originalName.split('.').pop();
|
||||
const nameWithoutExt = originalName.replace(`.${ext}`, '').replace(/[^a-zA-Z0-9]/g, '_');
|
||||
|
||||
const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`;
|
||||
|
||||
return prefix ? `${prefix.replace(/\/$/, '')}/${fileName}` : fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @param filename - 文件名
|
||||
* @returns 扩展名(不含点)
|
||||
*/
|
||||
export function getFileExtension(filename: string): string {
|
||||
return filename.split('.').pop() || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件类型
|
||||
* @param filename - 文件名
|
||||
* @param allowedTypes - 允许的扩展名数组(如: ['jpg', 'png', 'gif'])
|
||||
* @returns 是否为允许的类型
|
||||
*/
|
||||
export function validateFileType(filename: string, allowedTypes: string[]): boolean {
|
||||
const ext = getFileExtension(filename).toLowerCase();
|
||||
return allowedTypes.map(t => t.toLowerCase()).includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件大小
|
||||
* @param size - 文件大小(字节)
|
||||
* @param maxSize - 最大大小(字节)
|
||||
* @returns 是否在允许的大小范围内
|
||||
*/
|
||||
export function validateFileSize(size: number, maxSize: number): boolean {
|
||||
return size <= maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param bytes - 字节数
|
||||
* @returns 格式化后的文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
initBucket,
|
||||
uploadFile,
|
||||
getFileUrl,
|
||||
getPresignedUrl,
|
||||
downloadFile,
|
||||
getFileStream,
|
||||
deleteFile,
|
||||
deleteFiles,
|
||||
fileExists,
|
||||
getFileInfo,
|
||||
listFiles,
|
||||
copyFile,
|
||||
generateUniqueFileName,
|
||||
getFileExtension,
|
||||
validateFileType,
|
||||
validateFileSize,
|
||||
formatFileSize,
|
||||
};
|
||||
9
lib/prisma.ts
Normal file
9
lib/prisma.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
1787
package-lock.json
generated
Normal file
1787
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "douyin-archive",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "15.5.6",
|
||||
"playwright": "^1.56.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"prisma": "^6.16.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
31
pm2.config.cjs
Normal file
31
pm2.config.cjs
Normal file
@ -0,0 +1,31 @@
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
const instances = Number.parseInt(process.env.WEB_CONCURRENCY ?? '1', 10) || 1;
|
||||
const { parsed: envFromFile = {} } = dotenv.config({ path: path.join(__dirname, '.env') });
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "DouyinArchive",
|
||||
script: 'index.ts',
|
||||
cwd: __dirname,
|
||||
autorestart: true,
|
||||
restart_delay: 4000,
|
||||
kill_timeout: 5000,
|
||||
instances,
|
||||
exec_mode: instances > 1 ? 'cluster' : 'fork',
|
||||
watch: process.env.NODE_ENV !== 'production',
|
||||
ignore_watch: ['generated', 'node_modules', '.git'],
|
||||
env: {
|
||||
...envFromFile,
|
||||
NODE_ENV: 'development'
|
||||
},
|
||||
env_production: {
|
||||
...envFromFile,
|
||||
NODE_ENV: 'production'
|
||||
},
|
||||
time: true
|
||||
}
|
||||
]
|
||||
};
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
84
prisma/migrations/20251019082632_init/migration.sql
Normal file
84
prisma/migrations/20251019082632_init/migration.sql
Normal file
@ -0,0 +1,84 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Author" (
|
||||
"sec_uid" TEXT NOT NULL,
|
||||
"uid" TEXT,
|
||||
"nickname" TEXT NOT NULL,
|
||||
"signature" TEXT,
|
||||
"avatar_url" TEXT,
|
||||
"follower_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"total_favorited" BIGINT NOT NULL DEFAULT 0,
|
||||
"unique_id" TEXT,
|
||||
"short_id" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Author_pkey" PRIMARY KEY ("sec_uid")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Video" (
|
||||
"aweme_id" TEXT NOT NULL,
|
||||
"desc" TEXT NOT NULL,
|
||||
"preview_title" TEXT,
|
||||
"duration_ms" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL,
|
||||
"share_url" TEXT NOT NULL,
|
||||
"digg_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"comment_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"share_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"collect_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Video_pkey" PRIMARY KEY ("aweme_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CommentUser" (
|
||||
"id" TEXT NOT NULL,
|
||||
"nickname" TEXT NOT NULL,
|
||||
"avatar_url" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CommentUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Comment" (
|
||||
"cid" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"digg_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL,
|
||||
"videoId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Comment_pkey" PRIMARY KEY ("cid")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Author_uid_key" ON "Author"("uid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Video_authorId_idx" ON "Video"("authorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Video_created_at_idx" ON "Video"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CommentUser_nickname_avatar_url_key" ON "CommentUser"("nickname", "avatar_url");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_videoId_created_at_idx" ON "Comment"("videoId", "created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Video" ADD CONSTRAINT "Video_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "CommentUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `video_url` to the `Video` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Video" ADD COLUMN "tags" TEXT[],
|
||||
ADD COLUMN "video_url" TEXT NOT NULL;
|
||||
@ -0,0 +1,50 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImagePost" (
|
||||
"aweme_id" TEXT NOT NULL,
|
||||
"desc" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL,
|
||||
"share_url" TEXT NOT NULL,
|
||||
"digg_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"comment_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"share_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"collect_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"tags" TEXT[],
|
||||
"music_url" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ImagePost_pkey" PRIMARY KEY ("aweme_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImageFile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"postId" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL,
|
||||
"width" INTEGER,
|
||||
"height" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ImageFile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImagePost_authorId_idx" ON "ImagePost"("authorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImagePost_created_at_idx" ON "ImagePost"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImageFile_postId_order_idx" ON "ImageFile"("postId", "order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ImageFile_postId_order_key" ON "ImageFile"("postId", "order");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImagePost" ADD CONSTRAINT "ImagePost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImageFile" ADD CONSTRAINT "ImageFile_postId_fkey" FOREIGN KEY ("postId") REFERENCES "ImagePost"("aweme_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
15
prisma/migrations/20251019113302_add/migration.sql
Normal file
15
prisma/migrations/20251019113302_add/migration.sql
Normal file
@ -0,0 +1,15 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_videoId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Comment" ADD COLUMN "imagePostId" TEXT,
|
||||
ALTER COLUMN "videoId" DROP NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_imagePostId_created_at_idx" ON "Comment"("imagePostId", "created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_imagePostId_fkey" FOREIGN KEY ("imagePostId") REFERENCES "ImagePost"("aweme_id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
3
prisma/migrations/20251019120440_add/migration.sql
Normal file
3
prisma/migrations/20251019120440_add/migration.sql
Normal file
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Video" ADD COLUMN "height" INTEGER,
|
||||
ADD COLUMN "width" INTEGER;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Video" ADD COLUMN "cover_url" TEXT;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
142
prisma/schema.prisma
Normal file
142
prisma/schema.prisma
Normal file
@ -0,0 +1,142 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Author {
|
||||
// 抖音作者;以 sec_uid 稳定标识
|
||||
sec_uid String @id
|
||||
uid String? @unique
|
||||
nickname String
|
||||
signature String?
|
||||
avatar_url String?
|
||||
follower_count BigInt @default(0)
|
||||
total_favorited BigInt @default(0)
|
||||
unique_id String? // 抖音号
|
||||
short_id String?
|
||||
videos Video[]
|
||||
imagePosts ImagePost[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Video {
|
||||
aweme_id String @id
|
||||
desc String
|
||||
preview_title String?
|
||||
duration_ms Int
|
||||
created_at DateTime
|
||||
share_url String
|
||||
|
||||
digg_count BigInt @default(0)
|
||||
comment_count BigInt @default(0)
|
||||
share_count BigInt @default(0)
|
||||
collect_count BigInt @default(0)
|
||||
|
||||
// 视频分辨率(用于前端预布局)
|
||||
width Int?
|
||||
height Int?
|
||||
|
||||
// 视频封面(首帧提取后上传到 MinIO 的外链)
|
||||
cover_url String?
|
||||
|
||||
authorId String
|
||||
author Author @relation(fields: [authorId], references: [sec_uid])
|
||||
|
||||
comments Comment[]
|
||||
|
||||
tags String[] // 视频标签列表
|
||||
video_url String // 视频文件 URL
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([authorId])
|
||||
@@index([created_at])
|
||||
}
|
||||
|
||||
model CommentUser {
|
||||
id String @id @default(cuid())
|
||||
nickname String
|
||||
avatar_url String?
|
||||
|
||||
// 以 (nickname, avatar_url) 近似去重;如果你从响应里拿到用户 uid,可以改为以 uid 作为主键
|
||||
@@unique([nickname, avatar_url])
|
||||
|
||||
comments Comment[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Comment {
|
||||
cid String @id
|
||||
text String
|
||||
digg_count BigInt @default(0)
|
||||
created_at DateTime
|
||||
|
||||
// 可关联视频或图文中的一种
|
||||
videoId String?
|
||||
video Video? @relation(fields: [videoId], references: [aweme_id])
|
||||
|
||||
imagePostId String?
|
||||
imagePost ImagePost? @relation(fields: [imagePostId], references: [aweme_id])
|
||||
|
||||
userId String
|
||||
user CommentUser @relation(fields: [userId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([videoId, created_at])
|
||||
@@index([imagePostId, created_at])
|
||||
}
|
||||
|
||||
// 图片作品(图文)
|
||||
model ImagePost {
|
||||
aweme_id String @id
|
||||
desc String
|
||||
created_at DateTime
|
||||
share_url String
|
||||
|
||||
digg_count BigInt @default(0)
|
||||
comment_count BigInt @default(0)
|
||||
share_count BigInt @default(0)
|
||||
collect_count BigInt @default(0)
|
||||
|
||||
authorId String
|
||||
author Author @relation(fields: [authorId], references: [sec_uid])
|
||||
|
||||
tags String[]
|
||||
music_url String? // 背景音乐(已上传到 MinIO 的外链)
|
||||
|
||||
images ImageFile[]
|
||||
comments Comment[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([authorId])
|
||||
@@index([created_at])
|
||||
}
|
||||
|
||||
// 图片作品中的单张图片(已上传到 MinIO 的外链)
|
||||
model ImageFile {
|
||||
id String @id @default(cuid())
|
||||
postId String
|
||||
post ImagePost @relation(fields: [postId], references: [aweme_id])
|
||||
url String
|
||||
order Int // 在作品中的顺序(从 0 开始)
|
||||
width Int?
|
||||
height Int?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([postId, order])
|
||||
@@unique([postId, order])
|
||||
}
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
36
test.ts
Normal file
36
test.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { createWriteStream, writeFileSync } from "node:fs";
|
||||
|
||||
initBucket()
|
||||
|
||||
const response = await fetch("https://v3-web.douyinvod.com/95ee4b6a1f064d04653f506024e3d493/68f4daca/video/tos/cn/tos-cn-ve-15c000-ce/oojxjQ2XsXPPgPniGM4kz8M3BSEahiGNcA0AI/?a=6383\\u0026ch=26\\u0026cr=3\\u0026dr=0\\u0026lr=all\\u0026cd=0%7C0%7C0%7C3\\u0026cv=1\\u0026br=6662\\u0026bt=6662\\u0026cs=2\\u0026ds=10\\u0026ft=khyHAB1UiiuGzJrZ~~OC~49Zyo3nOz7HQNaLpMyC6LZjrKQ2B22E1J6kcKb2oPd.o~\\u0026mime_type=video_mp4\\u0026qs=15\\u0026rc=ODNoPGk2MzRnNDg7NDdnNUBpM3ZzbXA5cjNyNjMzbGkzNEAzMy41MzU1XzMxNTY2Ll9jYSNmYy1tMmQ0b2xhLS1kLWJzcw%3D%3D\\u0026btag=c0000e00028000\\u0026cquery=100w_100B_100x_100z_100o\\u0026dy_q=1760866258\\u0026feature_id=10cf95ef75b4f3e7eac623e4ea0ea691\\u0026l=20251019173058DF8022814E036CA4C097", {
|
||||
"headers": {
|
||||
"accept": "*/*",
|
||||
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||
"sec-ch-ua": "\"Microsoft Edge\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"",
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": "\"Windows\"",
|
||||
"sec-fetch-dest": "video",
|
||||
"sec-fetch-mode": "no-cors",
|
||||
"sec-fetch-site": "same-origin"
|
||||
},
|
||||
"referrer": "https://v3-web.douyinvod.com/95ee4b6a1f064d04653f506024e3d493/68f4daca/video/tos/cn/tos-cn-ve-15c000-ce/oojxjQ2XsXPPgPniGM4kz8M3BSEahiGNcA0AI/?a=6383\\u0026ch=26\\u0026cr=3\\u0026dr=0\\u0026lr=all\\u0026cd=0%7C0%7C0%7C3\\u0026cv=1\\u0026br=6662\\u0026bt=6662\\u0026cs=2\\u0026ds=10\\u0026ft=khyHAB1UiiuGzJrZ~~OC~49Zyo3nOz7HQNaLpMyC6LZjrKQ2B22E1J6kcKb2oPd.o~\\u0026mime_type=video_mp4\\u0026qs=15\\u0026rc=ODNoPGk2MzRnNDg7NDdnNUBpM3ZzbXA5cjNyNjMzbGkzNEAzMy41MzU1XzMxNTY2Ll9jYSNmYy1tMmQ0b2xhLS1kLWJzcw%3D%3D\\u0026btag=c0000e00028000\\u0026cquery=100w_100B_100x_100z_100o\\u0026dy_q=1760866258\\u0026feature_id=10cf95ef75b4f3e7eac623e4ea0ea691\\u0026l=20251019173058DF8022814E036CA4C097",
|
||||
"body": null,
|
||||
"method": "GET",
|
||||
"mode": "cors",
|
||||
"credentials": "omit"
|
||||
});
|
||||
|
||||
import { pipeline, Readable } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { initBucket } from "./lib/minio";
|
||||
const streamPipeline = promisify(pipeline);
|
||||
if(!response.body) {
|
||||
throw new Error("No response body");
|
||||
}
|
||||
|
||||
// 将 Web ReadableStream 转换为 Node.js Readable
|
||||
const nodeStream = Readable.fromWeb(response.body as any);
|
||||
const contentLength = response.headers.get('content-length');
|
||||
await uploadFileStream(nodeStream, 'test-video.mp4', Number(contentLength));
|
||||
|
||||
export { };
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user