@harbinger-ai/harbinger 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect } from 'react';
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
4
  import { motion, AnimatePresence } from 'framer-motion';
5
- import { CrosshairIcon, PlusIcon, TrashIcon, ChevronDownIcon, GlobeIcon, SpinnerIcon, DownloadIcon, CheckIcon } from './icons.js';
6
- import { getPrograms, createProgram, deleteProgram, getTargets, createTarget, deleteTarget, updateTarget, syncTargetsFromPlatform, syncAllTargets, getSyncStatus } from '../../bounty/actions.js';
5
+ import { CrosshairIcon, PlusIcon, TrashIcon, ChevronDownIcon, GlobeIcon, SpinnerIcon, DownloadIcon, CheckIcon, SearchIcon, CopyIcon, UploadIcon, FilterIcon } from './icons.js';
6
+ import { getPrograms, createProgram, deleteProgram, getTargets, createTarget, deleteTarget, updateTarget, syncTargetsFromPlatform, syncAllTargets, getSyncStatus, getProgramTargetCounts, bulkImportTargets, exportTargets } from '../../bounty/actions.js';
7
7
 
8
8
  const PLATFORMS = [
9
9
  { id: 'hackerone', label: 'HackerOne', bg: 'bg-purple-500/10', text: 'text-purple-500', border: 'border-purple-500/20' },
@@ -15,6 +15,7 @@ const PLATFORMS = [
15
15
  ];
16
16
 
17
17
  const TARGET_TYPES = ['domain', 'wildcard', 'ip', 'cidr', 'url', 'api', 'mobile'];
18
+ const TYPE_ICONS = { domain: '\u{1f310}', wildcard: '*', ip: '\u{1f4cd}', cidr: '\u{1f5a7}', url: '\u{1f517}', api: '\u{2699}\u{fe0f}', mobile: '\u{1f4f1}' };
18
19
  const STATUS_COLORS = {
19
20
  in_scope: { bg: 'bg-green-500/10', text: 'text-green-500', border: 'border-green-500/20', dot: 'bg-green-500' },
20
21
  out_of_scope: { bg: 'bg-red-500/10', text: 'text-red-500', border: 'border-red-500/20', dot: 'bg-red-500' },
@@ -36,6 +37,24 @@ function getPlatStyle(platformId) {
36
37
  return PLATFORMS.find(p => p.id === platformId) || PLATFORMS[5];
37
38
  }
38
39
 
40
+ // ─── Copy Button ──────────────────────────────────────────────────────────────
41
+
42
+ function CopyBtn({ text }) {
43
+ const [copied, setCopied] = useState(false);
44
+ const handleCopy = async () => {
45
+ try { await navigator.clipboard.writeText(text); } catch { /* fallback */ }
46
+ setCopied(true);
47
+ setTimeout(() => setCopied(false), 1500);
48
+ };
49
+ return (
50
+ <button onClick={handleCopy} className="shrink-0 p-1 text-muted-foreground hover:text-[--cyan] transition-colors" title="Copy">
51
+ {copied ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
52
+ </button>
53
+ );
54
+ }
55
+
56
+ // ─── Sync Panel ───────────────────────────────────────────────────────────────
57
+
39
58
  function SyncPanel({ onSync, syncStatus }) {
40
59
  const [syncing, setSyncing] = useState(null);
41
60
  const [results, setResults] = useState(null);
@@ -62,21 +81,15 @@ function SyncPanel({ onSync, syncStatus }) {
62
81
  const syncablePlatforms = PLATFORMS.filter(p => p.id !== 'custom');
63
82
 
64
83
  return (
65
- <motion.div
66
- initial={{ opacity: 0, y: 8 }}
67
- animate={{ opacity: 1, y: 0 }}
68
- transition={{ duration: 0.3 }}
69
- className="rounded-lg border border-white/[0.06] bg-[--card] p-4 mb-6"
70
- >
84
+ <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}
85
+ className="rounded-lg border border-white/[0.06] bg-[--card] p-4 mb-6">
71
86
  <div className="flex items-center justify-between mb-3">
72
87
  <div>
73
88
  <div className="flex items-center gap-2 mb-0.5">
74
89
  <DownloadIcon size={14} />
75
90
  <h3 className="text-sm font-mono font-medium text-[--cyan]">Sync from Bounty Platforms</h3>
76
91
  </div>
77
- <p className="text-[11px] text-muted-foreground font-mono">
78
- Import programs and targets from bounty-targets-data
79
- </p>
92
+ <p className="text-[11px] text-muted-foreground font-mono">Import programs and targets from bounty-targets-data</p>
80
93
  </div>
81
94
  {syncStatus?.lastSyncedAt && (
82
95
  <span className="text-[10px] text-muted-foreground font-mono">Last sync: {timeAgo(syncStatus.lastSyncedAt)}</span>
@@ -87,23 +100,16 @@ function SyncPanel({ onSync, syncStatus }) {
87
100
  {syncablePlatforms.map(p => {
88
101
  const count = syncStatus?.platformCounts?.[p.id] || 0;
89
102
  return (
90
- <button
91
- key={p.id}
92
- onClick={() => handleSync(p.id)}
93
- disabled={syncing !== null}
94
- className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium border transition-colors hover:bg-white/[0.04] disabled:opacity-50 ${p.border}`}
95
- >
103
+ <button key={p.id} onClick={() => handleSync(p.id)} disabled={syncing !== null}
104
+ className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium border transition-colors hover:bg-white/[0.04] disabled:opacity-50 ${p.border}`}>
96
105
  {syncing === p.id ? <SpinnerIcon size={12} /> : <DownloadIcon size={12} />}
97
106
  {p.label}
98
107
  {count > 0 && <span className={`inline-flex rounded-full px-1.5 py-0.5 text-[9px] font-mono ${p.bg} ${p.text}`}>{count}</span>}
99
108
  </button>
100
109
  );
101
110
  })}
102
- <button
103
- onClick={() => handleSync('all')}
104
- disabled={syncing !== null}
105
- className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium bg-[--cyan] text-[--primary-foreground] hover:opacity-90 disabled:opacity-50 transition-opacity"
106
- >
111
+ <button onClick={() => handleSync('all')} disabled={syncing !== null}
112
+ className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium bg-[--cyan] text-[--primary-foreground] hover:opacity-90 disabled:opacity-50 transition-opacity">
107
113
  {syncing === 'all' ? <SpinnerIcon size={12} /> : <DownloadIcon size={12} />}
108
114
  Sync All
109
115
  </button>
@@ -111,25 +117,15 @@ function SyncPanel({ onSync, syncStatus }) {
111
117
 
112
118
  <div className="flex items-center gap-2 mb-3">
113
119
  <label className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider">Max programs:</label>
114
- <input
115
- type="number"
116
- value={maxPrograms}
117
- onChange={e => setMaxPrograms(e.target.value)}
120
+ <input type="number" value={maxPrograms} onChange={e => setMaxPrograms(e.target.value)}
118
121
  className="w-20 text-xs border border-white/[0.06] rounded-md px-2 py-1 bg-black/20 font-mono focus:outline-none focus:border-[--cyan]/40 transition-colors"
119
- min="0"
120
- placeholder="0 = all"
121
- />
122
+ min="0" placeholder="0 = all" />
122
123
  <span className="text-[10px] text-muted-foreground font-mono">(0 = unlimited)</span>
123
124
  </div>
124
125
 
125
126
  <AnimatePresence>
126
127
  {results && (
127
- <motion.div
128
- initial={{ opacity: 0, height: 0 }}
129
- animate={{ opacity: 1, height: 'auto' }}
130
- exit={{ opacity: 0, height: 0 }}
131
- className="overflow-hidden"
132
- >
128
+ <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} className="overflow-hidden">
133
129
  <div className="rounded-md bg-black/30 border border-white/[0.04] p-3 mt-2">
134
130
  {results.error ? (
135
131
  <p className="text-xs font-mono text-[--destructive]">{results.error}</p>
@@ -169,8 +165,13 @@ function SyncPanel({ onSync, syncStatus }) {
169
165
  );
170
166
  }
171
167
 
172
- function ProgramCard({ program, onSelect, selected, onDelete, index }) {
168
+ // ─── Program Card ─────────────────────────────────────────────────────────────
169
+
170
+ function ProgramCard({ program, onSelect, selected, onDelete, index, targetCount }) {
173
171
  const plat = getPlatStyle(program.platform);
172
+ const tc = targetCount || { total: 0, inScope: 0, outOfScope: 0 };
173
+ const hasTargets = tc.total > 0;
174
+
174
175
  return (
175
176
  <motion.button
176
177
  initial={{ opacity: 0, x: -8 }}
@@ -183,11 +184,7 @@ function ProgramCard({ program, onSelect, selected, onDelete, index }) {
183
184
  : 'border-white/[0.06] bg-[--card] hover:border-[--cyan]/20'
184
185
  }`}
185
186
  >
186
- <div className="flex items-center gap-1 shrink-0">
187
- <div className="w-2 h-2 rounded-full bg-[#ff5f57]" />
188
- <div className="w-2 h-2 rounded-full bg-[#febc2e]" />
189
- <div className="w-2 h-2 rounded-full bg-[#28c840]" />
190
- </div>
187
+ <div className={`shrink-0 w-2 h-2 rounded-full ${hasTargets ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
191
188
  <div className="flex-1 min-w-0">
192
189
  <p className="text-sm font-mono font-medium truncate">{program.name}</p>
193
190
  <div className="flex items-center gap-2 mt-0.5 flex-wrap">
@@ -196,46 +193,236 @@ function ProgramCard({ program, onSelect, selected, onDelete, index }) {
196
193
  {program.syncHandle && <span className="inline-flex rounded-full bg-[--cyan]/10 border border-[--cyan]/20 px-1.5 py-0.5 text-[9px] font-mono text-[--cyan]">synced</span>}
197
194
  </div>
198
195
  </div>
199
- {!program.syncHandle && (
200
- <button onClick={(e) => { e.stopPropagation(); onDelete(program.id); }} className="shrink-0 p-1 text-muted-foreground hover:text-[--destructive] rounded transition-colors"><TrashIcon size={12} /></button>
201
- )}
196
+ <div className="flex items-center gap-2 shrink-0">
197
+ {hasTargets && (
198
+ <span className="inline-flex items-center gap-1 rounded-full bg-green-500/10 text-green-500 border border-green-500/20 px-2 py-0.5 text-[9px] font-mono font-medium">
199
+ {tc.total}
200
+ </span>
201
+ )}
202
+ {!program.syncHandle && (
203
+ <button onClick={(e) => { e.stopPropagation(); onDelete(program.id); }}
204
+ className="shrink-0 p-1 text-muted-foreground hover:text-[--destructive] rounded transition-colors">
205
+ <TrashIcon size={12} />
206
+ </button>
207
+ )}
208
+ </div>
202
209
  </motion.button>
203
210
  );
204
211
  }
205
212
 
206
- function TargetRow({ target, onDelete, onStatusChange, index }) {
213
+ // ─── Target Row ───────────────────────────────────────────────────────────────
214
+
215
+ function TargetRow({ target, onDelete, onStatusChange, onUpdate, index }) {
216
+ const [expanded, setExpanded] = useState(false);
217
+ const [notes, setNotes] = useState(target.notes || '');
218
+ const [savingNotes, setSavingNotes] = useState(false);
207
219
  const st = STATUS_COLORS[target.status] || STATUS_COLORS.in_scope;
220
+ const icon = TYPE_ICONS[target.type] || '\u{1f310}';
221
+
222
+ async function handleSaveNotes() {
223
+ setSavingNotes(true);
224
+ await onUpdate(target.id, { notes });
225
+ setSavingNotes(false);
226
+ }
227
+
228
+ let techs = [];
229
+ try { if (target.technologies) techs = JSON.parse(target.technologies); } catch {}
230
+
208
231
  return (
209
232
  <motion.div
210
233
  initial={{ opacity: 0, y: 6 }}
211
234
  animate={{ opacity: 1, y: 0 }}
212
- transition={{ duration: 0.2, delay: index * 0.02 }}
213
- className="flex items-center gap-3 p-3 rounded-lg border border-white/[0.06] bg-[--card] hover:border-[--cyan]/20 transition-colors"
235
+ transition={{ duration: 0.2, delay: Math.min(index * 0.015, 0.3) }}
236
+ className="rounded-lg border border-white/[0.06] bg-[--card] hover:border-[--cyan]/20 transition-colors"
214
237
  >
215
- <div className={`shrink-0 w-2 h-2 rounded-full ${st.dot}`} />
216
- <div className="flex-1 min-w-0">
217
- <p className="text-sm font-mono font-medium truncate">{target.value}</p>
218
- <div className="flex items-center gap-2 mt-0.5 flex-wrap">
219
- <span className="inline-flex rounded-full bg-white/5 border border-white/10 px-2 py-0.5 text-[10px] font-mono font-medium text-muted-foreground">{target.type}</span>
220
- {target.syncSource && <span className="inline-flex rounded-full bg-[--cyan]/10 border border-[--cyan]/20 px-1.5 py-0.5 text-[9px] font-mono text-[--cyan]">{target.syncSource}</span>}
221
- {target.technologies && <span className="text-[10px] font-mono text-muted-foreground truncate">{JSON.parse(target.technologies).join(', ')}</span>}
238
+ <div className="flex items-center gap-3 p-3 cursor-pointer" onClick={() => setExpanded(!expanded)}>
239
+ <div className={`shrink-0 w-2 h-2 rounded-full ${st.dot}`} />
240
+ <span className="text-sm shrink-0 w-6 text-center">{icon}</span>
241
+ <div className="flex-1 min-w-0">
242
+ <p className="text-sm font-mono font-medium truncate select-all">{target.value}</p>
243
+ <div className="flex items-center gap-2 mt-0.5 flex-wrap">
244
+ <span className="inline-flex rounded-full bg-white/5 border border-white/10 px-2 py-0.5 text-[10px] font-mono font-medium text-muted-foreground">{target.type}</span>
245
+ {target.syncSource && <span className="inline-flex rounded-full bg-[--cyan]/10 border border-[--cyan]/20 px-1.5 py-0.5 text-[9px] font-mono text-[--cyan]">{target.syncSource}</span>}
246
+ {techs.length > 0 && <span className="text-[10px] font-mono text-muted-foreground truncate max-w-[200px]">{techs.join(', ')}</span>}
247
+ {target.lastScannedAt && <span className="text-[9px] font-mono text-muted-foreground">scanned {timeAgo(target.lastScannedAt)}</span>}
248
+ </div>
249
+ </div>
250
+ <CopyBtn text={target.value} />
251
+ <select
252
+ value={target.status}
253
+ onClick={e => e.stopPropagation()}
254
+ onChange={(e) => { e.stopPropagation(); onStatusChange(target.id, e.target.value); }}
255
+ className={`text-[10px] font-mono font-medium rounded-full px-2 py-0.5 border cursor-pointer bg-transparent ${st.bg} ${st.text} ${st.border} focus:outline-none`}
256
+ >
257
+ <option value="in_scope">in scope</option>
258
+ <option value="testing">testing</option>
259
+ <option value="completed">completed</option>
260
+ <option value="out_of_scope">out of scope</option>
261
+ </select>
262
+ <span className={`transition-transform shrink-0 text-muted-foreground ${expanded ? 'rotate-180' : ''}`}>
263
+ <ChevronDownIcon size={14} />
264
+ </span>
265
+ <button onClick={(e) => { e.stopPropagation(); onDelete(target.id); }}
266
+ className="shrink-0 p-1 text-muted-foreground hover:text-[--destructive] rounded transition-colors">
267
+ <TrashIcon size={12} />
268
+ </button>
269
+ </div>
270
+
271
+ <AnimatePresence>
272
+ {expanded && (
273
+ <motion.div
274
+ initial={{ height: 0, opacity: 0 }}
275
+ animate={{ height: 'auto', opacity: 1 }}
276
+ exit={{ height: 0, opacity: 0 }}
277
+ transition={{ duration: 0.2 }}
278
+ className="overflow-hidden"
279
+ >
280
+ <div className="border-t border-white/[0.06] px-4 py-3 flex flex-col gap-3">
281
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
282
+ <div>
283
+ <span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Type</span>
284
+ <p className="text-xs font-mono mt-0.5">{icon} {target.type}</p>
285
+ </div>
286
+ <div>
287
+ <span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Status</span>
288
+ <p className="text-xs font-mono mt-0.5">{target.status.replace('_', ' ')}</p>
289
+ </div>
290
+ <div>
291
+ <span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Added</span>
292
+ <p className="text-xs font-mono mt-0.5">{timeAgo(target.createdAt)}</p>
293
+ </div>
294
+ <div>
295
+ <span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Last Scan</span>
296
+ <p className="text-xs font-mono mt-0.5">{target.lastScannedAt ? timeAgo(target.lastScannedAt) : '\u2014'}</p>
297
+ </div>
298
+ </div>
299
+ {techs.length > 0 && (
300
+ <div>
301
+ <span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Technologies</span>
302
+ <div className="flex flex-wrap gap-1 mt-1">
303
+ {techs.map(t => (
304
+ <span key={t} className="inline-flex rounded-full bg-purple-500/10 text-purple-500 border border-purple-500/20 px-2 py-0.5 text-[9px] font-mono">{t}</span>
305
+ ))}
306
+ </div>
307
+ </div>
308
+ )}
309
+ <div>
310
+ <span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Notes</span>
311
+ <div className="flex gap-2 mt-1">
312
+ <textarea
313
+ value={notes}
314
+ onChange={e => setNotes(e.target.value)}
315
+ rows={2}
316
+ placeholder="Add notes..."
317
+ className="flex-1 text-[11px] border border-white/[0.06] rounded-md p-2 bg-black/20 font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 transition-colors resize-y"
318
+ />
319
+ <button onClick={handleSaveNotes} disabled={savingNotes}
320
+ className="self-end inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[10px] font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors disabled:opacity-50">
321
+ {savingNotes ? <SpinnerIcon size={10} /> : <CheckIcon size={10} />} Save
322
+ </button>
323
+ </div>
324
+ </div>
325
+ </div>
326
+ </motion.div>
327
+ )}
328
+ </AnimatePresence>
329
+ </motion.div>
330
+ );
331
+ }
332
+
333
+ // ─── Import Dialog ────────────────────────────────────────────────────────────
334
+
335
+ function ImportDialog({ programId, onClose, onImported }) {
336
+ const [text, setText] = useState('');
337
+ const [importing, setImporting] = useState(false);
338
+ const [result, setResult] = useState(null);
339
+ const fileRef = useRef(null);
340
+
341
+ function handleFile(e) {
342
+ const file = e.target.files?.[0];
343
+ if (!file) return;
344
+ const reader = new FileReader();
345
+ reader.onload = (ev) => setText(ev.target.result);
346
+ reader.readAsText(file);
347
+ }
348
+
349
+ function parseTargets(raw) {
350
+ const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
351
+ return lines.map(line => {
352
+ // Try JSON
353
+ try { const obj = JSON.parse(line); if (obj.value) return obj; } catch {}
354
+ // Try CSV: value,type
355
+ const parts = line.split(',').map(s => s.trim());
356
+ const value = parts[0];
357
+ const type = TARGET_TYPES.includes(parts[1]) ? parts[1] : guessType(value);
358
+ return { value, type };
359
+ });
360
+ }
361
+
362
+ function guessType(v) {
363
+ if (/^\*\./.test(v)) return 'wildcard';
364
+ if (/^https?:\/\//.test(v)) return 'url';
365
+ if (/\/\d{1,2}$/.test(v)) return 'cidr';
366
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(v)) return 'ip';
367
+ return 'domain';
368
+ }
369
+
370
+ async function handleImport() {
371
+ const items = parseTargets(text);
372
+ if (items.length === 0) return;
373
+ setImporting(true);
374
+ const res = await bulkImportTargets(programId, items);
375
+ setResult(res);
376
+ setImporting(false);
377
+ if (!res.error) onImported();
378
+ }
379
+
380
+ return (
381
+ <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }}
382
+ className="fixed inset-0 z-50 flex items-center justify-center p-4">
383
+ <div className="absolute inset-0 bg-black/60" onClick={onClose} />
384
+ <div className="relative w-full max-w-lg rounded-lg border border-[--cyan]/20 bg-[--card] shadow-2xl">
385
+ <div className="flex items-center justify-between p-4 border-b border-white/[0.06]">
386
+ <div className="flex items-center gap-2">
387
+ <div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#ff5f57]" /><div className="w-2 h-2 rounded-full bg-[#febc2e]" /><div className="w-2 h-2 rounded-full bg-[#28c840]" /></div>
388
+ <span className="font-mono text-[10px] font-medium text-[--cyan] uppercase tracking-wider ml-1">Import Targets</span>
389
+ </div>
390
+ <button onClick={onClose} className="text-muted-foreground hover:text-foreground"><span className="text-lg">&times;</span></button>
391
+ </div>
392
+ <div className="p-4">
393
+ <p className="text-xs font-mono text-muted-foreground mb-3">Paste targets (one per line) or upload a file. Accepted: plain list, CSV (value,type), JSON.</p>
394
+ <div className="flex gap-2 mb-3">
395
+ <button onClick={() => fileRef.current?.click()}
396
+ className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors">
397
+ <UploadIcon size={12} /> Upload File
398
+ </button>
399
+ <input ref={fileRef} type="file" accept=".txt,.csv,.json" className="hidden" onChange={handleFile} />
400
+ </div>
401
+ <textarea value={text} onChange={e => setText(e.target.value)} rows={8} placeholder={"example.com\n*.api.example.com\n10.0.0.0/8,cidr\nhttps://app.example.com,url"}
402
+ className="w-full text-[11px] border border-white/[0.06] rounded-md p-3 bg-black/20 font-mono placeholder:text-muted-foreground/30 focus:outline-none focus:border-[--cyan]/40 focus:ring-1 focus:ring-[--cyan]/20 transition-colors resize-y" />
403
+ {text && (
404
+ <p className="text-[10px] font-mono text-muted-foreground mt-1">{parseTargets(text).length} targets detected</p>
405
+ )}
406
+ {result && (
407
+ <p className={`text-xs font-mono mt-2 ${result.error ? 'text-[--destructive]' : 'text-green-500'}`}>
408
+ {result.error || `${result.imported} targets imported`}
409
+ </p>
410
+ )}
411
+ <div className="flex justify-end gap-2 mt-3">
412
+ <button onClick={onClose} className="inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors text-muted-foreground">Cancel</button>
413
+ <button onClick={handleImport} disabled={importing || !text.trim()}
414
+ className="inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors disabled:opacity-50">
415
+ {importing ? <SpinnerIcon size={12} /> : <UploadIcon size={12} />} Import
416
+ </button>
417
+ </div>
222
418
  </div>
223
419
  </div>
224
- <select
225
- value={target.status}
226
- onChange={(e) => onStatusChange(target.id, e.target.value)}
227
- className={`text-[10px] font-mono font-medium rounded-full px-2 py-0.5 border cursor-pointer bg-transparent ${st.bg} ${st.text} ${st.border} focus:outline-none`}
228
- >
229
- <option value="in_scope">in scope</option>
230
- <option value="testing">testing</option>
231
- <option value="completed">completed</option>
232
- <option value="out_of_scope">out of scope</option>
233
- </select>
234
- <button onClick={() => onDelete(target.id)} className="shrink-0 p-1 text-muted-foreground hover:text-[--destructive] rounded transition-colors"><TrashIcon size={12} /></button>
235
420
  </motion.div>
236
421
  );
237
422
  }
238
423
 
424
+ // ─── Add Target/Program Form ──────────────────────────────────────────────────
425
+
239
426
  function AddForm({ fields, onSubmit }) {
240
427
  const [values, setValues] = useState({});
241
428
  const [open, setOpen] = useState(false);
@@ -247,20 +434,19 @@ function AddForm({ fields, onSubmit }) {
247
434
  );
248
435
 
249
436
  return (
250
- <motion.div
251
- initial={{ opacity: 0, scale: 0.98 }}
252
- animate={{ opacity: 1, scale: 1 }}
253
- className="flex flex-wrap items-end gap-2 p-3 rounded-lg border border-white/[0.06] bg-[--card]"
254
- >
437
+ <motion.div initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }}
438
+ className="flex flex-wrap items-end gap-2 p-3 rounded-lg border border-white/[0.06] bg-[--card]">
255
439
  {fields.map(f => (
256
440
  <div key={f.name} className="flex flex-col gap-1">
257
441
  <label className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider">{f.label}</label>
258
442
  {f.type === 'select' ? (
259
- <select value={values[f.name] || ''} onChange={e => setValues({ ...values, [f.name]: e.target.value })} className="text-xs border border-white/[0.06] rounded-md px-2 py-1.5 bg-black/20 font-mono focus:outline-none focus:border-[--cyan]/40 transition-colors">
443
+ <select value={values[f.name] || ''} onChange={e => setValues({ ...values, [f.name]: e.target.value })}
444
+ className="text-xs border border-white/[0.06] rounded-md px-2 py-1.5 bg-black/20 font-mono focus:outline-none focus:border-[--cyan]/40 transition-colors">
260
445
  {f.options.map(o => <option key={o} value={o}>{o}</option>)}
261
446
  </select>
262
447
  ) : (
263
- <input type={f.type || 'text'} placeholder={f.placeholder} value={values[f.name] || ''} onChange={e => setValues({ ...values, [f.name]: e.target.value })} className="text-xs border border-white/[0.06] rounded-md px-2 py-1.5 bg-black/20 font-mono min-w-[120px] placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 transition-colors" />
448
+ <input type={f.type || 'text'} placeholder={f.placeholder} value={values[f.name] || ''} onChange={e => setValues({ ...values, [f.name]: e.target.value })}
449
+ className="text-xs border border-white/[0.06] rounded-md px-2 py-1.5 bg-black/20 font-mono min-w-[120px] placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 transition-colors" />
264
450
  )}
265
451
  </div>
266
452
  ))}
@@ -270,26 +456,45 @@ function AddForm({ fields, onSubmit }) {
270
456
  );
271
457
  }
272
458
 
459
+ // ─── Main Page ────────────────────────────────────────────────────────────────
460
+
273
461
  export function TargetsPage() {
274
462
  const [programs_, setPrograms] = useState([]);
275
463
  const [targets_, setTargets] = useState([]);
464
+ const [targetCounts, setTargetCounts] = useState({});
276
465
  const [selectedProgram, setSelectedProgram] = useState(null);
277
466
  const [loading, setLoading] = useState(true);
467
+ const [loadingTargets, setLoadingTargets] = useState(false);
278
468
  const [syncStatus, setSyncStatus] = useState(null);
279
469
  const [platformFilter, setPlatformFilter] = useState('all');
470
+ const [programSearch, setProgramSearch] = useState('');
471
+ const [targetSearch, setTargetSearch] = useState('');
472
+ const [typeFilter, setTypeFilter] = useState('all');
473
+ const [sortBy, setSortBy] = useState('default');
474
+ const [showImport, setShowImport] = useState(false);
475
+ const targetsRef = useRef(null);
280
476
 
281
477
  async function load() {
282
- const [p, ss] = await Promise.all([getPrograms(), getSyncStatus()]);
478
+ const [p, ss, tc] = await Promise.all([getPrograms(), getSyncStatus(), getProgramTargetCounts()]);
283
479
  setPrograms(p);
284
480
  setSyncStatus(ss);
285
- if (p.length > 0 && !selectedProgram) setSelectedProgram(p[0].id);
481
+ setTargetCounts(tc);
482
+ // Auto-select first program that has targets, or first overall
483
+ if (p.length > 0 && !selectedProgram) {
484
+ const withTargets = p.find(prog => tc[prog.id]?.total > 0);
485
+ setSelectedProgram(withTargets ? withTargets.id : p[0].id);
486
+ }
286
487
  setLoading(false);
287
488
  }
288
489
 
289
490
  async function loadTargets() {
290
491
  if (!selectedProgram) { setTargets([]); return; }
492
+ setLoadingTargets(true);
291
493
  const t = await getTargets(selectedProgram);
292
494
  setTargets(t);
495
+ setLoadingTargets(false);
496
+ // Scroll targets panel to top on program change
497
+ if (targetsRef.current) targetsRef.current.scrollTop = 0;
293
498
  }
294
499
 
295
500
  useEffect(() => { load(); }, []);
@@ -322,11 +527,13 @@ export function TargetsPage() {
322
527
  async function handleAddTarget(values) {
323
528
  await createTarget({ programId: selectedProgram, type: values.type || 'domain', value: values.value || '' });
324
529
  loadTargets();
530
+ load(); // refresh counts
325
531
  }
326
532
 
327
533
  async function handleDeleteTarget(id) {
328
534
  await deleteTarget(id);
329
535
  loadTargets();
536
+ load(); // refresh counts
330
537
  }
331
538
 
332
539
  async function handleStatusChange(id, status) {
@@ -334,11 +541,71 @@ export function TargetsPage() {
334
541
  loadTargets();
335
542
  }
336
543
 
337
- const filteredPrograms = platformFilter === 'all'
338
- ? programs_
339
- : programs_.filter(p => p.platform === platformFilter);
544
+ async function handleUpdateTarget(id, data) {
545
+ await updateTarget(id, data);
546
+ loadTargets();
547
+ }
340
548
 
341
- const inScopeCount = targets_.filter(t => t.status === 'in_scope').length;
549
+ async function handleExport() {
550
+ if (!selectedProgram) return;
551
+ const t = await exportTargets(selectedProgram);
552
+ const prog = programs_.find(p => p.id === selectedProgram);
553
+ const csv = ['value,type,status,notes', ...t.map(r => `${r.value},${r.type},${r.status},${(r.notes || '').replace(/,/g, ';')}`)].join('\n');
554
+ const blob = new Blob([csv], { type: 'text/csv' });
555
+ const url = URL.createObjectURL(blob);
556
+ const a = document.createElement('a');
557
+ a.href = url;
558
+ a.download = `${(prog?.name || 'targets').replace(/\s+/g, '_').toLowerCase()}_targets.csv`;
559
+ a.click();
560
+ URL.revokeObjectURL(url);
561
+ }
562
+
563
+ // ─── Filtering ──────────────────────────────────────────────────────────────
564
+
565
+ // Sort programs: those with targets first, then alphabetical
566
+ const sortedPrograms = [...programs_].sort((a, b) => {
567
+ const aCount = targetCounts[a.id]?.total || 0;
568
+ const bCount = targetCounts[b.id]?.total || 0;
569
+ if (aCount > 0 && bCount === 0) return -1;
570
+ if (aCount === 0 && bCount > 0) return 1;
571
+ if (aCount !== bCount) return bCount - aCount;
572
+ return a.name.localeCompare(b.name);
573
+ });
574
+
575
+ const filteredPrograms = sortedPrograms.filter(p => {
576
+ if (platformFilter !== 'all' && p.platform !== platformFilter) return false;
577
+ if (programSearch && !p.name.toLowerCase().includes(programSearch.toLowerCase())) return false;
578
+ return true;
579
+ });
580
+
581
+ const selectedProg = programs_.find(p => p.id === selectedProgram);
582
+ const tc = selectedProgram ? (targetCounts[selectedProgram] || { total: 0, inScope: 0, outOfScope: 0 }) : { total: 0, inScope: 0, outOfScope: 0 };
583
+
584
+ // Filter and sort targets
585
+ let displayTargets = [...targets_];
586
+ if (targetSearch) {
587
+ const q = targetSearch.toLowerCase();
588
+ displayTargets = displayTargets.filter(t => t.value.toLowerCase().includes(q) || t.type.includes(q));
589
+ }
590
+ if (typeFilter !== 'all') {
591
+ displayTargets = displayTargets.filter(t => t.type === typeFilter);
592
+ }
593
+ if (sortBy === 'type') {
594
+ displayTargets.sort((a, b) => a.type.localeCompare(b.type));
595
+ } else if (sortBy === 'status') {
596
+ displayTargets.sort((a, b) => a.status.localeCompare(b.status));
597
+ } else if (sortBy === 'alpha') {
598
+ displayTargets.sort((a, b) => a.value.localeCompare(b.value));
599
+ }
600
+
601
+ // Group targets by type for header display
602
+ const typeGroups = {};
603
+ for (const t of displayTargets) {
604
+ typeGroups[t.type] = (typeGroups[t.type] || 0) + 1;
605
+ }
606
+
607
+ const totalInScope = targets_.filter(t => t.status === 'in_scope').length;
608
+ const totalTargets = Object.values(targetCounts).reduce((s, c) => s + c.total, 0);
342
609
 
343
610
  if (loading) return <div className="flex flex-col gap-3">{[...Array(3)].map((_, i) => <div key={i} className="h-16 animate-pulse rounded-lg bg-white/[0.04] border border-white/[0.06]" />)}</div>;
344
611
 
@@ -355,7 +622,7 @@ export function TargetsPage() {
355
622
  {programs_.length} programs
356
623
  </span>
357
624
  <span className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[10px] font-mono font-medium bg-green-500/10 text-green-500 border border-green-500/20">
358
- {inScopeCount} in scope
625
+ {totalTargets.toLocaleString()} targets
359
626
  </span>
360
627
  </div>
361
628
  </div>
@@ -364,34 +631,25 @@ export function TargetsPage() {
364
631
 
365
632
  {/* Platform filter tabs */}
366
633
  <div className="flex gap-1.5 mb-4 overflow-x-auto pb-1 scrollbar-thin">
367
- <button
368
- onClick={() => setPlatformFilter('all')}
634
+ <button onClick={() => setPlatformFilter('all')}
369
635
  className={`shrink-0 px-3 py-1 rounded-full text-[10px] font-mono font-medium border transition-colors ${
370
- platformFilter === 'all'
371
- ? 'bg-[--cyan]/10 text-[--cyan] border-[--cyan]/20'
372
- : 'border-white/[0.06] text-muted-foreground hover:text-foreground hover:border-white/[0.12]'
373
- }`}
374
- >
636
+ platformFilter === 'all' ? 'bg-[--cyan]/10 text-[--cyan] border-[--cyan]/20' : 'border-white/[0.06] text-muted-foreground hover:text-foreground hover:border-white/[0.12]'
637
+ }`}>
375
638
  All ({programs_.length})
376
639
  </button>
377
640
  {PLATFORMS.filter(p => programs_.some(prog => prog.platform === p.id)).map(p => (
378
- <button
379
- key={p.id}
380
- onClick={() => setPlatformFilter(p.id)}
641
+ <button key={p.id} onClick={() => setPlatformFilter(p.id)}
381
642
  className={`shrink-0 px-3 py-1 rounded-full text-[10px] font-mono font-medium border transition-colors ${
382
- platformFilter === p.id
383
- ? `${p.bg} ${p.text} ${p.border}`
384
- : 'border-white/[0.06] text-muted-foreground hover:text-foreground hover:border-white/[0.12]'
385
- }`}
386
- >
643
+ platformFilter === p.id ? `${p.bg} ${p.text} ${p.border}` : 'border-white/[0.06] text-muted-foreground hover:text-foreground hover:border-white/[0.12]'
644
+ }`}>
387
645
  {p.label} ({programs_.filter(prog => prog.platform === p.id).length})
388
646
  </button>
389
647
  ))}
390
648
  </div>
391
649
 
392
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
650
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6" style={{ minHeight: '60vh' }}>
393
651
  {/* Programs panel */}
394
- <div className="lg:col-span-1">
652
+ <div className="lg:col-span-1 flex flex-col min-h-0">
395
653
  <div className="flex items-center justify-between mb-3">
396
654
  <h2 className="text-[10px] font-mono font-medium uppercase tracking-wider text-muted-foreground">Programs</h2>
397
655
  <AddForm
@@ -404,7 +662,13 @@ export function TargetsPage() {
404
662
  onSubmit={handleAddProgram}
405
663
  />
406
664
  </div>
407
- <div className="flex flex-col gap-2 max-h-[600px] overflow-y-auto scrollbar-thin">
665
+ {/* Program search */}
666
+ <div className="relative mb-2">
667
+ <div className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"><SearchIcon size={12} /></div>
668
+ <input placeholder="Search programs..." value={programSearch} onChange={e => setProgramSearch(e.target.value)}
669
+ className="w-full text-xs border border-white/[0.06] rounded-md pl-8 pr-3 py-1.5 bg-black/20 font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 transition-colors" />
670
+ </div>
671
+ <div className="flex flex-col gap-2 flex-1 overflow-y-auto scrollbar-thin">
408
672
  {filteredPrograms.length === 0 ? (
409
673
  <div className="flex flex-col items-center py-8 text-center">
410
674
  <div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-3 mb-3"><GlobeIcon size={20} /></div>
@@ -412,48 +676,134 @@ export function TargetsPage() {
412
676
  <p className="text-[10px] font-mono text-muted-foreground mt-1">Sync from platforms or add manually</p>
413
677
  </div>
414
678
  ) : filteredPrograms.map((p, i) => (
415
- <ProgramCard key={p.id} program={p} selected={selectedProgram === p.id} onSelect={setSelectedProgram} onDelete={handleDeleteProgram} index={i} />
679
+ <ProgramCard key={p.id} program={p} selected={selectedProgram === p.id} onSelect={setSelectedProgram} onDelete={handleDeleteProgram} index={i} targetCount={targetCounts[p.id]} />
416
680
  ))}
417
681
  </div>
418
682
  </div>
419
683
 
420
684
  {/* Targets panel */}
421
- <div className="lg:col-span-2">
685
+ <div className="lg:col-span-2 flex flex-col min-h-0">
422
686
  <div className="flex items-center justify-between mb-3">
423
687
  <h2 className="text-[10px] font-mono font-medium uppercase tracking-wider text-muted-foreground">
424
- Targets {selectedProgram && `\u2014 ${programs_.find(p => p.id === selectedProgram)?.name || ''}`}
688
+ Targets {selectedProg && (<>
689
+ {'\u2014'} {selectedProg.name}
690
+ {tc.total > 0 && <span className="text-[--cyan] ml-1">({tc.total})</span>}
691
+ </>)}
425
692
  </h2>
426
693
  {selectedProgram && (
427
- <AddForm
428
- fields={[
429
- { name: 'value', label: 'Target', placeholder: '*.example.com' },
430
- { name: 'type', label: 'Type', type: 'select', options: TARGET_TYPES },
431
- ]}
432
- onSubmit={handleAddTarget}
433
- />
694
+ <div className="flex items-center gap-2">
695
+ <button onClick={() => setShowImport(true)}
696
+ className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors text-muted-foreground hover:text-foreground">
697
+ <UploadIcon size={12} /> Import
698
+ </button>
699
+ {targets_.length > 0 && (
700
+ <button onClick={handleExport}
701
+ className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors text-muted-foreground hover:text-foreground">
702
+ <DownloadIcon size={12} /> Export
703
+ </button>
704
+ )}
705
+ <AddForm
706
+ fields={[
707
+ { name: 'value', label: 'Target', placeholder: '*.example.com' },
708
+ { name: 'type', label: 'Type', type: 'select', options: TARGET_TYPES },
709
+ ]}
710
+ onSubmit={handleAddTarget}
711
+ />
712
+ </div>
434
713
  )}
435
714
  </div>
715
+
716
+ {/* Target filters */}
717
+ {selectedProgram && targets_.length > 0 && (
718
+ <div className="flex items-center gap-2 mb-3 flex-wrap">
719
+ <div className="relative flex-1 min-w-[150px]">
720
+ <div className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"><SearchIcon size={12} /></div>
721
+ <input placeholder="Search targets..." value={targetSearch} onChange={e => setTargetSearch(e.target.value)}
722
+ className="w-full text-xs border border-white/[0.06] rounded-md pl-8 pr-3 py-1.5 bg-black/20 font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 transition-colors" />
723
+ </div>
724
+ <select value={typeFilter} onChange={e => setTypeFilter(e.target.value)}
725
+ className="text-xs border border-white/[0.06] rounded-md px-2 py-1.5 bg-black/20 font-mono focus:outline-none focus:border-[--cyan]/40 transition-colors">
726
+ <option value="all">All types</option>
727
+ {TARGET_TYPES.map(t => <option key={t} value={t}>{TYPE_ICONS[t]} {t}</option>)}
728
+ </select>
729
+ <select value={sortBy} onChange={e => setSortBy(e.target.value)}
730
+ className="text-xs border border-white/[0.06] rounded-md px-2 py-1.5 bg-black/20 font-mono focus:outline-none focus:border-[--cyan]/40 transition-colors">
731
+ <option value="default">Recent</option>
732
+ <option value="type">By type</option>
733
+ <option value="status">By status</option>
734
+ <option value="alpha">A-Z</option>
735
+ </select>
736
+ </div>
737
+ )}
738
+
739
+ {/* Type summary chips */}
740
+ {selectedProgram && Object.keys(typeGroups).length > 1 && (
741
+ <div className="flex flex-wrap gap-1.5 mb-3">
742
+ {Object.entries(typeGroups).sort((a, b) => b[1] - a[1]).map(([type, count]) => (
743
+ <button key={type} onClick={() => setTypeFilter(typeFilter === type ? 'all' : type)}
744
+ className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-mono font-medium border transition-colors cursor-pointer ${
745
+ typeFilter === type
746
+ ? 'bg-[--cyan]/10 text-[--cyan] border-[--cyan]/20'
747
+ : 'bg-white/5 border-white/10 text-muted-foreground hover:text-foreground'
748
+ }`}>
749
+ {TYPE_ICONS[type]} {type} ({count})
750
+ </button>
751
+ ))}
752
+ </div>
753
+ )}
754
+
436
755
  <AnimatePresence mode="wait">
437
756
  {!selectedProgram ? (
438
- <motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center py-12 text-center">
757
+ <motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center py-12 text-center flex-1">
439
758
  <div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4"><CrosshairIcon size={24} /></div>
440
759
  <p className="text-sm font-mono font-medium mb-1">Select a program</p>
441
760
  <p className="text-[11px] font-mono text-muted-foreground">Choose a program to manage its targets</p>
442
761
  </motion.div>
443
- ) : targets_.length === 0 ? (
444
- <motion.div key="no-targets" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center py-12 text-center">
762
+ ) : loadingTargets ? (
763
+ <motion.div key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center py-12 text-center flex-1">
764
+ <SpinnerIcon size={24} className="text-[--cyan] mb-3" />
765
+ <p className="text-xs font-mono text-muted-foreground">Loading targets...</p>
766
+ </motion.div>
767
+ ) : displayTargets.length === 0 && targets_.length === 0 ? (
768
+ <motion.div key="no-targets" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center py-12 text-center flex-1">
445
769
  <div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4"><CrosshairIcon size={24} /></div>
446
770
  <p className="text-sm font-mono font-medium mb-1">No targets yet</p>
447
- <p className="text-[11px] font-mono text-muted-foreground">Add domains, IPs, and URLs to start hunting</p>
771
+ <p className="text-[11px] font-mono text-muted-foreground mb-4">Add domains, IPs, and URLs to start hunting</p>
772
+ <div className="flex gap-2">
773
+ <button onClick={() => setShowImport(true)}
774
+ className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors">
775
+ <UploadIcon size={12} /> Import Targets
776
+ </button>
777
+ </div>
778
+ </motion.div>
779
+ ) : displayTargets.length === 0 ? (
780
+ <motion.div key="no-match" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center py-12 text-center flex-1">
781
+ <div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4"><SearchIcon size={24} /></div>
782
+ <p className="text-sm font-mono text-muted-foreground">No targets match your filter</p>
448
783
  </motion.div>
449
784
  ) : (
450
- <motion.div key="targets" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col gap-2 max-h-[600px] overflow-y-auto scrollbar-thin">
451
- {targets_.map((t, i) => <TargetRow key={t.id} target={t} onDelete={handleDeleteTarget} onStatusChange={handleStatusChange} index={i} />)}
785
+ <motion.div key="targets" initial={{ opacity: 0 }} animate={{ opacity: 1 }}
786
+ ref={targetsRef}
787
+ className="flex flex-col gap-2 flex-1 overflow-y-auto scrollbar-thin">
788
+ {displayTargets.map((t, i) => (
789
+ <TargetRow key={t.id} target={t} onDelete={handleDeleteTarget} onStatusChange={handleStatusChange} onUpdate={handleUpdateTarget} index={i} />
790
+ ))}
452
791
  </motion.div>
453
792
  )}
454
793
  </AnimatePresence>
455
794
  </div>
456
795
  </div>
796
+
797
+ {/* Import dialog */}
798
+ <AnimatePresence>
799
+ {showImport && selectedProgram && (
800
+ <ImportDialog
801
+ programId={selectedProgram}
802
+ onClose={() => setShowImport(false)}
803
+ onImported={() => { setShowImport(false); loadTargets(); load(); }}
804
+ />
805
+ )}
806
+ </AnimatePresence>
457
807
  </>
458
808
  );
459
809
  }