Files
UHD_Media_Tool/apps/web/app/components/library/identity-entity-picker.tsx
T
rootandClaude Opus 5.5 ef73abe3df feat(web): W3-2 library, subscriptions and rules, preferences, decisions inbox, identity and metadata
- library list (kind/status/search, cursor paging) and detail: paths and roots, external identity
  mapping with lock, field-level metadata sources and locks, slot cards with rejection reasons,
  content units (provisional flag), downloads with verification; remap/lock/refresh/merge actions
- subscriptions: list/create/update, slot rules editor (hard/soft/upgrade/completion),
  policy revision draft -> preview -> publish (grant warning) -> rollback
- preferences: tag CRUD, disable/enable, suppression records and lift, history timeline
- inbox: grouped by entity and kind, evidence/suggestion/impact/preview, resolve options, SSE refresh
- identity: pool assign/dismiss/retry, external identity revisions and lock, title alias management
- all responses validated against contracts zod schemas

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 20:39:02 +08:00

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>
);
}