douyin-archive/app/components/FeedMasonry.tsx
2025-11-29 21:56:38 +08:00

217 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
fetchUrl?: string;
};
export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/api/feed' }: 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;
}, []);
// 为避免 SSR 与客户端初次渲染不一致window 未定义导致服务端为 1 列,客户端首次渲染为多列),
// 这里将初始列数固定为 1待挂载后再根据窗口宽度更新。
const [columnCount, setColumnCount] = useState<number>(1);
useEffect(() => {
// 挂载后立即根据当前窗口宽度更新一次列数
setColumnCount(getColumnCount());
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 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}`);
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) => (
<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 href={`/aweme/${item.aweme_id}`} target="_blank" className="block relative w-full">
<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>
</Link>
<div className="flex items-center gap-2 p-3">
{item.author.sec_uid ? (
<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 ? (
<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>
)}
<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>
</article>
), []);
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>
</>
);
}