proteinea / src /app /chat /_components /DatasetSearchPanel.tsx
Mahmoud Eljendy
feat: Antibody Studio — AI-native antibody design workspace by Proteinea
30cc31a
"use client";
import { useCallback, useMemo, useState } from "react";
import {
searchDatasetsAPI,
searchDatasetSourceAPI,
importDatasetEntriesAPI,
} from "@/lib/api";
import type { Artifact, DatasetSearchResult } from "@/lib/types";
type SourceFilter = "all" | "sabdab" | "oas" | "imgt";
const SOURCE_LABELS: Record<string, string> = {
sabdab: "SAbDab",
oas: "OAS",
imgt: "IMGT",
fc: "Fc",
};
const DATA_TYPE_COLORS: Record<string, string> = {
structure: "bg-blue-100 text-blue-700",
sequence: "bg-emerald-100 text-emerald-700",
gene: "bg-purple-100 text-purple-700",
};
interface Props {
open: boolean;
onClose: () => void;
onImport: (artifacts: Artifact[]) => void;
}
export default function DatasetSearchPanel({ open, onClose, onImport }: Props) {
const [query, setQuery] = useState("");
const [source, setSource] = useState<SourceFilter>("all");
const [results, setResults] = useState<Record<string, DatasetSearchResult[]>>(
{},
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [importing, setImporting] = useState(false);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const search = useCallback(async () => {
if (!query.trim()) return;
setLoading(true);
setError(null);
setResults({});
setSelected(new Set());
try {
if (source === "all") {
const res = await searchDatasetsAPI({ query: query.trim() });
setResults(res.results);
} else {
const res = await searchDatasetSourceAPI(source, {
query: query.trim(),
});
setResults({ [source]: res });
}
} catch (e) {
setError(e instanceof Error ? e.message : "Search failed");
} finally {
setLoading(false);
}
}, [query, source]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
search();
}
},
[search],
);
const toggleSelect = useCallback((id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleCollapse = useCallback((src: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(src)) next.delete(src);
else next.add(src);
return next;
});
}, []);
const allResults = useMemo(() => {
const flat: DatasetSearchResult[] = [];
for (const items of Object.values(results)) {
flat.push(...items);
}
return flat;
}, [results]);
const handleImport = useCallback(async () => {
const entries = allResults
.filter((r) => selected.has(r.id))
.map((r) => ({ source: r.source, entry_id: r.id }));
if (entries.length === 0) return;
setImporting(true);
try {
const res = await importDatasetEntriesAPI(entries);
onImport(res.artifacts);
} catch (e) {
setError(e instanceof Error ? e.message : "Import failed");
} finally {
setImporting(false);
}
}, [allResults, selected, onImport]);
if (!open) return null;
const sourceKeys = Object.keys(results).filter(
(k) => results[k].length > 0,
);
const hasResults = sourceKeys.length > 0;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Search datasets"
data-testid="dataset-search-panel"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-[640px] max-w-[94vw] max-h-[85vh] bg-white rounded-xl border border-border shadow-xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-4 py-3 border-b border-border flex items-center gap-3">
<div>
<p className="text-xs font-semibold text-foreground">
Search Databases
</p>
<p className="text-[10px] text-muted-fg">
Query SAbDab, OAS, and IMGT for antibody data
</p>
</div>
<div className="flex-1" />
<button
onClick={onClose}
className="text-[11px] text-muted-fg hover:text-foreground"
aria-label="Close"
>
</button>
</div>
{/* Search bar */}
<div className="px-4 py-2 border-b border-border flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search antibody databases..."
data-testid="dataset-search-input"
className="flex-1 rounded-md border border-border bg-white px-3 py-1.5 text-xs focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20"
/>
<select
value={source}
onChange={(e) => setSource(e.target.value as SourceFilter)}
data-testid="dataset-source-filter"
className="rounded-md border border-border bg-white px-2 py-1.5 text-xs focus:outline-none focus:border-accent"
>
<option value="all">All Sources</option>
<option value="sabdab">SAbDab</option>
<option value="oas">OAS</option>
<option value="imgt">IMGT</option>
</select>
<button
onClick={search}
disabled={loading || !query.trim()}
className="px-3 py-1.5 rounded-md bg-accent text-white text-xs font-medium hover:bg-accent-hover disabled:opacity-30 transition-colors"
>
Search
</button>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{loading && (
<p className="text-[11px] text-muted-fg px-2 py-6 text-center">
Searching...
</p>
)}
{error && (
<p className="text-[11px] text-red-600 px-2 py-6 text-center">
{error}
</p>
)}
{!loading && !error && !hasResults && query && (
<p className="text-[11px] text-muted-fg px-2 py-6 text-center">
No results found.
</p>
)}
{!loading && !error && !query && !hasResults && (
<p className="text-[11px] text-muted-fg px-2 py-6 text-center">
Enter a query to search antibody databases.
</p>
)}
{sourceKeys.map((src) => (
<div key={src} className="mb-2">
<button
onClick={() => toggleCollapse(src)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left"
>
<svg
className={`w-3 h-3 text-muted-fg transition-transform ${
collapsed.has(src) ? "" : "rotate-90"
}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-fg">
{SOURCE_LABELS[src] ?? src}
</span>
<span className="text-[10px] text-muted-fg">
({results[src].length})
</span>
</button>
{!collapsed.has(src) && (
<ul className="space-y-0.5 px-1">
{results[src].map((r) => (
<li
key={r.id}
data-testid="dataset-result-row"
className="rounded-lg border border-border-subtle bg-white hover:border-accent/30 transition-colors"
>
<div className="flex items-start gap-2 px-3 py-2">
<input
type="checkbox"
checked={selected.has(r.id)}
onChange={() => toggleSelect(r.id)}
className="mt-0.5 shrink-0 accent-accent"
aria-label={`Select ${r.title}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-foreground truncate">
{r.title}
</span>
<span
className={`text-[9px] px-1.5 py-0.5 rounded-full font-medium ${
DATA_TYPE_COLORS[r.data_type] ??
"bg-gray-100 text-gray-600"
}`}
>
{r.data_type}
</span>
</div>
<p className="text-[11px] text-foreground-dim mt-0.5 leading-relaxed line-clamp-2">
{r.summary}
</p>
{r.organism && (
<p className="text-[10px] text-muted-fg mt-0.5">
{r.organism}
</p>
)}
</div>
</div>
</li>
))}
</ul>
)}
</div>
))}
</div>
{/* Footer with import */}
{hasResults && (
<div className="px-4 py-2 border-t border-border flex items-center justify-between">
<p className="text-[10px] text-muted-fg">
{selected.size} of {allResults.length} selected
</p>
<button
onClick={handleImport}
disabled={selected.size === 0 || importing}
data-testid="dataset-import-button"
className="px-3 py-1.5 rounded-md bg-accent text-white text-xs font-medium hover:bg-accent-hover disabled:opacity-30 transition-colors"
>
{importing ? "Importing..." : "Import Selected"}
</button>
</div>
)}
</div>
</div>
);
}