271 lines
9.6 KiB
TypeScript
271 lines
9.6 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|