Files
douyin-archive/app/components/FeedMasonry.tsx
T

443 lines
16 KiB
TypeScript

"use client";
import { Check, ThumbsUp, X } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import type { FeedItem, FeedResponse } from "@/app/types/feed";
import { ASR_CONTENT_TAGS } from "@/lib/asr-tags";
import HoverVideo from "./HoverVideo";
const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"];
function getAspectRatio(item: FeedItem) {
return item.width && item.height ? `${item.width}/${item.height}` : undefined;
}
type Props = {
initialItems: FeedItem[];
initialCursor: string | null;
fetchUrl?: string;
};
type TagUpdateResponse = {
updated?: {
aweme_id: string;
content_tags: 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 [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchTag, setBatchTag] = useState<string>(ASR_CONTENT_TAGS[0]);
const [batchSaving, setBatchSaving] = useState(false);
const [batchError, setBatchError] = useState<string | null>(null);
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 = item.content_tags?.length ? 72 : 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);
}, [columnCount, estimateItemHeight, initialItems]);
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,
fetchUrl,
]);
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 toggleSelection = useCallback((awemeId: string) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(awemeId)) next.delete(awemeId);
else next.add(awemeId);
return next;
});
setBatchError(null);
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
setBatchError(null);
}, []);
const applyUpdatedTags = useCallback(
(updates: TagUpdateResponse["updated"]) => {
if (!updates?.length) return;
const tagMap = new Map(
updates.map((item) => [item.aweme_id, item.content_tags] as const),
);
setColumns((currentColumns) =>
currentColumns.map((column) =>
column.map((item) =>
tagMap.has(item.aweme_id)
? { ...item, content_tags: tagMap.get(item.aweme_id) ?? [] }
: item,
),
),
);
},
[],
);
const handleBatchAddTag = useCallback(async () => {
const awemeIds = [...selectedIds];
if (awemeIds.length === 0 || batchSaving) return;
setBatchSaving(true);
setBatchError(null);
try {
const response = await fetch("/api/asr/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ awemeIds, tag: batchTag, mode: "add" }),
});
if (!response.ok) throw new Error("批量添加标签失败");
const data: TagUpdateResponse = await response.json();
applyUpdatedTags(data.updated);
clearSelection();
} catch (error) {
setBatchError(
error instanceof Error ? error.message : "批量添加标签失败",
);
} finally {
setBatchSaving(false);
}
}, [applyUpdatedTags, batchSaving, batchTag, clearSelection, selectedIds]);
const renderCard = useCallback(
(item: FeedItem) => {
const selected = selectedIds.has(item.aweme_id);
return (
<article
key={item.aweme_id}
className={`mb-4 group relative overflow-hidden rounded-2xl bg-white/80 shadow-sm backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1 dark:bg-zinc-900/60 ${
selected ? "ring-2 ring-blue-500" : "ring-1 ring-black/5"
}`}
>
{item.type === "video" && (
<button
type="button"
onClick={() => toggleSelection(item.aweme_id)}
className={`absolute left-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full border text-white shadow-lg backdrop-blur transition-colors ${
selected
? "border-blue-300 bg-blue-600"
: "border-white/25 bg-black/35 hover:bg-black/55"
}`}
aria-pressed={selected}
aria-label={selected ? "取消选择视频" : "选择视频"}
>
{selected && <Check size={17} />}
</button>
)}
<Link
href={`/aweme/${item.aweme_id}`}
target="_blank"
className="block relative w-full"
>
<div
className="relative w-full"
style={{
aspectRatio: getAspectRatio(item),
}}
>
{item.type === "video" ? (
<HoverVideo
videoUrl={item.video_url}
coverUrl={item.cover_url}
className="absolute inset-0 w-full h-full"
/>
) : (
<Image
fill
src={item.cover_url || "/placeholder.svg"}
alt={item.desc?.slice(0, 20) || "image"}
unoptimized
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">
<div className="min-w-0">
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
{item.desc}
</p>
{!!item.content_tags?.length && (
<div className="mt-2 flex flex-wrap gap-1">
{item.content_tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-blue-500/80 px-2 py-0.5 text-xs text-white shadow"
>
{tag}
</span>
))}
</div>
)}
</div>
<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 ? (
<Image
src={item.author.avatar_url}
alt="avatar"
width={24}
height={24}
unoptimized
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 ? (
<Image
src={item.author.avatar_url}
alt="avatar"
width={24}
height={24}
unoptimized
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>
);
},
[selectedIds, toggleSelection],
);
return (
<>
{/* Masonry(按列渲染,动态分配到最短列) */}
<div
ref={containerRef}
className="grid gap-4"
style={{
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
}}
>
{columns.map((col, idx) => (
<div key={COLUMN_KEYS[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>
{selectedIds.size > 0 && (
<div className="fixed bottom-4 left-1/2 z-50 w-[min(92vw,36rem)] -translate-x-1/2 rounded-2xl border border-white/15 bg-zinc-950/92 p-3 text-white shadow-2xl backdrop-blur-xl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">
已选择 {selectedIds.size} 个视频
</span>
{batchError && (
<span className="text-xs text-rose-300">{batchError}</span>
)}
</div>
<div className="flex items-center gap-2">
<select
value={batchTag}
onChange={(event) => setBatchTag(event.target.value)}
className="h-9 rounded-full border border-white/15 bg-white/10 px-3 text-sm text-white outline-none"
aria-label="选择要添加的标签"
>
{ASR_CONTENT_TAGS.map((tag) => (
<option
key={tag}
value={tag}
className="bg-zinc-900 text-white"
>
{tag}
</option>
))}
</select>
<button
type="button"
onClick={handleBatchAddTag}
disabled={batchSaving}
className="h-9 rounded-full bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
>
{batchSaving ? "添加中..." : "添加标签"}
</button>
<button
type="button"
onClick={clearSelection}
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-white/10 text-white/70 transition-colors hover:bg-white/15 hover:text-white"
aria-label="清空选择"
>
<X size={17} />
</button>
</div>
</div>
</div>
)}
</>
);
}