321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { type SearchAction, type SearchWork, tmdbImageUrl } from "@umt/contracts";
|
|
import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, 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 } from "./search-labels.ts";
|
|
import {
|
|
isSearchShortcut,
|
|
openSearchPalette,
|
|
searchPagePath,
|
|
setSearchPaletteOpen,
|
|
useSearchPalette,
|
|
} from "./search-store.ts";
|
|
import { inventoryBadge, workHref } from "./work-card.tsx";
|
|
|
|
// 命令面板(Ctrl/⌘+K):输入即查作品(本地 + TMDB 合并,带库内状态)/ 页面与操作;选中作品打开作品详情页。
|
|
// 面板里不订阅、不查站点;最后一项进搜索结果页。
|
|
|
|
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">搜索作品和页面;选中作品打开详情</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 [active, setActive] = useState(0);
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
const close = () => setSearchPaletteOpen(false);
|
|
const go = (to: string) => {
|
|
close();
|
|
navigate(to);
|
|
};
|
|
|
|
const stale = q.trim() !== term;
|
|
const works = term ? (media.data?.media?.works ?? []) : [];
|
|
const tmdb = term ? media.data?.media?.tmdb : undefined;
|
|
const acts = term ? (actions.data?.actions?.items ?? []) : [];
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go 每次渲染新建)
|
|
const items = useMemo<Item[]>(() => {
|
|
const out: Item[] = [];
|
|
for (const w of works) out.push(workItem(w, () => go(workHref(w))));
|
|
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: "作品卡上可以选站点搜资源",
|
|
hint: "打开",
|
|
run: () => go(searchPagePath(term)),
|
|
});
|
|
return out;
|
|
}, [works, 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="搜索作品、页面"
|
|
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 workItem(w: SearchWork, run: () => void): Item {
|
|
const inv = inventoryBadge(w.inventory);
|
|
const parts = [
|
|
mediaKindLabel(w.kind),
|
|
w.year ? String(w.year) : null,
|
|
inv.label,
|
|
w.inventory.subscribed ? "已订阅" : null,
|
|
];
|
|
return {
|
|
key: `work-${w.key}`,
|
|
group: "作品",
|
|
icon: w.localEntityId ? Film : Globe,
|
|
posterPath: w.posterPath,
|
|
title: (
|
|
<span>
|
|
{w.title}
|
|
{w.originalTitle && w.originalTitle !== w.title ? (
|
|
<span className="ml-1.5 text-muted-foreground">· {w.originalTitle}</span>
|
|
) : null}
|
|
</span>
|
|
),
|
|
meta: parts.filter(Boolean).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 actionItem(a: SearchAction, run: () => void): Item {
|
|
return {
|
|
key: `action-${a.id}`,
|
|
group: "页面与操作",
|
|
icon: actionIcon(a.kind),
|
|
title: a.title,
|
|
meta: a.group,
|
|
hint: "前往",
|
|
run,
|
|
};
|
|
}
|