File size: 9,375 Bytes
8b41737 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | 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);
// Per-dataset save-as-preset state
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>
{/* Loaded datasets */}
<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>
{/* Add repo */}
<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>
);
}
|