- 新增 /api/hashtag/related:从作品描述实时解析 hashtag,按「同一作品里一起出现」计算相关 tag - 打分取子话题(候选多与目标同现)与上位话题(目标多与热门候选同现)两者较大值, 过滤 cos、二次元等万金油 tag 与平台活动、纯数字 tag - 播放页侧栏的 hashtag 列表顶部显示相关 tag,点击在侧栏内预览(不打断当前播放), 在预览里点作品才切换到该 tag 的列表 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
4.2 KiB
TypeScript
122 lines
4.2 KiB
TypeScript
import { ArrowLeft, Hash, Search } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { memo, useState } from "react";
|
|
import { playbackContextQuery } from "@/lib/playback-context";
|
|
import { type PlaylistState, usePlaylist } from "../hooks/usePlaylist";
|
|
import { useRelatedHashtags } from "../hooks/useRelatedHashtags";
|
|
import type { PlaybackContextInfo } from "../types.ts";
|
|
import { WorksGrid } from "./WorksGrid";
|
|
|
|
interface ContextListTabProps {
|
|
active: boolean;
|
|
context: PlaybackContextInfo;
|
|
list: PlaylistState;
|
|
currentId: string;
|
|
contextQuery: string;
|
|
/** hashtag 上下文时的 tag 名(用来取相关 tag) */
|
|
hashtagName: string | null;
|
|
/** 对应的搜索结果页链接(搜索上下文才有) */
|
|
searchPageHref: string | null;
|
|
onSelect: () => void;
|
|
}
|
|
|
|
// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致。
|
|
// hashtag 列表上方显示相关 tag(按共现计算);点相关 tag 只在侧栏里预览它的作品,不打断当前播放,
|
|
// 在预览里点作品才切换到那个 tag 的列表。
|
|
export const ContextListTab = memo(function ContextListTab({
|
|
active,
|
|
context,
|
|
list,
|
|
currentId,
|
|
contextQuery,
|
|
hashtagName,
|
|
searchPageHref,
|
|
onSelect,
|
|
}: ContextListTabProps) {
|
|
// 正在预览的相关 tag(null 表示显示当前列表本身)
|
|
const [browsing, setBrowsing] = useState<string | null>(null);
|
|
const viewTag = browsing ?? hashtagName;
|
|
|
|
const related = useRelatedHashtags(viewTag, active);
|
|
const browseQuery = browsing
|
|
? playbackContextQuery({ kind: "hashtag", name: browsing })
|
|
: "";
|
|
const browseList = usePlaylist(browseQuery, active && Boolean(browsing));
|
|
|
|
const pickTag = (name: string) => {
|
|
setBrowsing(
|
|
name.toLowerCase() === hashtagName?.toLowerCase() ? null : name,
|
|
);
|
|
};
|
|
|
|
const Icon = context.kind === "hashtag" ? Hash : Search;
|
|
const header = browsing ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => setBrowsing(null)}
|
|
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/10 text-white/80 hover:bg-white/20 cursor-pointer"
|
|
aria-label={`返回 ${context.label}`}
|
|
title={`返回 ${context.label}`}
|
|
>
|
|
<ArrowLeft size={16} />
|
|
</button>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate font-semibold text-white">#{browsing}</div>
|
|
<div className="text-xs text-white/50">
|
|
{related ? `共 ${related.count} 个 · ` : ""}点作品切换到这个 tag
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<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>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
const relatedRow =
|
|
viewTag && related && related.related.length > 0 ? (
|
|
<div className="flex items-center gap-1.5 overflow-x-auto px-3 py-2 border-b border-white/10 comment-scroll">
|
|
<span className="shrink-0 text-xs text-white/40">相关</span>
|
|
{related.related.map((tag) => (
|
|
<button
|
|
key={tag.name}
|
|
type="button"
|
|
onClick={() => pickTag(tag.name)}
|
|
className="shrink-0 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:border-white/30 hover:text-white cursor-pointer"
|
|
title={`一起出现在 ${tag.together} 个作品里,#${tag.name} 共 ${tag.count} 个作品`}
|
|
>
|
|
#{tag.name}
|
|
<span className="ml-1 text-white/40">{tag.together}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<WorksGrid
|
|
active={active}
|
|
list={browsing ? browseList : list}
|
|
currentId={currentId}
|
|
hrefQuery={browsing ? browseQuery : contextQuery}
|
|
onSelect={onSelect}
|
|
header={header}
|
|
subheader={relatedRow}
|
|
/>
|
|
);
|
|
});
|