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,63 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import type { PlaylistState } from "../hooks/usePlaylist";
|
||||
import type { User } from "../types.ts";
|
||||
import { WorksGrid } from "./WorksGrid";
|
||||
|
||||
interface AuthorWorksTabProps {
|
||||
/** 侧栏打开且停在「TA的作品」标签页 */
|
||||
active: boolean;
|
||||
author: User;
|
||||
works: PlaylistState;
|
||||
currentId: string;
|
||||
/** 跳转时原样带上的播放上下文查询串 */
|
||||
contextQuery: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
// 侧栏「TA的作品」:作者在库里的全部作品。点作品就地切换;点作者名字才去独立的作者页。
|
||||
export const AuthorWorksTab = memo(function AuthorWorksTab({
|
||||
active,
|
||||
author,
|
||||
works,
|
||||
currentId,
|
||||
contextQuery,
|
||||
onSelect,
|
||||
}: AuthorWorksTabProps) {
|
||||
const header = author.sec_uid ? (
|
||||
<Link
|
||||
href={`/author/${author.sec_uid}`}
|
||||
className="group flex min-w-0 flex-1 items-center gap-2.5"
|
||||
title="打开作者主页"
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: 头像来自对象存储,不走 next/image */}
|
||||
<img
|
||||
src={author.avatar_url ?? ""}
|
||||
alt=""
|
||||
className="h-9 w-9 shrink-0 rounded-full bg-white/10 object-cover"
|
||||
/>
|
||||
<span className="truncate font-semibold text-white group-hover:underline">
|
||||
@{author.nickname}
|
||||
</span>
|
||||
<ChevronRight size={16} className="shrink-0 text-white/60" />
|
||||
<span className="ml-auto shrink-0 text-xs text-white/40">作者主页</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate font-semibold text-white">
|
||||
@{author.nickname}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<WorksGrid
|
||||
active={active}
|
||||
list={works}
|
||||
currentId={currentId}
|
||||
hrefQuery={contextQuery}
|
||||
onSelect={onSelect}
|
||||
singleHint="库里只存了这位作者的这一个作品"
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ChevronUp, ListVideo, X } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
|
||||
interface ContextChipProps {
|
||||
label: string;
|
||||
position: number;
|
||||
total: number;
|
||||
/** 提供时整个标签可点:展开 / 打开侧栏里的完整列表 */
|
||||
onExpand?: () => void;
|
||||
expandLabel?: string;
|
||||
onExit: () => void;
|
||||
exitLabel: string;
|
||||
}
|
||||
|
||||
// 顶部的模式提示:说明上下切换当前在哪个列表里移动,避免「为什么跳到这里了」的困惑
|
||||
export const ContextChip = memo(function ContextChip({
|
||||
label,
|
||||
position,
|
||||
total,
|
||||
onExpand,
|
||||
expandLabel,
|
||||
onExit,
|
||||
exitLabel,
|
||||
}: ContextChipProps) {
|
||||
const content = (
|
||||
<>
|
||||
<ListVideo size={14} className="shrink-0 text-rose-300" />
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="shrink-0 tabular-nums text-white/60">
|
||||
{position}/{total}
|
||||
</span>
|
||||
{onExpand && <ChevronUp size={14} className="shrink-0 text-white/70" />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute left-1/2 top-14 z-40 flex max-w-[calc(100%-1.5rem)] sm:top-3 sm:max-w-[calc(100%-14rem)] -translate-x-1/2 items-center rounded-full border border-white/20 bg-black/60 text-xs font-medium text-white shadow-lg backdrop-blur">
|
||||
{onExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
className="flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1 cursor-pointer"
|
||||
aria-label={expandLabel}
|
||||
title={expandLabel}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExit}
|
||||
className="mr-1 grid h-6 w-6 shrink-0 place-items-center rounded-full text-white/70 hover:bg-white/15 hover:text-white cursor-pointer"
|
||||
aria-label={exitLabel}
|
||||
title={exitLabel}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Hash, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import type { PlaylistState } from "../hooks/usePlaylist";
|
||||
import type { PlaybackContextInfo } from "../types.ts";
|
||||
import { WorksGrid } from "./WorksGrid";
|
||||
|
||||
interface ContextListTabProps {
|
||||
active: boolean;
|
||||
context: PlaybackContextInfo;
|
||||
list: PlaylistState;
|
||||
currentId: string;
|
||||
contextQuery: string;
|
||||
/** 对应的搜索结果页链接(搜索上下文才有) */
|
||||
searchPageHref: string | null;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致
|
||||
export const ContextListTab = memo(function ContextListTab({
|
||||
active,
|
||||
context,
|
||||
list,
|
||||
currentId,
|
||||
contextQuery,
|
||||
searchPageHref,
|
||||
onSelect,
|
||||
}: ContextListTabProps) {
|
||||
const Icon = context.kind === "hashtag" ? Hash : Search;
|
||||
const header = (
|
||||
<>
|
||||
<Icon size={18} className="shrink-0 text-rose-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-white">{context.label}</div>
|
||||
<div className="text-xs text-white/50">
|
||||
第 {context.position} 个 · 共 {context.total} 个
|
||||
</div>
|
||||
</div>
|
||||
{searchPageHref && (
|
||||
<Link
|
||||
href={searchPageHref}
|
||||
className="shrink-0 text-xs text-white/50 hover:text-white hover:underline"
|
||||
>
|
||||
在搜索页查看
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<WorksGrid
|
||||
active={active}
|
||||
list={list}
|
||||
currentId={currentId}
|
||||
hrefQuery={contextQuery}
|
||||
onSelect={onSelect}
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo, type ReactNode, type RefObject } from "react";
|
||||
import { memo, type ReactNode, type RefObject, useMemo } from "react";
|
||||
import { parseDescHashtags } from "@/lib/hashtag";
|
||||
import type { AbLoop, LoopMode, ObjectFit, User } from "../types.ts";
|
||||
import { formatAbsoluteUTC, formatRelativeTime, formatTime } from "../utils";
|
||||
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
|
||||
@@ -59,6 +60,10 @@ interface MediaControlsProps {
|
||||
onLoopModeChange: (mode: LoopMode) => void;
|
||||
onDownload: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
/** 点作者头像/名字:在侧栏打开「TA的作品」 */
|
||||
onAuthorClick?: () => void;
|
||||
/** 点描述里的 hashtag:切到该 hashtag 的作品列表 */
|
||||
onHashtagClick?: (name: string) => void;
|
||||
}
|
||||
|
||||
const CONTROL_BTN_CLASS =
|
||||
@@ -96,7 +101,10 @@ export const MediaControls = memo(function MediaControls({
|
||||
onLoopModeChange,
|
||||
onDownload,
|
||||
onToggleFullscreen,
|
||||
onAuthorClick,
|
||||
onHashtagClick,
|
||||
}: MediaControlsProps) {
|
||||
const descParts = useMemo(() => parseDescHashtags(desc || ""), [desc]);
|
||||
const normalizedLoopMode =
|
||||
isVideo && loopMode === "single" ? "loop" : loopMode;
|
||||
const loopLabel = isVideo
|
||||
@@ -129,10 +137,29 @@ export const MediaControls = memo(function MediaControls({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 right-0 bottom-0 z-40 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
|
||||
<div
|
||||
data-no-swipe
|
||||
className="absolute left-0 right-0 bottom-0 z-40 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5"
|
||||
>
|
||||
{/* 描述行 */}
|
||||
<div className="flex items-center gap-2.5 mb-1 pointer-events-none">
|
||||
{author.sec_uid ? (
|
||||
{author.sec_uid && onAuthorClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAuthorClick}
|
||||
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity cursor-pointer"
|
||||
title="查看TA的作品"
|
||||
>
|
||||
<img
|
||||
src={author.avatar_url ?? ""}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">
|
||||
{author.nickname}
|
||||
</span>
|
||||
</button>
|
||||
) : author.sec_uid ? (
|
||||
<Link
|
||||
href={`/author/${author.sec_uid}`}
|
||||
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
|
||||
@@ -172,7 +199,23 @@ export const MediaControls = memo(function MediaControls({
|
||||
{desc ? (
|
||||
<div className="pointer-events-none">
|
||||
<p className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
|
||||
{desc}
|
||||
{descParts.map((part, i) =>
|
||||
part.type === "hashtag" && onHashtagClick ? (
|
||||
<button
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => onHashtagClick(part.name)}
|
||||
className="pointer-events-auto font-semibold text-white hover:underline cursor-pointer"
|
||||
title={`查看所有带 #${part.name} 的作品`}
|
||||
>
|
||||
{part.text}
|
||||
</button>
|
||||
) : (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
|
||||
<span key={i}>{part.text}</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { X } from "lucide-react";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import type { SidePanelTab } from "../hooks/useSidePanelState";
|
||||
|
||||
export type SidePanelTabConfig = {
|
||||
key: SidePanelTab;
|
||||
label: string;
|
||||
count: number;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
interface SidePanelProps {
|
||||
open: boolean;
|
||||
mounted: boolean;
|
||||
tab: SidePanelTab;
|
||||
tabs: SidePanelTabConfig[];
|
||||
onTabChange: (tab: SidePanelTab) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 右侧(竖屏为底部抽屉)的侧栏外壳:评论 / TA的作品 / 搜索结果(或 hashtag)几个标签页
|
||||
export const SidePanel = memo(function SidePanel({
|
||||
open,
|
||||
mounted,
|
||||
tab,
|
||||
tabs,
|
||||
onTabChange,
|
||||
onClose,
|
||||
}: SidePanelProps) {
|
||||
const visibility = 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";
|
||||
// 竖屏时作品网格不需要那么高,给视频多留点位置
|
||||
const portraitHeight =
|
||||
tab === "comments" ? "h-[min(80vh,88dvh)]" : "h-[min(62vh,64dvh)]";
|
||||
|
||||
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 ${portraitHeight} border-t border-white/10 rounded-t-2xl
|
||||
landscape:relative landscape:inset-auto landscape:h-full landscape:translate-y-0 landscape:border-t-0 landscape:rounded-none
|
||||
${mounted ? "transition-transform landscape:transition-[width] duration-200 ease-out" : ""}
|
||||
${visibility}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-1 px-3 border-b border-white/10 shrink-0">
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-5 overflow-x-auto"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.key}
|
||||
onClick={() => onTabChange(t.key)}
|
||||
className={`relative shrink-0 max-w-[10em] truncate py-3 text-[15px] transition-colors cursor-pointer ${
|
||||
tab === t.key
|
||||
? "font-semibold text-white"
|
||||
: "text-white/55 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{t.count > 0 && (
|
||||
<span className="ml-1 text-xs font-normal text-white/50">
|
||||
{t.count}
|
||||
</span>
|
||||
)}
|
||||
{tab === t.key && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-rose-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-8 h-8 shrink-0 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>
|
||||
|
||||
{tabs.map((t) => (
|
||||
<div
|
||||
key={t.key}
|
||||
className={tab === t.key ? "flex min-h-0 flex-1 flex-col" : "hidden"}
|
||||
>
|
||||
{t.content}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
@@ -66,7 +66,10 @@ export const VideoTagEditor = memo(function VideoTagEditor({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 top-3 z-40 flex flex-col items-end gap-2">
|
||||
<div
|
||||
data-no-swipe
|
||||
className="absolute right-3 top-3 z-40 flex flex-col items-end gap-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
|
||||
@@ -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