| | import { useState } from "react"; |
| | import type { Preset } from "../types"; |
| |
|
| | interface Props { |
| | store: { |
| | state: { |
| | datasets: { id: string; repo: string; name: string; split: string; n_instances: number }[]; |
| | presets: Preset[]; |
| | loading: boolean; |
| | }; |
| | loadDataset: (repo: string, split?: string) => Promise<void>; |
| | unloadDataset: (dsId: string) => Promise<void>; |
| | createPreset: (name: string, repo: string, split?: string) => Promise<void>; |
| | deletePreset: (id: string) => Promise<void>; |
| | loadPreset: (preset: Preset) => Promise<void>; |
| | }; |
| | } |
| |
|
| | export function Sidebar({ store }: Props) { |
| | const [repoInput, setRepoInput] = useState(""); |
| | const [splitInput, setSplitInput] = useState("train"); |
| | const [showAddForm, setShowAddForm] = useState(false); |
| | |
| | const [savingForDsId, setSavingForDsId] = useState<string | null>(null); |
| | const [presetName, setPresetName] = useState(""); |
| | const [presetSearch, setPresetSearch] = useState(""); |
| |
|
| | const handleLoad = async () => { |
| | const repo = repoInput.trim(); |
| | if (!repo) return; |
| | await store.loadDataset(repo, splitInput.trim() || "train"); |
| | setRepoInput(""); |
| | setShowAddForm(false); |
| | }; |
| |
|
| | const handleSavePreset = async (ds: { repo: string; split: string }) => { |
| | if (!presetName.trim()) return; |
| | await store.createPreset(presetName.trim(), ds.repo, ds.split); |
| | setPresetName(""); |
| | setSavingForDsId(null); |
| | }; |
| |
|
| | return ( |
| | <div className="w-72 bg-gray-900 border-r border-gray-800 flex flex-col h-full overflow-hidden"> |
| | {/* Header */} |
| | <div className="p-4 border-b border-gray-800"> |
| | <h1 className="text-lg font-semibold text-gray-100">Harbor Trace Viz</h1> |
| | <p className="text-xs text-gray-500 mt-1">Harbor agent trajectory viewer</p> |
| | </div> |
| | |
| | {/* Presets */} |
| | <div className="p-3 border-b border-gray-800"> |
| | <div className="flex items-center justify-between mb-2"> |
| | <span className="text-xs font-medium text-gray-400 uppercase tracking-wide"> |
| | Presets |
| | </span> |
| | </div> |
| | |
| | {store.state.presets.length === 0 ? ( |
| | <div className="text-xs text-gray-600 italic">No saved presets</div> |
| | ) : ( |
| | <> |
| | {store.state.presets.length > 6 && ( |
| | <input |
| | type="text" |
| | value={presetSearch} |
| | onChange={(e) => setPresetSearch(e.target.value)} |
| | placeholder="Search presets..." |
| | className="w-full px-2 py-1 mb-2 text-xs bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none" |
| | /> |
| | )} |
| | <div className="flex flex-wrap gap-1 max-h-32 overflow-y-auto"> |
| | {store.state.presets |
| | .filter((p) => |
| | !presetSearch || |
| | p.name.toLowerCase().includes(presetSearch.toLowerCase()) || |
| | p.repo.toLowerCase().includes(presetSearch.toLowerCase()) |
| | ) |
| | .map((p) => ( |
| | <div key={p.id} className="group relative"> |
| | <button |
| | onClick={() => store.loadPreset(p)} |
| | className="px-2 py-1 text-xs bg-gray-800 hover:bg-gray-700 rounded border border-gray-600 text-gray-300 transition-colors" |
| | title={`${p.repo} (${p.split})`} |
| | > |
| | {p.name} |
| | </button> |
| | <div className="hidden group-hover:flex absolute top-full left-0 mt-1 z-10 gap-1"> |
| | <button |
| | onClick={() => store.deletePreset(p.id)} |
| | className="px-1.5 py-0.5 text-[10px] bg-red-900 hover:bg-red-800 rounded text-red-300" |
| | > |
| | Delete |
| | </button> |
| | </div> |
| | </div> |
| | ))} |
| | </div> |
| | </> |
| | )} |
| | </div> |
| |
|
| | {} |
| | <div className="flex-1 overflow-y-auto p-3"> |
| | <div className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2"> |
| | Loaded ({store.state.datasets.length}) |
| | </div> |
| | {store.state.datasets.map((ds) => ( |
| | <div key={ds.id}> |
| | <div className="group flex items-start justify-between py-2 border-b border-gray-800/50"> |
| | <div className="flex-1 min-w-0"> |
| | <div className="text-xs text-gray-200 truncate font-medium" title={ds.repo}> |
| | {ds.name} |
| | </div> |
| | <div className="text-xs text-gray-500 mt-0.5"> |
| | {ds.n_instances} instances |
| | </div> |
| | </div> |
| | {/* Save as preset */} |
| | <button |
| | onClick={() => { |
| | setSavingForDsId(savingForDsId === ds.id ? null : ds.id); |
| | setPresetName(""); |
| | }} |
| | className={`text-xs ml-2 mt-0.5 transition-colors shrink-0 ${ |
| | savingForDsId === ds.id |
| | ? "text-teal-400" |
| | : "text-gray-600 hover:text-teal-400 opacity-0 group-hover:opacity-100" |
| | }`} |
| | title="Save as preset" |
| | > |
| | <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| | <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /> |
| | </svg> |
| | </button> |
| | {/* Remove */} |
| | <button |
| | onClick={() => store.unloadDataset(ds.id)} |
| | className="text-gray-600 hover:text-red-400 text-xs opacity-0 group-hover:opacity-100 ml-1 mt-0.5 shrink-0" |
| | title="Remove" |
| | > |
| | <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| | <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> |
| | </svg> |
| | </button> |
| | </div> |
| | {/* Inline save-as-preset form */} |
| | {savingForDsId === ds.id && ( |
| | <div className="flex gap-1 mt-1 mb-2"> |
| | <input |
| | type="text" |
| | value={presetName} |
| | onChange={(e) => setPresetName(e.target.value)} |
| | onKeyDown={(e) => { |
| | if (e.key === "Enter") handleSavePreset(ds); |
| | if (e.key === "Escape") setSavingForDsId(null); |
| | }} |
| | placeholder="Preset name..." |
| | className="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none" |
| | autoFocus |
| | /> |
| | <button |
| | onClick={() => handleSavePreset(ds)} |
| | className="px-2 py-1 text-xs bg-teal-600 hover:bg-teal-500 rounded text-white" |
| | > |
| | Save |
| | </button> |
| | </div> |
| | )} |
| | </div> |
| | ))} |
| | </div> |
| |
|
| | {} |
| | <div className="p-3 border-t border-gray-800"> |
| | {!showAddForm ? ( |
| | <button |
| | onClick={() => { |
| | setShowAddForm(true); |
| | setRepoInput(""); |
| | setSplitInput("train"); |
| | }} |
| | className="w-full px-3 py-2 text-sm bg-teal-600 hover:bg-teal-500 rounded text-white font-medium transition-colors" |
| | > |
| | + Add Repo |
| | </button> |
| | ) : ( |
| | <div className="space-y-2"> |
| | <input |
| | type="text" |
| | value={repoInput} |
| | onChange={(e) => setRepoInput(e.target.value)} |
| | onKeyDown={(e) => e.key === "Enter" && handleLoad()} |
| | placeholder="org/repo-name" |
| | className="w-full px-2 py-1.5 text-sm bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none" |
| | autoFocus |
| | /> |
| | <input |
| | type="text" |
| | value={splitInput} |
| | onChange={(e) => setSplitInput(e.target.value)} |
| | placeholder="Split" |
| | className="w-full px-2 py-1 text-xs bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none" |
| | /> |
| | <div className="flex gap-2"> |
| | <button |
| | onClick={handleLoad} |
| | disabled={store.state.loading || !repoInput.trim()} |
| | className="flex-1 px-2 py-1.5 text-sm bg-teal-600 hover:bg-teal-500 disabled:bg-gray-700 disabled:text-gray-500 rounded text-white transition-colors" |
| | > |
| | {store.state.loading ? "Loading..." : "Load"} |
| | </button> |
| | <button |
| | onClick={() => setShowAddForm(false)} |
| | className="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-300 transition-colors" |
| | > |
| | Cancel |
| | </button> |
| | </div> |
| | </div> |
| | )} |
| | </div> |
| | </div> |
| | ); |
| | } |
| |
|