Compare commits
No commits in common. "674c202264e3957189529209c70089066e20506f" and "823df2cbf60d07df5f2625131c68b435c6071c2e" have entirely different histories.
674c202264
...
823df2cbf6
@ -1,67 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
|
||||||
import { getFileUrl } from '@/lib/minio';
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ secUid: string }> }) {
|
|
||||||
const secUid = (await params).secUid;
|
|
||||||
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: {
|
|
||||||
authorId: secUid,
|
|
||||||
...(before ? { created_at: { lt: before } } : {})
|
|
||||||
},
|
|
||||||
orderBy: { created_at: 'desc' },
|
|
||||||
take: limit,
|
|
||||||
include: { author: true },
|
|
||||||
}),
|
|
||||||
prisma.imagePost.findMany({
|
|
||||||
where: {
|
|
||||||
authorId: secUid,
|
|
||||||
...(before ? { created_at: { lt: before } } : {})
|
|
||||||
},
|
|
||||||
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: getFileUrl(v.video_url),
|
|
||||||
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'),
|
|
||||||
width: v.width ?? null,
|
|
||||||
height: v.height ?? null,
|
|
||||||
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
|
||||||
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: getFileUrl(p.images?.[0]?.url ?? null),
|
|
||||||
width: p.images?.[0]?.width ?? null,
|
|
||||||
height: p.images?.[0]?.height ?? null,
|
|
||||||
author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid },
|
|
||||||
likes: Number(p.digg_count)
|
|
||||||
})),
|
|
||||||
].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
@ -41,7 +41,7 @@ export async function GET(req: NextRequest) {
|
|||||||
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'),
|
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'),
|
||||||
width: v.width ?? null,
|
width: v.width ?? null,
|
||||||
height: v.height ?? null,
|
height: v.height ?? null,
|
||||||
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? '') },
|
||||||
likes: Number(v.digg_count)
|
likes: Number(v.digg_count)
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => ({
|
||||||
@ -52,7 +52,7 @@ export async function GET(req: NextRequest) {
|
|||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? null,
|
height: p.images?.[0]?.height ?? null,
|
||||||
author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid },
|
author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? '') },
|
||||||
likes: Number(p.digg_count)
|
likes: Number(p.digg_count)
|
||||||
})),
|
})),
|
||||||
].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||||
|
|||||||
@ -6,12 +6,6 @@ import { downloadFile } from "@/lib/minio";
|
|||||||
import { extractAudio } from "../media";
|
import { extractAudio } from "../media";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResponseFormat } from "openai/helpers/zod";
|
import { zodResponseFormat } from "openai/helpers/zod";
|
||||||
import { ProxyAgent, setGlobalDispatcher } from "undici";
|
|
||||||
|
|
||||||
const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
|
||||||
if (proxy) {
|
|
||||||
setGlobalDispatcher(new ProxyAgent(proxy));
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
const client = new OpenAI({
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
@ -55,16 +49,14 @@ async function transcriptAudio(audio: Buffer | string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const data = completion.choices?.[0]?.message
|
const data = completion.choices?.[0]?.message
|
||||||
console.log("转写结果", data.content);
|
if (!data) {
|
||||||
|
|
||||||
if (!data || !data.content) {
|
|
||||||
throw new Error("No STT result returned from model");
|
throw new Error("No STT result returned from model");
|
||||||
}
|
}
|
||||||
try {
|
const parsed = SttSchema.safeParse(data?.content).data;
|
||||||
return JSON.parse(data.content) as SttResult;
|
if (!parsed) {
|
||||||
} catch (e) {
|
throw new Error("Failed to parse STT result with zod");
|
||||||
throw new Error("Failed to parse STT result JSON");
|
|
||||||
}
|
}
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function transcriptAweme(awemeId: string): Promise<SttResult> {
|
export async function transcriptAweme(awemeId: string): Promise<SttResult> {
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
export const runtime = "nodejs";
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
||||||
|
|||||||
@ -1,123 +0,0 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { getFileUrl } from "@/lib/minio";
|
|
||||||
import FeedMasonry from "@/app/components/FeedMasonry";
|
|
||||||
import BackButton from "@/app/components/BackButton";
|
|
||||||
import { FeedItem } from "@/app/types/feed";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
export default async function AuthorPage({ params }: { params: Promise<{ secUid: string }> }) {
|
|
||||||
const secUid = (await params).secUid;
|
|
||||||
|
|
||||||
const author = await prisma.author.findUnique({
|
|
||||||
where: { sec_uid: secUid },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!author) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial feed fetch
|
|
||||||
const limit = 24;
|
|
||||||
const [videos, posts] = await Promise.all([
|
|
||||||
prisma.video.findMany({
|
|
||||||
where: { authorId: secUid },
|
|
||||||
orderBy: { created_at: 'desc' },
|
|
||||||
take: limit,
|
|
||||||
include: { author: true },
|
|
||||||
}),
|
|
||||||
prisma.imagePost.findMany({
|
|
||||||
where: { authorId: secUid },
|
|
||||||
orderBy: { created_at: 'desc' },
|
|
||||||
take: limit,
|
|
||||||
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const initialItems: FeedItem[] = [
|
|
||||||
...videos.map((v) => ({
|
|
||||||
type: "video" as const,
|
|
||||||
aweme_id: v.aweme_id,
|
|
||||||
created_at: v.created_at,
|
|
||||||
desc: v.desc,
|
|
||||||
video_url: getFileUrl(v.video_url),
|
|
||||||
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'),
|
|
||||||
width: v.width ?? null,
|
|
||||||
height: v.height ?? null,
|
|
||||||
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
|
||||||
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: getFileUrl(p.images?.[0]?.url ?? null),
|
|
||||||
width: p.images?.[0]?.width ?? null,
|
|
||||||
height: p.images?.[0]?.height ?? null,
|
|
||||||
author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid },
|
|
||||||
likes: Number(p.digg_count)
|
|
||||||
})),
|
|
||||||
].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
|
||||||
.slice(0, limit);
|
|
||||||
|
|
||||||
const initialCursor = initialItems.length > 0 ? new Date(initialItems[initialItems.length - 1].created_at as any).toISOString() : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-white dark:bg-black text-black dark:text-white">
|
|
||||||
<div className="sticky top-0 z-10 p-4 bg-white/80 dark:bg-black/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-800 flex items-center gap-4">
|
|
||||||
<BackButton />
|
|
||||||
<h1 className="text-lg font-bold truncate">{author.nickname}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
|
||||||
{/* Author Profile Header */}
|
|
||||||
<div className="flex flex-col md:flex-row items-center md:items-start gap-6 mb-12">
|
|
||||||
<div className="relative w-24 h-24 md:w-32 md:h-32 shrink-0">
|
|
||||||
<Image
|
|
||||||
src={getFileUrl(author.avatar_url || 'default-avatar.png')}
|
|
||||||
alt={author.nickname}
|
|
||||||
fill
|
|
||||||
className="rounded-full object-cover border-2 border-gray-200 dark:border-gray-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 text-center md:text-left space-y-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold">{author.nickname}</h2>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
抖音号:{author.unique_id || author.short_id || '未知'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{author.signature && (
|
|
||||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap max-w-2xl">
|
|
||||||
{author.signature}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center md:justify-start gap-6 text-sm">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="font-bold text-lg">{Number(author.total_favorited).toLocaleString()}</span>
|
|
||||||
<span className="text-gray-500">获赞</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="font-bold text-lg">{Number(author.follower_count).toLocaleString()}</span>
|
|
||||||
<span className="text-gray-500">粉丝</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-800 pt-8">
|
|
||||||
<h3 className="text-lg font-semibold mb-6">作品</h3>
|
|
||||||
<FeedMasonry
|
|
||||||
initialItems={initialItems}
|
|
||||||
initialCursor={initialCursor}
|
|
||||||
fetchUrl={`/api/author/${secUid}/feed`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -6,7 +6,7 @@ export const BackgroundCanvas = forwardRef<HTMLCanvasElement, BackgroundCanvasPr
|
|||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="fixed inset-0 w-full h-full -z-10 scale-110"
|
className="fixed inset-0 w-full h-full -z-10"
|
||||||
style={{ filter: "blur(40px)" }}
|
style={{ filter: "blur(40px)" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
ArrowDownUp,
|
ArrowDownUp,
|
||||||
Download,
|
Download,
|
||||||
@ -92,21 +91,9 @@ export function MediaControls({
|
|||||||
return (
|
return (
|
||||||
<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="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="flex items-center gap-2.5 mb-1 pointer-events-none">
|
<div className="pointer-events-none flex items-center gap-2.5 mb-1">
|
||||||
{author.sec_uid ? (
|
|
||||||
<Link
|
|
||||||
href={`/author/${author.sec_uid}`}
|
|
||||||
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
|
|
||||||
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">{author.nickname}</span>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
|
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
|
||||||
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span>
|
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span>
|
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span>
|
||||||
<span
|
<span
|
||||||
className="text-[11px] leading-tight text-white/95 drop-shadow"
|
className="text-[11px] leading-tight text-white/95 drop-shadow"
|
||||||
|
|||||||
@ -66,11 +66,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
|
|||||||
created_at: aweme!.created_at,
|
created_at: aweme!.created_at,
|
||||||
likesCount: Number(aweme!.digg_count),
|
likesCount: Number(aweme!.digg_count),
|
||||||
commentsCount,
|
commentsCount,
|
||||||
author: {
|
author: { nickname: aweme!.author.nickname, avatar_url: getFileUrl(aweme!.author.avatar_url || 'default-avatar.png') },
|
||||||
nickname: aweme!.author.nickname,
|
|
||||||
avatar_url: getFileUrl(aweme!.author.avatar_url || 'default-avatar.png'),
|
|
||||||
sec_uid: aweme!.author.sec_uid
|
|
||||||
},
|
|
||||||
...(() => {
|
...(() => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const aweme = video!
|
const aweme = video!
|
||||||
@ -127,7 +123,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
|
|||||||
const neighbors: { prev: { aweme_id: string } | null; next: { aweme_id: string } | null } = { prev: pickPrev, next: pickNext };
|
const neighbors: { prev: { aweme_id: string } | null; next: { aweme_id: string } | null } = { prev: pickPrev, next: pickNext };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen w-full">
|
<main className="min-h-screen w-full overflow-hidden">
|
||||||
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
|
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
|
||||||
<div className="fixed left-3 top-3 z-30">
|
<div className="fixed left-3 top-3 z-30">
|
||||||
<BackButton
|
<BackButton
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export type User = { nickname: string; avatar_url: string | null; sec_uid?: string };
|
export type User = { nickname: string; avatar_url: string | null };
|
||||||
|
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
cid: string;
|
cid: string;
|
||||||
|
|||||||
@ -8,10 +8,9 @@ import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
|||||||
type Props = {
|
type Props = {
|
||||||
initialItems: FeedItem[];
|
initialItems: FeedItem[];
|
||||||
initialCursor: string | null;
|
initialCursor: string | null;
|
||||||
fetchUrl?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/api/feed' }: Props) {
|
export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
||||||
// 哨兵与容器
|
// 哨兵与容器
|
||||||
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -92,8 +91,7 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (cursor) params.set('before', cursor);
|
if (cursor) params.set('before', cursor);
|
||||||
params.set('limit', '24');
|
params.set('limit', '24');
|
||||||
const url = fetchUrl.includes('?') ? `${fetchUrl}&${params.toString()}` : `${fetchUrl}?${params.toString()}`;
|
const res = await fetch(`/api/feed?${params.toString()}`, { cache: 'no-store' });
|
||||||
const res = await fetch(url, { cache: 'no-store' });
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data: FeedResponse = await res.json();
|
const data: FeedResponse = await res.json();
|
||||||
// 将新数据按最短列分配
|
// 将新数据按最短列分配
|
||||||
@ -139,8 +137,8 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
}, [fetchMore]);
|
}, [fetchMore]);
|
||||||
|
|
||||||
const renderCard = useCallback((item: FeedItem) => (
|
const renderCard = useCallback((item: FeedItem) => (
|
||||||
<article key={item.aweme_id} className="mb-4 group 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 hover:-translate-y-1">
|
<Link key={item.aweme_id} href={`/aweme/${item.aweme_id}`} target="_blank" className="mb-4 block group">
|
||||||
<Link href={`/aweme/${item.aweme_id}`} target="_blank" className="block relative w-full">
|
<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
|
<div
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
style={{ aspectRatio: `${(item.width && item.height) ? `${item.width}/${item.height}` : ''}` as any }}
|
style={{ aspectRatio: `${(item.width && item.height) ? `${item.width}/${item.height}` : ''}` as any }}
|
||||||
@ -169,33 +167,18 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 p-3">
|
<div className="flex items-center gap-2 p-3">
|
||||||
{item.author.sec_uid ? (
|
<div className="size-6 rounded-full overflow-hidden bg-zinc-200">
|
||||||
<Link href={`/author/${item.author.sec_uid}`} className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity">
|
|
||||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
|
||||||
{item.author.avatar_url ? (
|
{item.author.avatar_url ? (
|
||||||
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{item.author.nickname}</span>
|
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{item.author.nickname}</span>
|
||||||
</Link>
|
<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 className="flex items-center gap-2 min-w-0 flex-1">
|
|
||||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
|
||||||
{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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
|
|
||||||
{item.likes} <ThumbsUp size={16} style={{ color: 'var(--color-zinc-700)' }} />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
</Link>
|
||||||
), []);
|
), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export default function SearchBox() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="relative w-full">
|
<form onSubmit={handleSubmit} className="relative w-full max-w-2xl mb-8">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
|
|||||||
24
app/page.tsx
24
app/page.tsx
@ -5,8 +5,6 @@ import type { FeedItem } from "./types/feed";
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "作品集 - 抖歪",
|
title: "作品集 - 抖歪",
|
||||||
description: "抖歪作品集,记录当下时代的精彩瞬间",
|
description: "抖歪作品集,记录当下时代的精彩瞬间",
|
||||||
@ -36,7 +34,7 @@ export default async function Home() {
|
|||||||
cover_url: getFileUrl(v.cover_url ?? ''),
|
cover_url: getFileUrl(v.cover_url ?? ''),
|
||||||
width: v.width ?? null,
|
width: v.width ?? null,
|
||||||
height: v.height ?? null,
|
height: v.height ?? null,
|
||||||
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? '') },
|
||||||
likes: Number(v.digg_count)
|
likes: Number(v.digg_count)
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => ({
|
||||||
@ -47,7 +45,7 @@ export default async function Home() {
|
|||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? null,
|
height: p.images?.[0]?.height ?? null,
|
||||||
author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid },
|
author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? '') },
|
||||||
likes: Number(p.digg_count)
|
likes: Number(p.digg_count)
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
@ -55,29 +53,19 @@ export default async function Home() {
|
|||||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen w-full bg-gray-50 dark:bg-black transition-colors duration-300">
|
<main className="min-h-screen w-full px-4 py-8 md:py-12">
|
||||||
<div className="relative pt-16 pb-12 px-4 sm:px-6 lg:px-8 flex flex-col items-center">
|
<div className="flex flex-col items-center mb-8">
|
||||||
{/* 装饰背景 */}
|
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight mb-6">
|
||||||
<div className="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
|
作品集
|
||||||
<div className="absolute left-[50%] top-0 h-[40rem] w-[40rem] -translate-x-1/2 -translate-y-1/2 rounded-full bg-gradient-to-b from-indigo-500/20 to-transparent blur-3xl dark:from-indigo-500/10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-center mb-8 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400">
|
|
||||||
Douyin Archive
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="w-full max-w-xl mb-12">
|
|
||||||
<SearchBox />
|
<SearchBox />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full">
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const initial = feed.slice(0, 24);
|
const initial = feed.slice(0, 24);
|
||||||
const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null;
|
const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null;
|
||||||
return <FeedMasonry initialItems={initial} initialCursor={cursor} />;
|
return <FeedMasonry initialItems={initial} initialCursor={cursor} />;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export type FeedItem =
|
|||||||
type: "image";
|
type: "image";
|
||||||
}) & {
|
}) & {
|
||||||
likes: number;
|
likes: number;
|
||||||
author: { nickname: string; avatar_url: string | null; sec_uid?: string };
|
author: { nickname: string; avatar_url: string | null };
|
||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
created_at: Date | string;
|
created_at: Date | string;
|
||||||
desc: string;
|
desc: string;
|
||||||
|
|||||||
@ -18,14 +18,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
/* config options here */
|
/* config options here */
|
||||||
images: {
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 's3l.xn--876a.net',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
2491
package-lock.json
generated
2491
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -21,7 +21,6 @@
|
|||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"undici": "^7.16.0",
|
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user