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:
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user