作者主页

This commit is contained in:
feie9454 2025-11-29 21:56:38 +08:00
parent 63ed49e762
commit 674c202264
11 changed files with 279 additions and 37 deletions

View File

@ -0,0 +1,67 @@
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);
}

View File

@ -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 ?? '') }, author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
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 ?? '') }, author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid },
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))

View File

@ -0,0 +1,123 @@
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>
);
}

View File

@ -1,3 +1,4 @@
import Link from "next/link";
import { import {
ArrowDownUp, ArrowDownUp,
Download, Download,
@ -91,9 +92,21 @@ 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="pointer-events-none flex items-center gap-2.5 mb-1"> <div className="flex items-center gap-2.5 mb-1 pointer-events-none">
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" /> {author.sec_uid ? (
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span> <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" />
<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"

View File

@ -66,7 +66,11 @@ 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: { nickname: aweme!.author.nickname, avatar_url: getFileUrl(aweme!.author.avatar_url || 'default-avatar.png') }, author: {
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!

View File

@ -1,4 +1,4 @@
export type User = { nickname: string; avatar_url: string | null }; export type User = { nickname: string; avatar_url: string | null; sec_uid?: string };
export type Comment = { export type Comment = {
cid: string; cid: string;

View File

@ -8,9 +8,10 @@ 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 }: Props) { export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/api/feed' }: 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);
@ -91,7 +92,8 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
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 res = await fetch(`/api/feed?${params.toString()}`, { cache: 'no-store' }); const url = fetchUrl.includes('?') ? `${fetchUrl}&${params.toString()}` : `${fetchUrl}?${params.toString()}`;
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();
// 将新数据按最短列分配 // 将新数据按最短列分配
@ -137,8 +139,8 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
}, [fetchMore]); }, [fetchMore]);
const renderCard = useCallback((item: FeedItem) => ( const renderCard = useCallback((item: FeedItem) => (
<Link key={item.aweme_id} href={`/aweme/${item.aweme_id}`} target="_blank" className="mb-4 block group"> <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">
<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"> <Link href={`/aweme/${item.aweme_id}`} target="_blank" className="block relative w-full">
<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 }}
@ -167,18 +169,33 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
</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">
<div className="size-6 rounded-full overflow-hidden bg-zinc-200"> {item.author.sec_uid ? (
{item.author.avatar_url ? ( <Link href={`/author/${item.author.sec_uid}`} className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity">
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" /> <div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
) : null} {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>
</Link>
) : (
<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> </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)' }} /> <span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
</div> {item.likes} <ThumbsUp size={16} style={{ color: 'var(--color-zinc-700)' }} />
</article> </span>
</Link> </div>
</article>
), []); ), []);
return ( return (

View File

@ -16,7 +16,7 @@ export default function SearchBox() {
}; };
return ( return (
<form onSubmit={handleSubmit} className="relative w-full max-w-2xl mb-8"> <form onSubmit={handleSubmit} className="relative w-full">
<input <input
type="text" type="text"
value={query} value={query}

View File

@ -36,7 +36,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 ?? '') }, author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
likes: Number(v.digg_count) likes: Number(v.digg_count)
})), })),
...posts.map((p) => ({ ...posts.map((p) => ({
@ -47,7 +47,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 ?? '') }, author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid },
likes: Number(p.digg_count) likes: Number(p.digg_count)
})), })),
] ]
@ -55,19 +55,29 @@ 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 px-4 py-8 md:py-12"> <main className="min-h-screen w-full bg-gray-50 dark:bg-black transition-colors duration-300">
<div className="flex flex-col items-center mb-8"> <div className="relative pt-16 pb-12 px-4 sm:px-6 lg:px-8 flex flex-col items-center">
<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">
</h1> <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" />
<SearchBox /> </div>
</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">
const initial = feed.slice(0, 24); Douyin Archive
const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null; </h1>
return <FeedMasonry initialItems={initial} initialCursor={cursor} />;
})()} <div className="w-full max-w-xl mb-12">
<SearchBox />
</div>
<div className="w-full">
{(() => {
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} />;
})()}
</div>
</div>
</main> </main>
); );
} }

View File

@ -6,7 +6,7 @@ export type FeedItem =
type: "image"; type: "image";
}) & { }) & {
likes: number; likes: number;
author: { nickname: string; avatar_url: string | null }; author: { nickname: string; avatar_url: string | null; sec_uid?: string };
aweme_id: string; aweme_id: string;
created_at: Date | string; created_at: Date | string;
desc: string; desc: string;

View File

@ -18,6 +18,14 @@ const nextConfig: NextConfig = {
}, },
}, },
/* config options here */ /* config options here */
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 's3l.xn--876a.net',
},
],
},
}; };
export default nextConfig; export default nextConfig;