Files
UHD_Media_Tool/apps/web/app/components/search/command-palette.tsx
T
rootandClaude Opus 5.5 7862573944 feat(cache): TMDB image proxy, posters, cache admin API and settings cards
GET /img/tmdb/{size}/{file} ported from MP ImageHelper / fetch_image
(sanitized cache path under DATA_DIR/cache/images, GLOBAL_IMAGE_CACHE
read switch, md5 ETag / 304, day-based cleanup job). Cache module adds
cache.sweep / cache.images_clean jobs and /cache/tmdb, /cache/images
list and clear endpoints. Library list/detail and the search palette
show posters with the neutral placeholder as fallback; metadata settings
get TMDB cache and image cache cards. Runbook docs/runbook/cache.md and
porting notes docs/porting/moviepilot-tmdb-cache.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 22:26:29 +08:00

341 lines
12 KiB
TypeScript

import { type LocalMediaHit, type SearchAction, type TmdbCandidate, tmdbImageUrl } from "@umt/contracts";
import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Plus, Search } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router";
import { useSearch } from "~/api/search.ts";
import { settingsPath } from "~/components/console/nav.ts";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts";
import { useSubscribeTmdb } from "./search-sections.tsx";
import {
isSearchShortcut,
openSearchPalette,
searchPagePath,
setSearchPaletteOpen,
useSearchPalette,
} from "./search-store.ts";
// 命令面板(Ctrl/⌘+K):输入即查本地作品 / TMDB 候选 / 页面与操作,最后一项跳资源结果页。
// 资源组最慢要等 15s,面板里不查,回车进结果页。
function useDebounced<T>(v: T, ms: number): T {
const [d, setD] = useState(v);
useEffect(() => {
const t = setTimeout(() => setD(v), ms);
return () => clearTimeout(t);
}, [v, ms]);
return d;
}
type Item = {
key: string;
group: string;
icon: LucideIcon;
/** TMDB 海报路径:有则以小海报代替图标(失败回退图标) */
posterPath?: string | null | undefined;
title: ReactNode;
meta?: ReactNode;
/** 右侧动作提示 */
hint: string;
run: () => void;
};
/** 全局快捷键 Ctrl/⌘+K:切换面板(输入框里同样生效) */
export function useSearchHotkey() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.repeat || !isSearchShortcut(e)) return;
e.preventDefault();
openSearchPalette();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}
export function CommandPalette() {
useSearchHotkey();
const { open, initial } = useSearchPalette();
return (
<Dialog open={open} onOpenChange={setSearchPaletteOpen}>
<DialogContent
showCloseButton={false}
className={cn(
"top-4 flex translate-y-0 flex-col gap-0 overflow-hidden p-0 sm:top-[12vh] sm:max-w-xl",
"max-sm:inset-0 max-sm:max-w-none max-sm:translate-x-0 max-sm:rounded-none",
)}
>
<DialogTitle className="sr-only">搜索</DialogTitle>
<DialogDescription className="sr-only">搜索作品、TMDB 和页面;回车在资源中搜索</DialogDescription>
{open ? <PaletteBody initial={initial} /> : null}
</DialogContent>
</Dialog>
);
}
function PaletteBody({ initial }: { initial: string }) {
const navigate = useNavigate();
const listId = useId();
const [q, setQ] = useState(initial);
const term = useDebounced(q.trim(), 150);
const media = useSearch(term, "media", 6);
const actions = useSearch(term, "actions", 6);
const tmdbSub = useSubscribeTmdb();
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const close = () => setSearchPaletteOpen(false);
const go = (to: string) => {
close();
navigate(to);
};
const subscribe = (c: TmdbCandidate) => tmdbSub.subscribe(c, close);
const stale = q.trim() !== term;
const local = term ? (media.data?.media?.local.items ?? []) : [];
const tmdb = term ? media.data?.media?.tmdb : undefined;
const acts = term ? (actions.data?.actions?.items ?? []) : [];
// biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go/subscribe 每次渲染新建)
const items = useMemo<Item[]>(() => {
const out: Item[] = [];
for (const h of local) out.push(localItem(h, () => go(`/console/library/${h.entity.id}`)));
for (const c of tmdb?.items ?? [])
out.push(tmdbItem(c, () => go(`/console/library/${c.localEntityId}`), subscribe));
for (const a of acts) out.push(actionItem(a, () => go(a.href)));
if (term)
out.push({
key: "releases",
group: "资源",
icon: Search,
title: (
<span>
在资源中搜索 <span className="font-medium">“{term}”</span>
</span>
),
meta: "查询全部已启用站点,最慢约 15 秒",
hint: "打开",
run: () => go(searchPagePath(term)),
});
return out;
}, [local, tmdb, acts, term]);
// biome-ignore lint/correctness/useExhaustiveDependencies: 结果变化时高亮回到第一项
useEffect(() => setActive(0), [items.length, term]);
useEffect(() => {
listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
}, [active]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.nativeEvent.isComposing) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => (items.length ? (i + 1) % items.length : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => (items.length ? (i - 1 + items.length) % items.length : 0));
} else if (e.key === "Enter") {
e.preventDefault();
const it = items[active];
if (it && !stale) it.run();
else if (q.trim()) go(searchPagePath(q));
}
};
const loading = !!term && (media.isFetching || actions.isFetching);
const activeKey = items[active]?.key;
let lastGroup = "";
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-border/70 px-4">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
// biome-ignore lint/a11y/noAutofocus: 命令面板打开即输入
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
placeholder="搜索作品、TMDB、页面"
role="combobox"
aria-expanded={items.length > 0}
aria-controls={listId}
aria-activedescendant={activeKey ? `${listId}-${activeKey}` : undefined}
aria-autocomplete="list"
className="h-12 min-w-0 flex-1 bg-transparent text-body text-foreground outline-none placeholder:text-muted-foreground"
/>
{loading ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" /> : null}
<kbd className="hidden rounded-tight border border-border/70 px-1.5 text-label text-muted-foreground sm:inline">
Esc
</kbd>
<button
type="button"
onClick={close}
className="cursor-pointer text-caption text-muted-foreground hover:text-foreground sm:hidden"
>
取消
</button>
</div>
<div
ref={listRef}
id={listId}
role="listbox"
aria-label="搜索结果"
className="min-h-0 flex-1 overflow-y-auto p-2 sm:max-h-[60vh]"
>
{!term ? (
<p className="px-3 py-6 text-center text-caption text-muted-foreground">
输入作品名、别名或页面名称。回车在资源中搜索。
</p>
) : (
<>
{items.map((it, i) => {
const head = it.group !== lastGroup ? it.group : null;
lastGroup = it.group;
return (
<div key={it.key}>
{head ? (
<p className="px-3 pt-2 pb-1 text-micro font-semibold tracking-[0.12em] text-muted-foreground uppercase">
{head}
</p>
) : null}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: 键盘由输入框(combobox)的上下键与回车处理 */}
<div
id={`${listId}-${it.key}`}
role="option"
tabIndex={-1}
aria-selected={i === active}
data-index={i}
onMouseMove={() => i !== active && setActive(i)}
onClick={it.run}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 transition-colors duration-micro",
i === active ? "bg-foreground/[0.06]" : "",
)}
>
<ItemIcon it={it} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-foreground">{it.title}</div>
{it.meta ? <div className="truncate text-meta text-muted-foreground">{it.meta}</div> : null}
</div>
<span
className={cn(
"flex shrink-0 items-center gap-1 text-meta text-muted-foreground",
i !== active && "max-sm:hidden sm:invisible",
)}
>
{it.hint}
<CornerDownLeft className="h-3 w-3" />
</span>
</div>
</div>
);
})}
{!stale && media.data && tmdb && !tmdb.configured ? (
<p className="px-3 py-2 text-meta text-muted-foreground">
TMDB 未配置,只搜本地作品。
<Link to={settingsPath("metadata")} onClick={close} className="ml-1 underline underline-offset-4">
去配置
</Link>
</p>
) : null}
{!stale && tmdb?.error ? (
<p className="px-3 py-2 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
) : null}
{media.isError || actions.isError ? (
<p className="px-3 py-2 text-meta text-destructive">
{errorMessage(media.error ?? actions.error, "搜索失败")}
</p>
) : null}
</>
)}
</div>
<div className="hidden items-center gap-4 border-t border-border/70 px-4 py-2 text-meta text-muted-foreground sm:flex">
<span>↑↓ 选择</span>
<span>↵ 打开</span>
<span className="ml-auto inline-flex items-center gap-1">
<ArrowRight className="h-3 w-3" />
回车在资源中搜索
</span>
</div>
</div>
);
}
function localItem(h: LocalMediaHit, run: () => void): Item {
const e = h.entity;
const parts = [mediaKindLabel(e.kind), e.year ? String(e.year) : null, slotSummary(h)].filter(Boolean);
return {
key: `local-${e.id}`,
group: "本地作品",
icon: Film,
title: (
<span>
{e.title}
{h.matchedAlias && h.matchedAlias !== e.title ? (
<span className="ml-1.5 text-muted-foreground">· {h.matchedAlias}</span>
) : null}
</span>
),
meta: parts.join(" · "),
hint: "打开",
run,
};
}
function ItemIcon({ it }: { it: Item }) {
const [failed, setFailed] = useState(false);
const src = failed ? null : tmdbImageUrl(it.posterPath, "w92");
if (src) {
return (
<img
src={src}
alt=""
loading="lazy"
onError={() => setFailed(true)}
className="h-9 w-6 shrink-0 rounded-tight border border-border object-cover"
/>
);
}
return <it.icon className="h-4 w-4 shrink-0 text-muted-foreground" />;
}
function tmdbItem(c: TmdbCandidate, view: () => void, subscribe: (c: TmdbCandidate) => void): Item {
const local = !!c.localEntityId;
return {
key: `tmdb-${c.entityType}-${c.externalId}`,
group: "TMDB",
icon: local ? Globe : Plus,
posterPath: c.posterPath,
title: (
<span>
{c.title}
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
</span>
),
meta: [c.entityType === "movie" ? "电影" : "剧集", local ? "本地已有" : c.originalTitle, c.overview]
.filter(Boolean)
.join(" · "),
hint: local ? "查看" : "订阅",
run: local ? view : () => subscribe(c),
};
}
function actionItem(a: SearchAction, run: () => void): Item {
return {
key: `action-${a.id}`,
group: "页面与操作",
icon: actionIcon(a.kind),
title: a.title,
meta: a.group,
hint: "前往",
run,
};
}