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,87 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PlaylistItem, PlaylistResponse } from "@/lib/playback-context";
|
||||
|
||||
// 侧栏列表(作者作品 / 搜索结果 / hashtag)缓存在模块里:切换作品时播放页会重新挂载,
|
||||
// 但客户端导航不会清掉 JS 内存,所以在列表里上下切换时可以立刻拿到列表,不用每次重新请求。
|
||||
const STALE_MS = 60_000;
|
||||
|
||||
type CacheEntry = PlaylistResponse & { fetchedAt: number };
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inflight = new Map<string, Promise<PlaylistResponse>>();
|
||||
|
||||
function loadPlaylist(query: string): Promise<PlaylistResponse> {
|
||||
const pending = inflight.get(query);
|
||||
if (pending) return pending;
|
||||
const promise = fetch(`/api/playlist${query}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`加载列表失败 (${res.status})`);
|
||||
const data = (await res.json()) as PlaylistResponse;
|
||||
cache.set(query, { ...data, fetchedAt: Date.now() });
|
||||
return data;
|
||||
})
|
||||
.finally(() => inflight.delete(query));
|
||||
inflight.set(query, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export type PlaylistState = {
|
||||
items: PlaylistItem[] | null;
|
||||
/** 列表总数(可能多于 items:侧栏最多列出 500 个) */
|
||||
total: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按播放上下文(/api/playlist 的查询串,含 ?)取作品列表。
|
||||
* query 为空或 enabled 为 false 时不请求。
|
||||
*/
|
||||
export function usePlaylist(query: string, enabled: boolean): PlaylistState {
|
||||
const [state, setState] = useState<PlaylistState>(() => {
|
||||
const cached = query ? cache.get(query) : undefined;
|
||||
return {
|
||||
items: cached?.items ?? null,
|
||||
total: cached?.total ?? 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!query || !enabled) return;
|
||||
const cached = cache.get(query);
|
||||
setState({
|
||||
items: cached?.items ?? null,
|
||||
total: cached?.total ?? 0,
|
||||
loading: !cached,
|
||||
error: null,
|
||||
});
|
||||
if (cached && Date.now() - cached.fetchedAt < STALE_MS) return;
|
||||
|
||||
let cancelled = false;
|
||||
loadPlaylist(query)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
items: data.items,
|
||||
total: data.total,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "加载失败",
|
||||
}));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, enabled]);
|
||||
|
||||
return state;
|
||||
}
|
||||
Reference in New Issue
Block a user