优化图文页

This commit is contained in:
2026-04-28 17:11:42 +08:00
parent 674c202264
commit 040273586b
69 changed files with 3939 additions and 2302 deletions
+138 -84
View File
@@ -1,9 +1,9 @@
"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';
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[];
@@ -11,7 +11,11 @@ type Props = {
fetchUrl?: string;
};
export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/api/feed' }: Props) {
export default function FeedMasonry({
initialItems,
initialCursor,
fetchUrl = "/api/feed",
}: Props) {
// 哨兵与容器
const [cursor, setCursor] = useState<string | null>(initialCursor);
const [loading, setLoading] = useState(false);
@@ -22,11 +26,11 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
// 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4
const getColumnCount = useCallback(() => {
if (typeof window === 'undefined') return 1;
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
if (w >= 640) return 2; // sm
return 1;
}, []);
// 为避免 SSR 与客户端初次渲染不一致(window 未定义导致服务端为 1 列,客户端首次渲染为多列),
@@ -37,8 +41,8 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
// 挂载后立即根据当前窗口宽度更新一次列数
setColumnCount(getColumnCount());
const onResize = () => setColumnCount(getColumnCount());
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [getColumnCount]);
// 估算卡片高度(用于分配到“最短列”)
@@ -46,8 +50,11 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
// 媒体区域高度
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 = Math.max(
80,
(Number(item.height) / Number(item.width)) * colWidth,
);
} else if (item.type === "video") {
mediaH = (9 / 16) * colWidth; // 常见视频比例
}
// 文本 + 作者栏的高度粗估
@@ -62,12 +69,15 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
return cols;
});
const [colHeights, setColHeights] = useState<number[]>(() => Array.from({ length: columnCount }, () => 0));
const [colHeights, setColHeights] = useState<number[]>(() =>
Array.from({ length: columnCount }, () => 0),
);
// 初始化与当列数变化时重排
useEffect(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth;
const colWidth =
columnCount > 0 ? containerWidth / columnCount : containerWidth;
// 用 initialItems 重排
const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
const newHeights: number[] = Array.from({ length: columnCount }, () => 0);
@@ -90,16 +100,19 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
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 (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 colWidth =
columnCount > 0 ? containerWidth / columnCount : containerWidth;
const cols = prevCols.map((c) => [...c]);
const heights = [...colHeights];
for (const item of data.items) {
@@ -117,7 +130,7 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
setCursor(data.nextCursor);
if (!data.nextCursor || data.items.length === 0) setEnded(true);
} catch (e) {
console.error('fetch more feed failed', e);
console.error("fetch more feed failed", e);
// 失败也不要死循环
setEnded(true);
} finally {
@@ -128,88 +141,129 @@ export default function FeedMasonry({ initialItems, initialCursor, 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' });
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 }}
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"
>
{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
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>
</div>
</Link>
</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 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="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>
), []);
)}
<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))` }}>
<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
ref={sentinelRef}
className="h-10 flex items-center justify-center text-sm text-zinc-500"
>
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
</div>
</>
);