feat: 播放页按来源列表切换,新增作者/搜索/hashtag 侧栏

- 播放上下文:作品链接带上来源(搜索、标签、作者、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>
This commit is contained in:
2026-11-09 00:50:09 +08:00
co-authored by Claude Opus 5.5
parent 182ca9cf05
commit b84fcec77b
28 changed files with 1592 additions and 282 deletions
@@ -0,0 +1,149 @@
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>
);
});