import type { LocalMediaHit, SearchAction, TmdbCandidate } 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(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; 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 ( 搜索 搜索作品、TMDB 和页面;回车在资源中搜索 {open ? : null} ); } 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(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(() => { 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: ( 在资源中搜索 “{term}” ), 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 (
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 ? : null} Esc
{!term ? (

输入作品名、别名或页面名称。回车在资源中搜索。

) : ( <> {items.map((it, i) => { const head = it.group !== lastGroup ? it.group : null; lastGroup = it.group; return (
{head ? (

{head}

) : null} {/* biome-ignore lint/a11y/useKeyWithClickEvents: 键盘由输入框(combobox)的上下键与回车处理 */}
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]" : "", )} >
{it.title}
{it.meta ?
{it.meta}
: null}
{it.hint}
); })} {!stale && media.data && tmdb && !tmdb.configured ? (

TMDB 未配置,只搜本地作品。 去配置

) : null} {!stale && tmdb?.error ? (

TMDB 查询失败:{tmdb.error}

) : null} {media.isError || actions.isError ? (

{errorMessage(media.error ?? actions.error, "搜索失败")}

) : null} )}
↑↓ 选择 ↵ 打开 回车在资源中搜索
); } 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: ( {e.title} {h.matchedAlias && h.matchedAlias !== e.title ? ( · {h.matchedAlias} ) : null} ), meta: parts.join(" · "), hint: "打开", run, }; } 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, title: ( {c.title} {c.year ? ({c.year}) : null} ), 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, }; }