Claude commited on
Commit
e861556
·
unverified ·
1 Parent(s): b3e5e1c

feat(frontend): Sprint R5 — retro Admin panel

Browse files

Redesign the Admin page (888 lines) with retro-computing aesthetic:

- RetroMenuBar replaces dark header
- Sidebar as RetroWindow with navy-blue selection highlighting
- CreateCorpusPanel in a RetroWindow with RetroInput/RetroSelect
- ModelPanel with RetroButton provider selector + RetroSelect models
- IngestPanel with RetroButton sub-tabs (URLs/Manifest/Files)
and RetroTextarea/RetroInput forms
- RunPanel with RetroBadge job status indicators, retro scrollable
job list, RetroButton controls
- CorpusDetail with RetroWindow sections for each panel
- ErrorMsg/SuccessMsg restyled with retro borders
- All forms use retro primitives throughout

CSS bundle dropped from 29KB to 15KB (retro theme is more compact).
Build passes (tsc + vite).

https://claude.ai/code/session_01WWohTtw2CxGRawmpH1tyrY

Files changed (1) hide show
  1. frontend/src/pages/Admin.tsx +233 -482
frontend/src/pages/Admin.tsx CHANGED
@@ -24,6 +24,15 @@ import {
24
  type Job,
25
  type CreateCorpusInput,
26
  } from '../lib/api.ts'
 
 
 
 
 
 
 
 
 
27
 
28
  type IngestSubTab = 'urls' | 'manifest' | 'files'
29
 
@@ -35,27 +44,16 @@ interface Props {
35
 
36
  function ErrorMsg({ message }: { message: string }) {
37
  return (
38
- <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
39
- {message}
40
- </p>
41
  )
42
  }
43
 
44
  function SuccessMsg({ message }: { message: string }) {
45
  return (
46
- <p className="text-sm text-green-700 bg-green-50 border border-green-200 rounded px-3 py-2">
47
- {message}
48
- </p>
49
- )
50
- }
51
-
52
- // ── SectionCard ───────────────────────────────────────────────────────────
53
-
54
- function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
55
- return (
56
- <div className="bg-white border border-stone-200 rounded-lg p-6 mb-4">
57
- <h3 className="text-base font-semibold text-stone-800 mb-4">{title}</h3>
58
- {children}
59
  </div>
60
  )
61
  }
@@ -89,7 +87,7 @@ function CreateCorpusPanel({ onCreated }: CreateCorpusPanelProps) {
89
  setLoading(true)
90
  try {
91
  const corpus = await createCorpus(form)
92
- setSuccess(`Corpus « ${corpus.title} » créé.`)
93
  setForm((f) => ({ ...f, slug: '', title: '' }))
94
  onCreated(corpus)
95
  } catch (err) {
@@ -99,71 +97,45 @@ function CreateCorpusPanel({ onCreated }: CreateCorpusPanelProps) {
99
  }
100
  }
101
 
102
- const inputClass =
103
- 'border border-stone-300 rounded px-3 py-2 text-sm w-full focus:outline-none focus:ring-2 focus:ring-stone-400'
104
-
105
  return (
106
- <div className="max-w-lg">
107
- <h2 className="text-xl font-semibold text-stone-800 mb-6">Créer un corpus</h2>
108
- <form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
109
- <div>
110
- <label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
111
- Slug{' '}
112
- <span className="text-stone-400 font-normal normal-case">(identifiant unique, sans espaces)</span>
113
- </label>
114
- <input
115
- type="text"
116
- value={form.slug}
117
- onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
118
- required
119
- placeholder="ex. beatus-lat8878"
120
- className={inputClass}
121
- />
122
- </div>
123
- <div>
124
- <label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
125
- Titre
126
- </label>
127
- <input
128
- type="text"
129
- value={form.title}
130
- onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
131
- required
132
- placeholder="ex. Beatus de Saint-Sever"
133
- className={inputClass}
134
  />
135
- </div>
136
- <div>
137
- <label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
138
- Profil
139
- </label>
140
- {profiles.length === 0 ? (
141
- <p className="text-sm text-stone-400">Chargement des profils…</p>
142
- ) : (
143
- <select
144
- value={form.profile_id}
145
- onChange={(e) => setForm((f) => ({ ...f, profile_id: e.target.value }))}
146
- className="border border-stone-300 rounded px-3 py-2 text-sm w-full bg-white focus:outline-none focus:ring-2 focus:ring-stone-400"
147
- >
148
- {profiles.map((p) => (
149
- <option key={p.profile_id} value={p.profile_id}>
150
- {p.label} ({p.profile_id})
151
- </option>
152
- ))}
153
- </select>
154
- )}
155
- </div>
156
  {error && <ErrorMsg message={error} />}
157
  {success && <SuccessMsg message={success} />}
158
- <button
159
- type="submit"
160
- disabled={loading || !form.slug || !form.title || !form.profile_id}
161
- className="bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
162
- >
163
- {loading ? 'Création…' : 'Créer le corpus'}
164
- </button>
 
165
  </form>
166
- </div>
167
  )
168
  }
169
 
@@ -178,19 +150,16 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
178
  const [providers, setProviders] = useState<ProviderInfo[]>([])
179
  const [loadingProviders, setLoadingProviders] = useState(true)
180
  const [providersError, setProvidersError] = useState<string | null>(null)
181
-
182
  const [selectedProvider, setSelectedProvider] = useState<string>('')
183
  const [models, setModels] = useState<ModelInfo[]>([])
184
  const [loadingModels, setLoadingModels] = useState(false)
185
  const [modelsError, setModelsError] = useState<string | null>(null)
186
  const [selectedModelId, setSelectedModelId] = useState('')
187
-
188
  const [currentModel, setCurrentModel] = useState<CorpusModelConfig | null>(null)
189
  const [savingModel, setSavingModel] = useState(false)
190
  const [saveError, setSaveError] = useState<string | null>(null)
191
  const [saveSuccess, setSaveSuccess] = useState<string | null>(null)
192
 
193
- // Load current model config and providers on mount
194
  useEffect(() => {
195
  void getCorpusModel(corpusId).then(setCurrentModel)
196
  setLoadingProviders(true)
@@ -201,13 +170,10 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
201
  const first = ps.find((p) => p.available)
202
  if (first) setSelectedProvider(first.provider_type)
203
  })
204
- .catch((err) => {
205
- setProvidersError(err instanceof Error ? err.message : 'Erreur inconnue')
206
- })
207
  .finally(() => setLoadingProviders(false))
208
  }, [corpusId])
209
 
210
- // Load models when provider changes
211
  useEffect(() => {
212
  if (!selectedProvider) return
213
  setModels([])
@@ -215,13 +181,8 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
215
  setModelsError(null)
216
  setLoadingModels(true)
217
  fetchProviderModels(selectedProvider)
218
- .then((ms) => {
219
- setModels(ms)
220
- if (ms.length > 0) setSelectedModelId(ms[0].model_id)
221
- })
222
- .catch((err) => {
223
- setModelsError(err instanceof Error ? err.message : 'Erreur inconnue')
224
- })
225
  .finally(() => setLoadingModels(false))
226
  }, [selectedProvider])
227
 
@@ -235,124 +196,83 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
235
  await selectModel(corpusId, selectedModelId, model?.display_name ?? selectedModelId, selectedProvider)
236
  const updated = await getCorpusModel(corpusId)
237
  setCurrentModel(updated)
238
- setSaveSuccess(`Modèle « ${model?.display_name ?? selectedModelId} » associé au corpus.`)
239
  onSaved()
240
  } catch (err) {
241
- setSaveError(err instanceof Error ? err.message : 'Erreur inconnue')
242
  } finally {
243
  setSavingModel(false)
244
  }
245
  }
246
 
247
- const availableProviders = providers.filter((p) => p.available)
248
-
249
  return (
250
- <>
251
  {currentModel && (
252
- <div className="mb-4 text-sm bg-stone-50 border border-stone-200 rounded px-3 py-2 text-stone-600">
253
- Modèle actuel :{' '}
254
- <span className="font-medium text-stone-800">{currentModel.selected_model_display_name}</span>
255
  {' '}({currentModel.provider_type})
256
  </div>
257
  )}
258
 
259
- {loadingProviders && (
260
- <p className="text-sm text-stone-400">Détection des providers disponibles…</p>
261
- )}
262
  {!loadingProviders && providersError && <ErrorMsg message={providersError} />}
263
  {!loadingProviders && providers.length > 0 && (
264
- <div className="mb-4">
265
- <p className="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-2">
266
- Providers IA détectés
267
- </p>
268
- <div className="flex flex-wrap gap-2">
269
- {providers.map((p) => (
270
- <span
271
- key={p.provider_type}
272
- className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border ${
273
- p.available
274
- ? 'bg-green-50 border-green-200 text-green-800 cursor-pointer hover:bg-green-100'
275
- : 'bg-stone-50 border-stone-200 text-stone-400 cursor-default'
276
- } ${selectedProvider === p.provider_type ? 'ring-2 ring-stone-500' : ''}`}
277
- onClick={() => p.available && setSelectedProvider(p.provider_type)}
278
- >
279
- <span className={`w-1.5 h-1.5 rounded-full ${p.available ? 'bg-green-500' : 'bg-stone-300'}`} />
280
- {p.display_name}
281
- {p.available && <span className="text-green-600">({p.model_count})</span>}
282
- {!p.available && <span className="text-stone-400">— clé manquante</span>}
283
- </span>
284
- ))}
285
- </div>
286
- {availableProviders.length === 0 && (
287
- <p className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-2 mt-3">
288
- Aucun provider disponible. Vérifiez les secrets{' '}
289
- <code className="font-mono">GOOGLE_AI_STUDIO_API_KEY</code>,{' '}
290
- <code className="font-mono">VERTEX_API_KEY</code> ou{' '}
291
- <code className="font-mono">MISTRAL_API_KEY</code>.
292
- </p>
293
- )}
294
  </div>
295
  )}
296
 
297
  {selectedProvider && (
298
- <form onSubmit={(e) => void handleSelectModel(e)} className="space-y-3 max-w-sm">
299
- {loadingModels && <p className="text-sm text-stone-400">Chargement des modèles…</p>}
300
  {!loadingModels && modelsError && <ErrorMsg message={modelsError} />}
301
  {!loadingModels && models.length > 0 && (
302
- <div>
303
- <label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
304
- Modèle — {providers.find((p) => p.provider_type === selectedProvider)?.display_name}
305
- </label>
306
- <select
307
- value={selectedModelId}
308
- onChange={(e) => setSelectedModelId(e.target.value)}
309
- className="border border-stone-300 rounded px-3 py-2 text-sm w-full bg-white focus:outline-none focus:ring-2 focus:ring-stone-400"
310
- >
311
- {models.map((m) => (
312
- <option key={m.model_id} value={m.model_id}>
313
- {m.display_name}{m.supports_vision ? ' (vision)' : ''}
314
- </option>
315
- ))}
316
- </select>
317
- </div>
318
  )}
319
  {saveError && <ErrorMsg message={saveError} />}
320
  {saveSuccess && <SuccessMsg message={saveSuccess} />}
321
  {!loadingModels && models.length > 0 && (
322
- <button
323
- type="submit"
324
- disabled={savingModel || !selectedModelId}
325
- className="bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
326
- >
327
- {savingModel ? 'Enregistrement…' : 'Sélectionner ce modèle'}
328
- </button>
329
  )}
330
  </form>
331
  )}
332
- </>
333
  )
334
  }
335
 
336
  // ── IngestPanel ───────────────────────────────────────────────────────────
337
 
338
- interface IngestPanelProps {
339
- corpusId: string
340
- }
341
-
342
- function IngestPanel({ corpusId }: IngestPanelProps) {
343
  const [subTab, setSubTab] = useState<IngestSubTab>('urls')
344
-
345
  const [urlsText, setUrlsText] = useState('')
346
  const [folioLabelsText, setFolioLabelsText] = useState('')
347
  const [urlsLoading, setUrlsLoading] = useState(false)
348
  const [urlsError, setUrlsError] = useState<string | null>(null)
349
  const [urlsSuccess, setUrlsSuccess] = useState<string | null>(null)
350
-
351
  const [manifestUrl, setManifestUrl] = useState('')
352
  const [manifestLoading, setManifestLoading] = useState(false)
353
  const [manifestError, setManifestError] = useState<string | null>(null)
354
  const [manifestSuccess, setManifestSuccess] = useState<string | null>(null)
355
-
356
  const [selectedFiles, setSelectedFiles] = useState<File[]>([])
357
  const [filesLoading, setFilesLoading] = useState(false)
358
  const [filesError, setFilesError] = useState<string | null>(null)
@@ -360,174 +280,98 @@ function IngestPanel({ corpusId }: IngestPanelProps) {
360
 
361
  const handleUrlsSubmit = async (e: FormEvent) => {
362
  e.preventDefault()
363
- setUrlsError(null)
364
- setUrlsSuccess(null)
365
  const urls = urlsText.split('\n').map((l) => l.trim()).filter(Boolean)
366
  const labels = folioLabelsText.split('\n').map((l) => l.trim()).filter(Boolean)
367
- if (urls.length === 0) { setUrlsError('Aucune URL renseignée.'); return }
368
- if (labels.length !== urls.length) {
369
- setUrlsError(`Le nombre de folio_labels (${labels.length}) doit être égal au nombre d'URLs (${urls.length}).`)
370
- return
371
- }
372
  setUrlsLoading(true)
373
  try {
374
  const resp = await ingestImages(corpusId, urls, labels)
375
- setUrlsSuccess(`${resp.pages_created} page(s) ingérée(s).`)
376
- setUrlsText('')
377
- setFolioLabelsText('')
378
- } catch (err) {
379
- setUrlsError(err instanceof Error ? err.message : 'Erreur inconnue')
380
- } finally {
381
- setUrlsLoading(false)
382
- }
383
  }
384
 
385
  const handleManifestSubmit = async (e: FormEvent) => {
386
  e.preventDefault()
387
- setManifestError(null)
388
- setManifestSuccess(null)
389
- setManifestLoading(true)
390
  try {
391
  const resp = await ingestManifest(corpusId, manifestUrl)
392
- setManifestSuccess(`${resp.pages_created} page(s) ingérée(s) depuis le manifest.`)
393
  setManifestUrl('')
394
- } catch (err) {
395
- setManifestError(err instanceof Error ? err.message : 'Erreur inconnue')
396
- } finally {
397
- setManifestLoading(false)
398
- }
399
  }
400
 
401
  const handleFilesSubmit = async (e: FormEvent) => {
402
  e.preventDefault()
403
- setFilesError(null)
404
- setFilesSuccess(null)
405
- if (selectedFiles.length === 0) { setFilesError('Aucun fichier sélectionné.'); return }
406
  setFilesLoading(true)
407
  try {
408
  const resp = await ingestFiles(corpusId, selectedFiles)
409
- setFilesSuccess(`${resp.pages_created} page(s) ingérée(s).`)
410
  setSelectedFiles([])
411
- } catch (err) {
412
- setFilesError(err instanceof Error ? err.message : 'Erreur inconnue')
413
- } finally {
414
- setFilesLoading(false)
415
- }
416
  }
417
 
418
- const subTabClass = (tab: IngestSubTab) =>
419
- `px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
420
- subTab === tab
421
- ? 'border-stone-800 text-stone-900'
422
- : 'border-transparent text-stone-500 hover:text-stone-700'
423
- }`
424
-
425
- const textareaClass =
426
- 'border border-stone-300 rounded px-3 py-2 text-sm w-full font-mono focus:outline-none focus:ring-2 focus:ring-stone-400'
427
- const submitBtnClass =
428
- 'bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
429
-
430
  return (
431
- <>
432
- <div className="flex border-b border-stone-200 mb-4 -mt-1">
433
- <button className={subTabClass('urls')} onClick={() => setSubTab('urls')}>URLs directes</button>
434
- <button className={subTabClass('manifest')} onClick={() => setSubTab('manifest')}>Manifest IIIF</button>
435
- <button className={subTabClass('files')} onClick={() => setSubTab('files')}>Fichiers locaux</button>
 
 
436
  </div>
437
 
438
  {subTab === 'urls' && (
439
- <form onSubmit={(e) => void handleUrlsSubmit(e)} className="space-y-3 max-w-lg">
440
- <div>
441
- <label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
442
- URLs d'images <span className="font-normal normal-case text-stone-400">(1 par ligne)</span>
443
- </label>
444
- <textarea
445
- value={urlsText}
446
- onChange={(e) => setUrlsText(e.target.value)}
447
- rows={4}
448
- placeholder="https://gallica.bnf.fr/iiif/ark:/…/f1/full/max/0/native.jpg"
449
- className={textareaClass}
450
- />
451
- </div>
452
- <div>
453
- <label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
454
- Folio labels <span className="font-normal normal-case text-stone-400">(1 par ligne, même ordre)</span>
455
- </label>
456
- <textarea
457
- value={folioLabelsText}
458
- onChange={(e) => setFolioLabelsText(e.target.value)}
459
- rows={4}
460
- placeholder={'001r\n001v\n002r'}
461
- className={textareaClass}
462
- />
463
- </div>
464
  {urlsError && <ErrorMsg message={urlsError} />}
465
  {urlsSuccess && <SuccessMsg message={urlsSuccess} />}
466
- <button type="submit" disabled={urlsLoading} className={submitBtnClass}>
467
- {urlsLoading ? 'Ingestion…' : 'Ingérer les images'}
468
- </button>
469
  </form>
470
  )}
471
 
472
  {subTab === 'manifest' && (
473
- <form onSubmit={(e) => void handleManifestSubmit(e)} className="space-y-3 max-w-lg">
474
- <div>
475
- <label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
476
- URL du manifest IIIF
477
- </label>
478
- <input
479
- type="url"
480
- value={manifestUrl}
481
- onChange={(e) => setManifestUrl(e.target.value)}
482
- required
483
- placeholder="https://gallica.bnf.fr/iiif/ark:/…/manifest.json"
484
- className="border border-stone-300 rounded px-3 py-2 text-sm w-full font-mono focus:outline-none focus:ring-2 focus:ring-stone-400"
485
- />
486
- </div>
487
  {manifestError && <ErrorMsg message={manifestError} />}
488
  {manifestSuccess && <SuccessMsg message={manifestSuccess} />}
489
- <button type="submit" disabled={manifestLoading || !manifestUrl} className={submitBtnClass}>
490
- {manifestLoading ? 'Ingestion…' : 'Importer le manifest'}
491
- </button>
492
  </form>
493
  )}
494
 
495
  {subTab === 'files' && (
496
- <form onSubmit={(e) => void handleFilesSubmit(e)} className="space-y-3 max-w-lg">
497
- <div>
498
- <label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
499
- Fichiers images
500
- </label>
501
  <input
502
- type="file"
503
- multiple
504
- accept="image/*"
505
  onChange={(e) => setSelectedFiles(Array.from(e.target.files ?? []))}
506
- className="block text-sm text-stone-600 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-stone-100 file:text-stone-700 hover:file:bg-stone-200"
507
  />
508
  {selectedFiles.length > 0 && (
509
- <p className="text-xs text-stone-500 mt-1">{selectedFiles.length} fichier(s) sélectionné(s)</p>
510
  )}
511
  </div>
512
  {filesError && <ErrorMsg message={filesError} />}
513
  {filesSuccess && <SuccessMsg message={filesSuccess} />}
514
- <button type="submit" disabled={filesLoading || selectedFiles.length === 0} className={submitBtnClass}>
515
- {filesLoading ? 'Envoi…' : 'Envoyer les fichiers'}
516
- </button>
517
  </form>
518
  )}
519
- </>
520
  )
521
  }
522
 
523
  // ── RunPanel ──────────────────────────────────────────────────────────────
524
 
525
- interface RunPanelProps {
526
- corpusId: string
527
- hasModel: boolean
528
- }
529
-
530
- function RunPanel({ corpusId, hasModel }: RunPanelProps) {
531
  const [pageCount, setPageCount] = useState<number | null>(null)
532
  const [launching, setLaunching] = useState(false)
533
  const [launchError, setLaunchError] = useState<string | null>(null)
@@ -535,7 +379,6 @@ function RunPanel({ corpusId, hasModel }: RunPanelProps) {
535
  const [jobs, setJobs] = useState<Record<string, Job>>({})
536
  const [polling, setPolling] = useState(false)
537
 
538
- // Fetch page count from manuscripts + pages
539
  useEffect(() => {
540
  fetchManuscripts(corpusId)
541
  .then(async (manuscripts) => {
@@ -555,28 +398,19 @@ function RunPanel({ corpusId, hasModel }: RunPanelProps) {
555
  for (const job of results) map[job.id] = job
556
  setJobs(map)
557
  if (results.every((j) => j.status === 'done' || j.status === 'failed')) setPolling(false)
558
- } catch {
559
- // Erreur réseau transitoire — on continue
560
- }
561
  }
562
  const id = setInterval(() => void poll(), 3000)
563
  return () => clearInterval(id)
564
  }, [polling, jobIds])
565
 
566
  const handleRun = async () => {
567
- setLaunchError(null)
568
- setJobIds([])
569
- setJobs({})
570
- setLaunching(true)
571
  try {
572
  const resp = await runCorpus(corpusId)
573
- setJobIds(resp.job_ids)
574
- setPolling(true)
575
- } catch (err) {
576
- setLaunchError(err instanceof Error ? err.message : 'Erreur inconnue')
577
- } finally {
578
- setLaunching(false)
579
- }
580
  }
581
 
582
  const handleRetryFailed = async () => {
@@ -591,84 +425,51 @@ function RunPanel({ corpusId, hasModel }: RunPanelProps) {
591
  const failedCount = jobList.filter((j) => j.status === 'failed').length
592
  const totalCount = jobList.length
593
 
594
- const statusBadge = (status: string) => {
595
- const classes: Record<string, string> = {
596
- pending: 'bg-stone-100 text-stone-600',
597
- running: 'bg-blue-100 text-blue-700',
598
- done: 'bg-green-100 text-green-700',
599
- failed: 'bg-red-100 text-red-700',
600
- }
601
- return (
602
- <span className={`text-xs px-2 py-0.5 rounded font-medium ${classes[status] ?? 'bg-stone-100 text-stone-500'}`}>
603
- {status}
604
- </span>
605
- )
606
  }
607
 
608
  if (!hasModel) {
609
- return (
610
- <p className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-2">
611
- Configurez d'abord un modèle IA pour ce corpus.
612
- </p>
613
- )
614
  }
615
 
616
  return (
617
- <div className="space-y-4">
618
  {pageCount !== null && (
619
- <p className="text-sm text-stone-600">
620
- {pageCount === 0
621
- ? 'Aucune page ingérée.'
622
- : `${pageCount} page(s) dans ce corpus.`}
623
- </p>
624
  )}
625
-
626
  {launchError && <ErrorMsg message={launchError} />}
627
-
628
- <div className="flex flex-wrap gap-3 items-center">
629
- <button
630
- onClick={() => void handleRun()}
631
- disabled={launching || polling || pageCount === 0}
632
- className="bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
633
- >
634
- {launching ? 'Démarrage…' : polling ? 'Traitement en cours…' : 'Analyser tout le corpus'}
635
- </button>
636
-
637
  {failedCount > 0 && !polling && (
638
- <button
639
- onClick={() => void handleRetryFailed()}
640
- className="border border-stone-300 text-stone-700 px-5 py-2 rounded text-sm font-medium hover:bg-stone-50 transition-colors"
641
- >
642
- Relancer {failedCount} page(s) en erreur
643
- </button>
644
  )}
645
  </div>
646
-
647
  {totalCount > 0 && (
648
  <div>
649
- <p className="text-sm text-stone-600 mb-3">
650
- Progression : <strong>{doneCount}</strong> / {totalCount} pages traitées
651
- {failedCount > 0 && <span className="text-red-600 ml-2">· {failedCount} en erreur</span>}
652
- {polling && <span className="text-blue-600 ml-2">· actualisation toutes les 3 s</span>}
653
- </p>
654
- <ul className="space-y-1 max-h-64 overflow-y-auto border border-stone-200 rounded p-2 bg-white">
655
  {jobList.map((job) => (
656
- <li
657
- key={job.id}
658
- className="flex items-center justify-between text-xs text-stone-600 py-1 px-2 rounded hover:bg-stone-50"
659
- >
660
- <span className="font-mono truncate max-w-xs">{job.page_id ?? job.id}</span>
661
- <div className="flex items-center gap-2 ml-2 shrink-0">
662
- {statusBadge(job.status)}
663
- {job.error_message && (
664
- <span className="text-red-500 truncate max-w-xs" title={job.error_message}>
665
- {job.error_message}
666
- </span>
667
- )}
668
  </div>
669
- </li>
670
  ))}
671
- </ul>
672
  </div>
673
  )}
674
  </div>
@@ -677,99 +478,68 @@ function RunPanel({ corpusId, hasModel }: RunPanelProps) {
677
 
678
  // ── CorpusDetail ──────────────────────────────────────────────────────────
679
 
680
- interface CorpusDetailProps {
681
- corpus: Corpus
682
- onDeleted: () => void
683
- }
684
-
685
- function CorpusDetail({ corpus, onDeleted }: CorpusDetailProps) {
686
  const [hasModel, setHasModel] = useState(false)
687
  const [deleting, setDeleting] = useState(false)
688
  const [deleteError, setDeleteError] = useState<string | null>(null)
689
  const [confirmDelete, setConfirmDelete] = useState(false)
690
 
691
  useEffect(() => {
692
- getCorpusModel(corpus.id)
693
- .then((m) => setHasModel(m !== null))
694
- .catch(() => {})
695
  }, [corpus.id])
696
 
697
  const handleDelete = async () => {
698
- setDeleteError(null)
699
- setDeleting(true)
700
- try {
701
- await deleteCorpus(corpus.id)
702
- onDeleted()
703
- } catch (err) {
704
- setDeleteError(err instanceof Error ? err.message : 'Erreur inconnue')
705
- setDeleting(false)
706
- setConfirmDelete(false)
707
- }
708
  }
709
 
710
  return (
711
- <div>
712
- {/* Corpus header */}
713
- <div className="flex items-start justify-between mb-6">
714
  <div>
715
- <h2 className="text-xl font-semibold text-stone-800">{corpus.title}</h2>
716
- <p className="text-sm text-stone-500 mt-0.5">
717
- <span className="font-mono">{corpus.slug}</span>
718
- {' · '}
719
- <span>{corpus.profile_id}</span>
720
- </p>
721
  </div>
722
- <div className="flex items-center gap-2">
723
- {deleteError && <span className="text-xs text-red-600">{deleteError}</span>}
724
  {confirmDelete ? (
725
  <>
726
- <span className="text-xs text-stone-600">Confirmer la suppression ?</span>
727
- <button
728
- onClick={() => void handleDelete()}
729
- disabled={deleting}
730
- className="px-3 py-1.5 bg-red-600 text-white text-xs rounded font-medium hover:bg-red-700 disabled:opacity-50 transition-colors"
731
- >
732
- {deleting ? 'Suppression…' : 'Supprimer'}
733
- </button>
734
- <button
735
- onClick={() => setConfirmDelete(false)}
736
- className="px-3 py-1.5 border border-stone-300 text-stone-600 text-xs rounded font-medium hover:bg-stone-50 transition-colors"
737
- >
738
- Annuler
739
- </button>
740
  </>
741
  ) : (
742
- <button
743
- onClick={() => setConfirmDelete(true)}
744
- className="px-3 py-1.5 border border-red-200 text-red-600 text-xs rounded font-medium hover:bg-red-50 transition-colors"
745
- >
746
- Supprimer
747
- </button>
748
  )}
749
  </div>
750
  </div>
751
 
752
- {/* Section cards */}
753
- <SectionCard title="Modèle IA">
754
- <ModelPanel
755
- key={corpus.id}
756
- corpusId={corpus.id}
757
- onSaved={() => setHasModel(true)}
758
- />
759
- </SectionCard>
760
 
761
- <SectionCard title="Ingestion">
762
- <IngestPanel key={corpus.id} corpusId={corpus.id} />
763
- </SectionCard>
 
 
764
 
765
- <SectionCard title="Traitement">
766
- <RunPanel key={corpus.id} corpusId={corpus.id} hasModel={hasModel} />
767
- </SectionCard>
 
 
768
  </div>
769
  )
770
  }
771
 
772
- // ── Admin (composant principal) ─────────────────────────────────────────────
773
 
774
  export default function Admin({ onHome }: Props) {
775
  const [corpora, setCorpora] = useState<Corpus[]>([])
@@ -781,85 +551,71 @@ export default function Admin({ onHome }: Props) {
781
  fetchCorpora()
782
  .then((cs) => {
783
  setCorpora(cs)
784
- if (selectId) {
785
- setSelectedCorpusId(selectId)
786
- setShowCreate(false)
787
- } else if (!didInit.current) {
788
  didInit.current = true
789
- if (cs.length > 0) {
790
- setSelectedCorpusId(cs[0].id)
791
- setShowCreate(false)
792
- } else {
793
- setShowCreate(true)
794
- }
795
  }
796
  })
797
  .catch(() => {})
798
  }
799
 
800
- useEffect(() => {
801
- refreshCorpora()
802
- }, [])
803
 
804
  const selectedCorpus = corpora.find((c) => c.id === selectedCorpusId) ?? null
805
 
806
  return (
807
- <div className="h-screen flex flex-col bg-stone-50">
808
- {/* Top bar */}
809
- <header className="bg-stone-900 text-stone-100 px-6 py-4 flex items-center gap-4 shrink-0">
810
- <button
811
- onClick={onHome}
812
- className="text-stone-400 hover:text-stone-100 text-sm transition-colors"
813
- >
814
- ← Accueil
815
- </button>
816
- <h1 className="text-xl font-semibold tracking-tight">Administration</h1>
817
- </header>
818
-
819
- <div className="flex flex-1 overflow-hidden">
820
  {/* Sidebar */}
821
- <aside className="w-64 bg-white border-r border-stone-200 flex flex-col shrink-0 overflow-y-auto">
822
- <div className="p-3 border-b border-stone-100">
823
  <button
824
  onClick={() => { setShowCreate(true); setSelectedCorpusId(null) }}
825
- className={`w-full text-left px-3 py-2 rounded text-sm font-medium transition-colors ${
826
- showCreate && !selectedCorpusId
827
- ? 'bg-stone-800 text-white'
828
- : 'text-stone-600 hover:bg-stone-100'
829
- }`}
830
  >
831
  + Nouveau corpus
832
  </button>
833
- </div>
834
- <nav className="flex-1 p-3 space-y-0.5">
835
  {corpora.length === 0 && (
836
- <p className="text-xs text-stone-400 px-3 py-2">Aucun corpus</p>
837
  )}
838
  {corpora.map((c) => (
839
  <button
840
  key={c.id}
841
  onClick={() => { setSelectedCorpusId(c.id); setShowCreate(false) }}
842
- className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
843
- selectedCorpusId === c.id && !showCreate
844
- ? 'bg-stone-100 text-stone-900 font-medium'
845
- : 'text-stone-600 hover:bg-stone-50'
846
- }`}
 
 
847
  >
848
- <span className="block truncate">{c.title}</span>
849
- <span className="block truncate text-xs text-stone-400 font-mono">{c.slug}</span>
 
 
850
  </button>
851
  ))}
852
- </nav>
853
- </aside>
854
 
855
  {/* Main panel */}
856
- <main className="flex-1 overflow-y-auto p-8">
857
  {showCreate && !selectedCorpusId && (
858
- <CreateCorpusPanel
859
- onCreated={(corpus) => {
860
- refreshCorpora(corpus.id)
861
- }}
862
- />
863
  )}
864
  {!showCreate && selectedCorpus && (
865
  <CorpusDetail
@@ -868,20 +624,15 @@ export default function Admin({ onHome }: Props) {
868
  onDeleted={() => {
869
  const remaining = corpora.filter((c) => c.id !== selectedCorpus.id)
870
  setCorpora(remaining)
871
- if (remaining.length > 0) {
872
- setSelectedCorpusId(remaining[0].id)
873
- setShowCreate(false)
874
- } else {
875
- setSelectedCorpusId(null)
876
- setShowCreate(true)
877
- }
878
  }}
879
  />
880
  )}
881
  {!showCreate && !selectedCorpus && corpora.length > 0 && (
882
- <p className="text-sm text-stone-400">Sélectionnez un corpus dans la barre latérale.</p>
883
  )}
884
- </main>
885
  </div>
886
  </div>
887
  )
 
24
  type Job,
25
  type CreateCorpusInput,
26
  } from '../lib/api.ts'
27
+ import {
28
+ RetroMenuBar,
29
+ RetroWindow,
30
+ RetroButton,
31
+ RetroInput,
32
+ RetroTextarea,
33
+ RetroSelect,
34
+ RetroBadge,
35
+ } from '../components/retro'
36
 
37
  type IngestSubTab = 'urls' | 'manifest' | 'files'
38
 
 
44
 
45
  function ErrorMsg({ message }: { message: string }) {
46
  return (
47
+ <div className="border border-retro-black bg-retro-white p-2 text-retro-sm">
48
+ <span className="font-bold">Erreur:</span> {message}
49
+ </div>
50
  )
51
  }
52
 
53
  function SuccessMsg({ message }: { message: string }) {
54
  return (
55
+ <div className="border border-retro-black bg-retro-light p-2 text-retro-sm">
56
+ <span className="font-bold">OK:</span> {message}
 
 
 
 
 
 
 
 
 
 
 
57
  </div>
58
  )
59
  }
 
87
  setLoading(true)
88
  try {
89
  const corpus = await createCorpus(form)
90
+ setSuccess(`Corpus "${corpus.title}" cree.`)
91
  setForm((f) => ({ ...f, slug: '', title: '' }))
92
  onCreated(corpus)
93
  } catch (err) {
 
97
  }
98
  }
99
 
 
 
 
100
  return (
101
+ <RetroWindow title="Creer un corpus" className="max-w-lg">
102
+ <form onSubmit={(e) => void handleSubmit(e)} className="p-3 flex flex-col gap-2">
103
+ <RetroInput
104
+ label="Slug (identifiant unique)"
105
+ value={form.slug}
106
+ onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
107
+ required
108
+ placeholder="ex. beatus-lat8878"
109
+ />
110
+ <RetroInput
111
+ label="Titre"
112
+ value={form.title}
113
+ onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
114
+ required
115
+ placeholder="ex. Beatus de Saint-Sever"
116
+ />
117
+ {profiles.length === 0 ? (
118
+ <div className="text-retro-sm text-retro-darkgray">Chargement des profils...</div>
119
+ ) : (
120
+ <RetroSelect
121
+ label="Profil"
122
+ value={form.profile_id}
123
+ onChange={(e) => setForm((f) => ({ ...f, profile_id: e.target.value }))}
124
+ options={profiles.map((p) => ({ value: p.profile_id, label: `${p.label} (${p.profile_id})` }))}
 
 
 
 
125
  />
126
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  {error && <ErrorMsg message={error} />}
128
  {success && <SuccessMsg message={success} />}
129
+ <div className="mt-1">
130
+ <RetroButton
131
+ type="submit"
132
+ disabled={loading || !form.slug || !form.title || !form.profile_id}
133
+ >
134
+ {loading ? 'Creation...' : 'Creer le corpus'}
135
+ </RetroButton>
136
+ </div>
137
  </form>
138
+ </RetroWindow>
139
  )
140
  }
141
 
 
150
  const [providers, setProviders] = useState<ProviderInfo[]>([])
151
  const [loadingProviders, setLoadingProviders] = useState(true)
152
  const [providersError, setProvidersError] = useState<string | null>(null)
 
153
  const [selectedProvider, setSelectedProvider] = useState<string>('')
154
  const [models, setModels] = useState<ModelInfo[]>([])
155
  const [loadingModels, setLoadingModels] = useState(false)
156
  const [modelsError, setModelsError] = useState<string | null>(null)
157
  const [selectedModelId, setSelectedModelId] = useState('')
 
158
  const [currentModel, setCurrentModel] = useState<CorpusModelConfig | null>(null)
159
  const [savingModel, setSavingModel] = useState(false)
160
  const [saveError, setSaveError] = useState<string | null>(null)
161
  const [saveSuccess, setSaveSuccess] = useState<string | null>(null)
162
 
 
163
  useEffect(() => {
164
  void getCorpusModel(corpusId).then(setCurrentModel)
165
  setLoadingProviders(true)
 
170
  const first = ps.find((p) => p.available)
171
  if (first) setSelectedProvider(first.provider_type)
172
  })
173
+ .catch((err) => setProvidersError(err instanceof Error ? err.message : 'Erreur'))
 
 
174
  .finally(() => setLoadingProviders(false))
175
  }, [corpusId])
176
 
 
177
  useEffect(() => {
178
  if (!selectedProvider) return
179
  setModels([])
 
181
  setModelsError(null)
182
  setLoadingModels(true)
183
  fetchProviderModels(selectedProvider)
184
+ .then((ms) => { setModels(ms); if (ms.length > 0) setSelectedModelId(ms[0].model_id) })
185
+ .catch((err) => setModelsError(err instanceof Error ? err.message : 'Erreur'))
 
 
 
 
 
186
  .finally(() => setLoadingModels(false))
187
  }, [selectedProvider])
188
 
 
196
  await selectModel(corpusId, selectedModelId, model?.display_name ?? selectedModelId, selectedProvider)
197
  const updated = await getCorpusModel(corpusId)
198
  setCurrentModel(updated)
199
+ setSaveSuccess(`Modele "${model?.display_name ?? selectedModelId}" associe.`)
200
  onSaved()
201
  } catch (err) {
202
+ setSaveError(err instanceof Error ? err.message : 'Erreur')
203
  } finally {
204
  setSavingModel(false)
205
  }
206
  }
207
 
 
 
208
  return (
209
+ <div className="flex flex-col gap-2">
210
  {currentModel && (
211
+ <div className="text-retro-sm border border-retro-black p-2 bg-retro-light">
212
+ Modele actuel: <span className="font-bold">{currentModel.selected_model_display_name}</span>
 
213
  {' '}({currentModel.provider_type})
214
  </div>
215
  )}
216
 
217
+ {loadingProviders && <div className="text-retro-sm text-retro-darkgray">Detection providers...</div>}
 
 
218
  {!loadingProviders && providersError && <ErrorMsg message={providersError} />}
219
  {!loadingProviders && providers.length > 0 && (
220
+ <div className="flex flex-wrap gap-[2px]">
221
+ {providers.map((p) => (
222
+ <RetroButton
223
+ key={p.provider_type}
224
+ size="sm"
225
+ pressed={selectedProvider === p.provider_type}
226
+ disabled={!p.available}
227
+ onClick={() => p.available && setSelectedProvider(p.provider_type)}
228
+ >
229
+ {p.display_name} {p.available ? `(${p.model_count})` : '— N/A'}
230
+ </RetroButton>
231
+ ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  </div>
233
  )}
234
 
235
  {selectedProvider && (
236
+ <form onSubmit={(e) => void handleSelectModel(e)} className="flex flex-col gap-2 max-w-sm">
237
+ {loadingModels && <div className="text-retro-sm text-retro-darkgray">Chargement modeles...</div>}
238
  {!loadingModels && modelsError && <ErrorMsg message={modelsError} />}
239
  {!loadingModels && models.length > 0 && (
240
+ <RetroSelect
241
+ label={`Modele ${providers.find((p) => p.provider_type === selectedProvider)?.display_name}`}
242
+ value={selectedModelId}
243
+ onChange={(e) => setSelectedModelId(e.target.value)}
244
+ options={models.map((m) => ({
245
+ value: m.model_id,
246
+ label: `${m.display_name}${m.supports_vision ? ' (vision)' : ''}`,
247
+ }))}
248
+ />
 
 
 
 
 
 
 
249
  )}
250
  {saveError && <ErrorMsg message={saveError} />}
251
  {saveSuccess && <SuccessMsg message={saveSuccess} />}
252
  {!loadingModels && models.length > 0 && (
253
+ <RetroButton type="submit" disabled={savingModel || !selectedModelId}>
254
+ {savingModel ? 'Enregistrement...' : 'Selectionner'}
255
+ </RetroButton>
 
 
 
 
256
  )}
257
  </form>
258
  )}
259
+ </div>
260
  )
261
  }
262
 
263
  // ── IngestPanel ───────────────────────────────────────────────────────────
264
 
265
+ function IngestPanel({ corpusId }: { corpusId: string }) {
 
 
 
 
266
  const [subTab, setSubTab] = useState<IngestSubTab>('urls')
 
267
  const [urlsText, setUrlsText] = useState('')
268
  const [folioLabelsText, setFolioLabelsText] = useState('')
269
  const [urlsLoading, setUrlsLoading] = useState(false)
270
  const [urlsError, setUrlsError] = useState<string | null>(null)
271
  const [urlsSuccess, setUrlsSuccess] = useState<string | null>(null)
 
272
  const [manifestUrl, setManifestUrl] = useState('')
273
  const [manifestLoading, setManifestLoading] = useState(false)
274
  const [manifestError, setManifestError] = useState<string | null>(null)
275
  const [manifestSuccess, setManifestSuccess] = useState<string | null>(null)
 
276
  const [selectedFiles, setSelectedFiles] = useState<File[]>([])
277
  const [filesLoading, setFilesLoading] = useState(false)
278
  const [filesError, setFilesError] = useState<string | null>(null)
 
280
 
281
  const handleUrlsSubmit = async (e: FormEvent) => {
282
  e.preventDefault()
283
+ setUrlsError(null); setUrlsSuccess(null)
 
284
  const urls = urlsText.split('\n').map((l) => l.trim()).filter(Boolean)
285
  const labels = folioLabelsText.split('\n').map((l) => l.trim()).filter(Boolean)
286
+ if (urls.length === 0) { setUrlsError('Aucune URL.'); return }
287
+ if (labels.length !== urls.length) { setUrlsError(`Labels (${labels.length}) != URLs (${urls.length})`); return }
 
 
 
288
  setUrlsLoading(true)
289
  try {
290
  const resp = await ingestImages(corpusId, urls, labels)
291
+ setUrlsSuccess(`${resp.pages_created} page(s) ingeree(s).`)
292
+ setUrlsText(''); setFolioLabelsText('')
293
+ } catch (err) { setUrlsError(err instanceof Error ? err.message : 'Erreur') }
294
+ finally { setUrlsLoading(false) }
 
 
 
 
295
  }
296
 
297
  const handleManifestSubmit = async (e: FormEvent) => {
298
  e.preventDefault()
299
+ setManifestError(null); setManifestSuccess(null); setManifestLoading(true)
 
 
300
  try {
301
  const resp = await ingestManifest(corpusId, manifestUrl)
302
+ setManifestSuccess(`${resp.pages_created} page(s) ingeree(s).`)
303
  setManifestUrl('')
304
+ } catch (err) { setManifestError(err instanceof Error ? err.message : 'Erreur') }
305
+ finally { setManifestLoading(false) }
 
 
 
306
  }
307
 
308
  const handleFilesSubmit = async (e: FormEvent) => {
309
  e.preventDefault()
310
+ setFilesError(null); setFilesSuccess(null)
311
+ if (selectedFiles.length === 0) { setFilesError('Aucun fichier.'); return }
 
312
  setFilesLoading(true)
313
  try {
314
  const resp = await ingestFiles(corpusId, selectedFiles)
315
+ setFilesSuccess(`${resp.pages_created} page(s) ingeree(s).`)
316
  setSelectedFiles([])
317
+ } catch (err) { setFilesError(err instanceof Error ? err.message : 'Erreur') }
318
+ finally { setFilesLoading(false) }
 
 
 
319
  }
320
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  return (
322
+ <div className="flex flex-col gap-2">
323
+ <div className="flex gap-[2px]">
324
+ {(['urls', 'manifest', 'files'] as IngestSubTab[]).map((t) => (
325
+ <RetroButton key={t} size="sm" pressed={subTab === t} onClick={() => setSubTab(t)}>
326
+ {t === 'urls' ? 'URLs' : t === 'manifest' ? 'Manifest' : 'Fichiers'}
327
+ </RetroButton>
328
+ ))}
329
  </div>
330
 
331
  {subTab === 'urls' && (
332
+ <form onSubmit={(e) => void handleUrlsSubmit(e)} className="flex flex-col gap-2">
333
+ <RetroTextarea label="URLs d'images (1/ligne)" value={urlsText} onChange={(e) => setUrlsText(e.target.value)} rows={4} placeholder="https://..." />
334
+ <RetroTextarea label="Folio labels (1/ligne)" value={folioLabelsText} onChange={(e) => setFolioLabelsText(e.target.value)} rows={4} placeholder={'001r\n001v'} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  {urlsError && <ErrorMsg message={urlsError} />}
336
  {urlsSuccess && <SuccessMsg message={urlsSuccess} />}
337
+ <RetroButton type="submit" disabled={urlsLoading}>{urlsLoading ? 'Ingestion...' : 'Ingerer'}</RetroButton>
 
 
338
  </form>
339
  )}
340
 
341
  {subTab === 'manifest' && (
342
+ <form onSubmit={(e) => void handleManifestSubmit(e)} className="flex flex-col gap-2">
343
+ <RetroInput label="URL manifest IIIF" type="url" value={manifestUrl} onChange={(e) => setManifestUrl(e.target.value)} required placeholder="https://.../manifest.json" />
 
 
 
 
 
 
 
 
 
 
 
 
344
  {manifestError && <ErrorMsg message={manifestError} />}
345
  {manifestSuccess && <SuccessMsg message={manifestSuccess} />}
346
+ <RetroButton type="submit" disabled={manifestLoading || !manifestUrl}>{manifestLoading ? 'Ingestion...' : 'Importer'}</RetroButton>
 
 
347
  </form>
348
  )}
349
 
350
  {subTab === 'files' && (
351
+ <form onSubmit={(e) => void handleFilesSubmit(e)} className="flex flex-col gap-2">
352
+ <div className="flex flex-col gap-[2px]">
353
+ <label className="text-retro-xs font-bold">Fichiers images</label>
 
 
354
  <input
355
+ type="file" multiple accept="image/*"
 
 
356
  onChange={(e) => setSelectedFiles(Array.from(e.target.files ?? []))}
357
+ className="text-retro-sm font-retro border border-retro-black bg-retro-white p-1"
358
  />
359
  {selectedFiles.length > 0 && (
360
+ <span className="text-retro-xs text-retro-darkgray">{selectedFiles.length} fichier(s)</span>
361
  )}
362
  </div>
363
  {filesError && <ErrorMsg message={filesError} />}
364
  {filesSuccess && <SuccessMsg message={filesSuccess} />}
365
+ <RetroButton type="submit" disabled={filesLoading || selectedFiles.length === 0}>{filesLoading ? 'Envoi...' : 'Envoyer'}</RetroButton>
 
 
366
  </form>
367
  )}
368
+ </div>
369
  )
370
  }
371
 
372
  // ── RunPanel ──────────────────────────────────────────────────────────────
373
 
374
+ function RunPanel({ corpusId, hasModel }: { corpusId: string; hasModel: boolean }) {
 
 
 
 
 
375
  const [pageCount, setPageCount] = useState<number | null>(null)
376
  const [launching, setLaunching] = useState(false)
377
  const [launchError, setLaunchError] = useState<string | null>(null)
 
379
  const [jobs, setJobs] = useState<Record<string, Job>>({})
380
  const [polling, setPolling] = useState(false)
381
 
 
382
  useEffect(() => {
383
  fetchManuscripts(corpusId)
384
  .then(async (manuscripts) => {
 
398
  for (const job of results) map[job.id] = job
399
  setJobs(map)
400
  if (results.every((j) => j.status === 'done' || j.status === 'failed')) setPolling(false)
401
+ } catch { /* transient */ }
 
 
402
  }
403
  const id = setInterval(() => void poll(), 3000)
404
  return () => clearInterval(id)
405
  }, [polling, jobIds])
406
 
407
  const handleRun = async () => {
408
+ setLaunchError(null); setJobIds([]); setJobs({}); setLaunching(true)
 
 
 
409
  try {
410
  const resp = await runCorpus(corpusId)
411
+ setJobIds(resp.job_ids); setPolling(true)
412
+ } catch (err) { setLaunchError(err instanceof Error ? err.message : 'Erreur') }
413
+ finally { setLaunching(false) }
 
 
 
 
414
  }
415
 
416
  const handleRetryFailed = async () => {
 
425
  const failedCount = jobList.filter((j) => j.status === 'failed').length
426
  const totalCount = jobList.length
427
 
428
+ const statusVariant = (s: string): 'default' | 'success' | 'warning' | 'error' | 'info' => {
429
+ if (s === 'done') return 'success'
430
+ if (s === 'failed') return 'error'
431
+ if (s === 'running') return 'info'
432
+ return 'default'
 
 
 
 
 
 
 
433
  }
434
 
435
  if (!hasModel) {
436
+ return <div className="text-retro-sm border border-retro-black p-2 bg-retro-white">Configurez d'abord un modele IA.</div>
 
 
 
 
437
  }
438
 
439
  return (
440
+ <div className="flex flex-col gap-2">
441
  {pageCount !== null && (
442
+ <div className="text-retro-sm">{pageCount === 0 ? 'Aucune page ingeree.' : `${pageCount} page(s).`}</div>
 
 
 
 
443
  )}
 
444
  {launchError && <ErrorMsg message={launchError} />}
445
+ <div className="flex flex-wrap gap-[2px]">
446
+ <RetroButton onClick={() => void handleRun()} disabled={launching || polling || pageCount === 0}>
447
+ {launching ? 'Demarrage...' : polling ? 'En cours...' : 'Analyser tout'}
448
+ </RetroButton>
 
 
 
 
 
 
449
  {failedCount > 0 && !polling && (
450
+ <RetroButton onClick={() => void handleRetryFailed()}>
451
+ Relancer {failedCount} erreur(s)
452
+ </RetroButton>
 
 
 
453
  )}
454
  </div>
 
455
  {totalCount > 0 && (
456
  <div>
457
+ <div className="text-retro-sm mb-1">
458
+ <span className="font-bold">{doneCount}</span>/{totalCount} traitees
459
+ {failedCount > 0 && <span className="ml-2 font-bold">{failedCount} erreur(s)</span>}
460
+ {polling && <span className="ml-2 text-retro-darkgray">(actualisation 3s)</span>}
461
+ </div>
462
+ <div className="border border-retro-black bg-retro-white max-h-48 overflow-y-auto retro-scroll">
463
  {jobList.map((job) => (
464
+ <div key={job.id} className="flex items-center justify-between text-retro-xs px-2 py-[2px] border-b border-retro-gray last:border-0">
465
+ <span className="truncate max-w-[200px]">{job.page_id ?? job.id}</span>
466
+ <div className="flex items-center gap-1 shrink-0">
467
+ <RetroBadge variant={statusVariant(job.status)}>{job.status}</RetroBadge>
468
+ {job.error_message && <span className="text-retro-xs truncate max-w-[120px]" title={job.error_message}>{job.error_message}</span>}
 
 
 
 
 
 
 
469
  </div>
470
+ </div>
471
  ))}
472
+ </div>
473
  </div>
474
  )}
475
  </div>
 
478
 
479
  // ── CorpusDetail ──────────────────────────────────────────────────────────
480
 
481
+ function CorpusDetail({ corpus, onDeleted }: { corpus: Corpus; onDeleted: () => void }) {
 
 
 
 
 
482
  const [hasModel, setHasModel] = useState(false)
483
  const [deleting, setDeleting] = useState(false)
484
  const [deleteError, setDeleteError] = useState<string | null>(null)
485
  const [confirmDelete, setConfirmDelete] = useState(false)
486
 
487
  useEffect(() => {
488
+ getCorpusModel(corpus.id).then((m) => setHasModel(m !== null)).catch(() => {})
 
 
489
  }, [corpus.id])
490
 
491
  const handleDelete = async () => {
492
+ setDeleteError(null); setDeleting(true)
493
+ try { await deleteCorpus(corpus.id); onDeleted() }
494
+ catch (err) { setDeleteError(err instanceof Error ? err.message : 'Erreur'); setDeleting(false); setConfirmDelete(false) }
 
 
 
 
 
 
 
495
  }
496
 
497
  return (
498
+ <div className="flex flex-col gap-2">
499
+ {/* Header */}
500
+ <div className="flex items-center justify-between border border-retro-black bg-retro-light p-2">
501
  <div>
502
+ <span className="text-retro-lg font-bold">{corpus.title}</span>
503
+ <div className="text-retro-xs text-retro-darkgray">{corpus.slug} — {corpus.profile_id}</div>
 
 
 
 
504
  </div>
505
+ <div className="flex items-center gap-1">
506
+ {deleteError && <span className="text-retro-xs">{deleteError}</span>}
507
  {confirmDelete ? (
508
  <>
509
+ <span className="text-retro-xs">Confirmer?</span>
510
+ <RetroButton size="sm" onClick={() => void handleDelete()} disabled={deleting}>
511
+ {deleting ? '...' : 'Oui'}
512
+ </RetroButton>
513
+ <RetroButton size="sm" onClick={() => setConfirmDelete(false)}>Non</RetroButton>
 
 
 
 
 
 
 
 
 
514
  </>
515
  ) : (
516
+ <RetroButton size="sm" onClick={() => setConfirmDelete(true)}>Supprimer</RetroButton>
 
 
 
 
 
517
  )}
518
  </div>
519
  </div>
520
 
521
+ <RetroWindow title="Modele IA">
522
+ <div className="p-2">
523
+ <ModelPanel key={corpus.id} corpusId={corpus.id} onSaved={() => setHasModel(true)} />
524
+ </div>
525
+ </RetroWindow>
 
 
 
526
 
527
+ <RetroWindow title="Ingestion">
528
+ <div className="p-2">
529
+ <IngestPanel key={corpus.id} corpusId={corpus.id} />
530
+ </div>
531
+ </RetroWindow>
532
 
533
+ <RetroWindow title="Traitement">
534
+ <div className="p-2">
535
+ <RunPanel key={corpus.id} corpusId={corpus.id} hasModel={hasModel} />
536
+ </div>
537
+ </RetroWindow>
538
  </div>
539
  )
540
  }
541
 
542
+ // ── Admin (main component) ─────────────────────────────────────────────────
543
 
544
  export default function Admin({ onHome }: Props) {
545
  const [corpora, setCorpora] = useState<Corpus[]>([])
 
551
  fetchCorpora()
552
  .then((cs) => {
553
  setCorpora(cs)
554
+ if (selectId) { setSelectedCorpusId(selectId); setShowCreate(false) }
555
+ else if (!didInit.current) {
 
 
556
  didInit.current = true
557
+ if (cs.length > 0) { setSelectedCorpusId(cs[0].id); setShowCreate(false) }
558
+ else setShowCreate(true)
 
 
 
 
559
  }
560
  })
561
  .catch(() => {})
562
  }
563
 
564
+ useEffect(() => { refreshCorpora() }, [])
 
 
565
 
566
  const selectedCorpus = corpora.find((c) => c.id === selectedCorpusId) ?? null
567
 
568
  return (
569
+ <div className="h-screen flex flex-col bg-retro-dither">
570
+ <RetroMenuBar
571
+ items={[
572
+ { label: 'IIIF Studio', onClick: onHome },
573
+ { label: 'Administration' },
574
+ ]}
575
+ />
576
+
577
+ <div className="flex flex-1 overflow-hidden p-1 gap-1">
 
 
 
 
578
  {/* Sidebar */}
579
+ <RetroWindow title="Corpus" className="w-56 shrink-0" scrollable>
580
+ <div className="flex flex-col">
581
  <button
582
  onClick={() => { setShowCreate(true); setSelectedCorpusId(null) }}
583
+ className={`
584
+ w-full text-left px-2 py-[4px] text-retro-sm font-bold
585
+ border-b border-retro-gray
586
+ ${showCreate && !selectedCorpusId ? 'bg-retro-select text-retro-select-text' : 'hover:bg-retro-select hover:text-retro-select-text'}
587
+ `}
588
  >
589
  + Nouveau corpus
590
  </button>
 
 
591
  {corpora.length === 0 && (
592
+ <div className="px-2 py-2 text-retro-xs text-retro-darkgray">Aucun corpus</div>
593
  )}
594
  {corpora.map((c) => (
595
  <button
596
  key={c.id}
597
  onClick={() => { setSelectedCorpusId(c.id); setShowCreate(false) }}
598
+ className={`
599
+ w-full text-left px-2 py-[4px] text-retro-sm
600
+ border-b border-retro-gray
601
+ ${selectedCorpusId === c.id && !showCreate
602
+ ? 'bg-retro-select text-retro-select-text'
603
+ : 'hover:bg-retro-select hover:text-retro-select-text'}
604
+ `}
605
  >
606
+ <div className="truncate font-bold">{c.title}</div>
607
+ <div className={`truncate text-retro-xs ${selectedCorpusId === c.id && !showCreate ? 'opacity-70' : 'text-retro-darkgray'}`}>
608
+ {c.slug}
609
+ </div>
610
  </button>
611
  ))}
612
+ </div>
613
+ </RetroWindow>
614
 
615
  {/* Main panel */}
616
+ <div className="flex-1 overflow-y-auto retro-scroll p-2">
617
  {showCreate && !selectedCorpusId && (
618
+ <CreateCorpusPanel onCreated={(corpus) => refreshCorpora(corpus.id)} />
 
 
 
 
619
  )}
620
  {!showCreate && selectedCorpus && (
621
  <CorpusDetail
 
624
  onDeleted={() => {
625
  const remaining = corpora.filter((c) => c.id !== selectedCorpus.id)
626
  setCorpora(remaining)
627
+ if (remaining.length > 0) { setSelectedCorpusId(remaining[0].id); setShowCreate(false) }
628
+ else { setSelectedCorpusId(null); setShowCreate(true) }
 
 
 
 
 
629
  }}
630
  />
631
  )}
632
  {!showCreate && !selectedCorpus && corpora.length > 0 && (
633
+ <div className="text-retro-sm text-retro-darkgray p-2">Selectionnez un corpus.</div>
634
  )}
635
+ </div>
636
  </div>
637
  </div>
638
  )