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
+24 -58
View File
@@ -1,15 +1,13 @@
import { X } from "lucide-react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { Comment, User } from "../types.ts";
import { CommentList } from "./CommentList";
interface CommentPanelProps {
/** 侧栏打开且停在「评论」标签页 */
open: boolean;
onClose: () => void;
author: User;
createdAt: string | Date;
awemeId: string;
mounted: boolean;
}
interface CommentsApiResponse {
@@ -21,16 +19,14 @@ interface CommentsApiResponse {
snapshot?: string | number;
}
// 侧栏「评论」标签页的内容(外壳与标签切换在 SidePanel 里)
export const CommentPanel = memo(function CommentPanel({
open,
onClose,
author,
createdAt,
awemeId,
mounted,
}: CommentPanelProps) {
const [comments, setComments] = useState<Comment[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [hasOpened, setHasOpened] = useState(open);
@@ -93,7 +89,6 @@ export const CommentPanel = memo(function CommentPanel({
return deduped;
});
setTotal(typeof data.total === "number" ? data.total : 0);
const nextHasMore = Boolean(data.hasMore);
hasMoreRef.current = nextHasMore;
setHasMore(nextHasMore);
@@ -131,7 +126,6 @@ export const CommentPanel = memo(function CommentPanel({
hasMoreRef.current = true;
rankParamsRef.current = null;
setComments([]);
setTotal(0);
setHasMore(true);
setLoading(false);
@@ -174,57 +168,29 @@ export const CommentPanel = memo(function CommentPanel({
}, [open, hasMore, loadComments]);
return (
<aside
className={`
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white overflow-hidden
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)] border-t border-white/10
landscape:relative landscape:inset-auto landscape:h-full landscape:translate-y-0 landscape:border-t-0
${mounted ? "transition-transform landscape:transition-[width] duration-200 ease-out" : ""}
${
open
? "translate-y-0 pointer-events-auto landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10"
: "translate-y-full pointer-events-none landscape:w-0 landscape:border-l-0"
}
`}
>
<div className="flex items-center justify-between landscape:flex-row-reverse px-3 py-3 border-b border-white/10 shrink-0">
<div className="text-white font-semibold">
评论 {total > 0 ? `(${total})` : ""}
</div>
<button
type="button"
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors cursor-pointer"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
</div>
<div ref={scrollRef} className="p-3 overflow-auto comment-scroll flex-1">
{(open || hasOpened) && (
<>
<CommentList
author={author}
createdAt={createdAt}
comments={comments}
/>
<div ref={scrollRef} className="p-3 overflow-auto comment-scroll flex-1">
{(open || hasOpened) && (
<>
<CommentList
author={author}
createdAt={createdAt}
comments={comments}
/>
{hasMore && <div ref={sentinelRef} className="h-1 w-full" />}
{hasMore && <div ref={sentinelRef} className="h-1 w-full" />}
{loading && (
<div className="py-4 text-center text-white/60 text-sm">
加载中...
</div>
)}
{!hasMore && comments.length > 0 && (
<div className="py-4 text-center text-white/40 text-sm">
没有更多评论了
</div>
)}
</>
)}
</div>
</aside>
{loading && (
<div className="py-4 text-center text-white/60 text-sm">
加载中...
</div>
)}
{!hasMore && comments.length > 0 && (
<div className="py-4 text-center text-white/40 text-sm">
没有更多评论了
</div>
)}
</>
)}
</div>
);
});