- 播放上下文:作品链接带上来源(搜索、标签、作者、hashtag),上一条/下一条 在该列表内移动;当前作品不在列表中时退回全部作品的发布时间顺序 - 顶部提示标签显示当前列表与位置,点击打开侧栏列表,✕ 退出 - 侧栏改为「评论 | TA的作品 | 搜索结果/#hashtag」标签页,作品封面网格可就地切换 - 作者模式:打开「TA的作品」进入,关闭侧栏不退出,点提示 ✕ 后回到原来的列表 - 从搜索结果进入或点 hashtag 时自动打开对应列表 - 描述中的 hashtag 可点击,按完整 hashtag 精确匹配(#丰川祥子 不含 #丰川祥子cos) - 新增 /api/playlist;搜索 SQL 抽到 lib/search-query.ts 与播放页共用,保证顺序一致 - 移动端:底部抽屉、点作品后收起,媒体区支持上下滑切换 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
5.3 KiB
TypeScript
150 lines
5.3 KiB
TypeScript
import { Heart, Images } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { memo, type ReactNode, useEffect, useRef } from "react";
|
|
import { awemeHref } from "@/lib/playback-context";
|
|
import type { PlaylistState } from "../hooks/usePlaylist";
|
|
|
|
interface WorksGridProps {
|
|
/** 所在标签页当前可见 */
|
|
active: boolean;
|
|
list: PlaylistState;
|
|
currentId: string;
|
|
/** 点击作品跳转时带上的播放上下文查询串 */
|
|
hrefQuery: string;
|
|
onSelect: () => void;
|
|
/** 列表只有当前这一个作品时的提示 */
|
|
singleHint?: string;
|
|
/** 网格上方的标题栏 */
|
|
header: ReactNode;
|
|
}
|
|
|
|
function formatCount(n: number): string {
|
|
if (n >= 10000) return `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}万`;
|
|
return String(n);
|
|
}
|
|
|
|
function PlayingBars() {
|
|
return (
|
|
<span className="flex h-4 items-end gap-0.5" aria-hidden>
|
|
{[60, 100, 40].map((height, i) => (
|
|
<span
|
|
key={height}
|
|
className="w-1 rounded-sm bg-white animate-pulse"
|
|
style={{ height: `${height}%`, animationDelay: `${i * 150}ms` }}
|
|
/>
|
|
))}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// 侧栏里的作品封面网格(TA的作品 / 搜索结果 / hashtag 共用):当前作品标「播放中」,点作品就地切换
|
|
export const WorksGrid = memo(function WorksGrid({
|
|
active,
|
|
list,
|
|
currentId,
|
|
hrefQuery,
|
|
onSelect,
|
|
singleHint,
|
|
header,
|
|
}: WorksGridProps) {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const currentRef = useRef<HTMLAnchorElement>(null);
|
|
const items = list.items ?? [];
|
|
|
|
// 让「播放中」的作品保持在可见区域(切换作品或列表加载完成时重新定位)
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: currentId / items.length 只用来触发重新定位
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
const container = scrollRef.current;
|
|
const el = currentRef.current;
|
|
if (!container || !el) return;
|
|
const top = el.offsetTop - container.offsetTop;
|
|
const bottom = top + el.offsetHeight;
|
|
if (
|
|
top < container.scrollTop ||
|
|
bottom > container.scrollTop + container.clientHeight
|
|
) {
|
|
container.scrollTop =
|
|
top - (container.clientHeight - el.offsetHeight) / 2;
|
|
}
|
|
}, [active, currentId, items.length]);
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<div className="flex items-center gap-3 px-3 py-3 border-b border-white/10 shrink-0">
|
|
{header}
|
|
</div>
|
|
|
|
<div ref={scrollRef} className="comment-scroll flex-1 overflow-auto p-2">
|
|
{list.loading && !list.items && (
|
|
<div className="py-10 text-center text-sm text-white/60">
|
|
加载中...
|
|
</div>
|
|
)}
|
|
{list.error && !list.items && (
|
|
<div className="py-10 text-center text-sm text-red-300/80">
|
|
{list.error}
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-3 gap-1.5">
|
|
{items.map((item) => {
|
|
const isCurrent = item.aweme_id === currentId;
|
|
return (
|
|
<Link
|
|
key={item.aweme_id}
|
|
ref={isCurrent ? currentRef : undefined}
|
|
href={awemeHref(item.aweme_id, hrefQuery)}
|
|
scroll={false}
|
|
onClick={onSelect}
|
|
title={item.desc}
|
|
aria-current={isCurrent ? "true" : undefined}
|
|
className={`relative block aspect-[3/4] overflow-hidden rounded-md bg-white/5 ${
|
|
isCurrent ? "ring-2 ring-rose-400" : "hover:opacity-90"
|
|
}`}
|
|
>
|
|
{item.cover_url ? (
|
|
// biome-ignore lint/performance/noImgElement: 封面来自对象存储,不走 next/image
|
|
<img
|
|
src={item.cover_url}
|
|
alt={item.desc?.slice(0, 20) || ""}
|
|
loading="lazy"
|
|
className="absolute inset-0 h-full w-full object-cover"
|
|
/>
|
|
) : null}
|
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
|
{item.type === "image" && (
|
|
<span className="absolute right-1 top-1 inline-flex items-center gap-0.5 rounded bg-black/55 px-1 py-0.5 text-[10px] text-white/90">
|
|
<Images size={10} />
|
|
图文
|
|
</span>
|
|
)}
|
|
{isCurrent ? (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/55 text-xs text-white">
|
|
<PlayingBars />
|
|
播放中
|
|
</div>
|
|
) : (
|
|
<span className="absolute bottom-1 left-1 inline-flex items-center gap-0.5 text-[11px] text-white/90 drop-shadow">
|
|
<Heart size={11} />
|
|
{formatCount(item.likes)}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
{list.items && items.length <= 1 && singleHint && (
|
|
<div className="py-6 text-center text-xs text-white/40">
|
|
{singleHint}
|
|
</div>
|
|
)}
|
|
{list.items && list.total > items.length && (
|
|
<div className="py-6 text-center text-xs text-white/40">
|
|
只列出前 {items.length} 个,共 {list.total} 个
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|