- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing, full-width parens around Chinese, single ellipsis, 「」 quotes, period rules) - scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX text (not comments, types, keys, match/compare strings); --fix, --strict, --by-file; wired into deno task ci as check:copy in warn mode - --fix applied to web, server and contracts; files other lanes were editing (subscriptions, sites, browser, agent, auth/account, inventory) left for them - tests updated for the changed messages Co-Authored-By: Claude Opus 5.5 <[email protected]>
91 lines
3.5 KiB
TypeScript
91 lines
3.5 KiB
TypeScript
import type { MediaKind } from "@umt/contracts";
|
|
import { Check, Search, X } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { useEntitySearch } from "~/api/library.ts";
|
|
import { LoadingState } from "~/components/console/empty-state.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { Input } from "~/components/ui/input.tsx";
|
|
import { cn } from "~/lib/utils.ts";
|
|
import { MEDIA_KIND } from "./labels.ts";
|
|
|
|
export type PickedEntity = { id: string; title: string; year: number | null; kind: MediaKind };
|
|
|
|
/** 本地作品选择:输入关键字检索 media_entity,点选一项 */
|
|
export function EntityPicker({
|
|
value,
|
|
onChange,
|
|
placeholder = "搜索本地作品(标题/原名)",
|
|
id,
|
|
}: {
|
|
value: PickedEntity | null;
|
|
onChange: (v: PickedEntity | null) => void;
|
|
placeholder?: string;
|
|
id?: string;
|
|
}) {
|
|
const [q, setQ] = useState("");
|
|
const [debounced, setDebounced] = useState("");
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setDebounced(q), 250);
|
|
return () => clearTimeout(t);
|
|
}, [q]);
|
|
const search = useEntitySearch(debounced, { enabled: !value && debounced.trim().length > 0 });
|
|
|
|
if (value) {
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-border px-3 py-2">
|
|
<Check className="h-4 w-4 shrink-0 text-success" />
|
|
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
|
{value.title}
|
|
{value.year ? ` (${value.year})` : ""} · {MEDIA_KIND[value.kind]}
|
|
</span>
|
|
<Button type="button" variant="ghost" size="icon-sm" aria-label="清除选择" onClick={() => onChange(null)}>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
const items = search.data?.items ?? [];
|
|
return (
|
|
<div className="min-w-0 space-y-1.5">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input id={id} value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} className="pl-9" />
|
|
</div>
|
|
{debounced.trim() ? (
|
|
<div className="max-h-48 overflow-y-auto rounded-lg border border-border">
|
|
{search.isPending ? (
|
|
<LoadingState rows={2} className="p-2" />
|
|
) : search.isError ? (
|
|
<p className="p-3 text-meta text-destructive">{(search.error as Error).message}</p>
|
|
) : items.length === 0 ? (
|
|
<p className="p-3 text-meta text-muted-foreground">没有匹配的本地作品</p>
|
|
) : (
|
|
<ul>
|
|
{items.map((e) => (
|
|
<li key={e.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onChange({ id: e.id, title: e.title, year: e.year, kind: e.kind });
|
|
setQ("");
|
|
}}
|
|
className={cn(
|
|
"flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors duration-micro hover:bg-foreground/[0.05]",
|
|
)}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-foreground">
|
|
{e.title}
|
|
{e.year ? ` (${e.year})` : ""}
|
|
</span>
|
|
<span className="shrink-0 text-meta text-muted-foreground">{MEDIA_KIND[e.kind]}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|