maribakulj commited on
Commit
5427296
Β·
unverified Β·
2 Parent(s): 7637ca8e861556

Merge pull request #32 from maribakulj/claude/find-commit-dcec666-sI5Qn

Browse files
.gitignore CHANGED
@@ -5,6 +5,10 @@ data/
5
  *.db
6
  *.sqlite
7
 
 
 
 
 
8
  # Python
9
  __pycache__/
10
  *.py[cod]
 
5
  *.db
6
  *.sqlite
7
 
8
+ # Node / Frontend
9
+ node_modules/
10
+ package-lock.json
11
+
12
  # Python
13
  __pycache__/
14
  *.py[cod]
frontend/src/components/AdminNav.tsx CHANGED
@@ -1,14 +1,13 @@
 
 
1
  interface AdminNavProps {
2
  onClick: () => void
3
  }
4
 
5
  export default function AdminNav({ onClick }: AdminNavProps) {
6
  return (
7
- <button
8
- onClick={onClick}
9
- className="text-stone-400 hover:text-stone-100 text-sm transition-colors"
10
- >
11
- Administration
12
- </button>
13
  )
14
  }
 
1
+ import { RetroButton } from './retro'
2
+
3
  interface AdminNavProps {
4
  onClick: () => void
5
  }
6
 
7
  export default function AdminNav({ onClick }: AdminNavProps) {
8
  return (
9
+ <RetroButton size="sm" onClick={onClick}>
10
+ Admin
11
+ </RetroButton>
 
 
 
12
  )
13
  }
frontend/src/components/CommentaryPanel.tsx CHANGED
@@ -1,20 +1,21 @@
1
  import { useState, type FC } from 'react'
2
  import type { Commentary, EditorialInfo, EditorialStatus } from '../lib/api.ts'
 
3
 
4
  const STATUS_LABELS: Record<EditorialStatus, string> = {
5
  machine_draft: 'Brouillon IA',
6
- needs_review: 'Γ€ rΓ©viser',
7
- reviewed: 'RΓ©visΓ©',
8
- validated: 'ValidΓ©',
9
- published: 'PubliΓ©',
10
  }
11
 
12
- const STATUS_COLORS: Record<EditorialStatus, string> = {
13
- machine_draft: 'bg-amber-100 text-amber-700',
14
- needs_review: 'bg-orange-100 text-orange-700',
15
- reviewed: 'bg-blue-100 text-blue-700',
16
- validated: 'bg-green-100 text-green-700',
17
- published: 'bg-emerald-100 text-emerald-700',
18
  }
19
 
20
  interface Props {
@@ -29,7 +30,6 @@ const CommentaryPanel: FC<Props> = ({ commentary, editorial, visiblePublic, visi
29
 
30
  if (!visiblePublic && !visibleScholarly) return null
31
 
32
- // Si une seule couche est visible, forcer l'onglet correspondant
33
  const activeTab: 'public' | 'scholarly' =
34
  !visiblePublic && visibleScholarly ? 'scholarly' :
35
  !visibleScholarly && visiblePublic ? 'public' :
@@ -39,40 +39,39 @@ const CommentaryPanel: FC<Props> = ({ commentary, editorial, visiblePublic, visi
39
  const bothVisible = visiblePublic && visibleScholarly
40
 
41
  return (
42
- <div className="px-4 py-4">
43
- <div className="flex items-center justify-between mb-3">
44
- <h3 className="text-xs font-semibold text-stone-500 uppercase tracking-wide">
45
- Commentaire
46
- </h3>
47
- <span
48
- className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[editorial.status]}`}
49
- >
50
  {STATUS_LABELS[editorial.status]}
51
- </span>
52
  </div>
53
 
54
  {bothVisible && (
55
- <div className="flex gap-2 mb-3">
56
- {(['public', 'scholarly'] as const).map((t) => (
57
- <button
58
- key={t}
59
- onClick={() => setTab(t)}
60
- className={`text-xs px-3 py-1 rounded transition-colors ${
61
- activeTab === t
62
- ? 'bg-stone-800 text-white'
63
- : 'bg-stone-100 text-stone-600 hover:bg-stone-200'
64
- }`}
65
- >
66
- {t === 'public' ? 'Public' : 'Savant'}
67
- </button>
68
- ))}
 
69
  </div>
70
  )}
71
 
72
  {content ? (
73
- <p className="text-sm text-stone-800 leading-relaxed whitespace-pre-wrap">{content}</p>
 
 
74
  ) : (
75
- <p className="text-sm text-stone-400 italic">Commentaire non disponible.</p>
76
  )}
77
  </div>
78
  )
 
1
  import { useState, type FC } from 'react'
2
  import type { Commentary, EditorialInfo, EditorialStatus } from '../lib/api.ts'
3
+ import { RetroBadge, RetroButton } from './retro'
4
 
5
  const STATUS_LABELS: Record<EditorialStatus, string> = {
6
  machine_draft: 'Brouillon IA',
7
+ needs_review: 'A reviser',
8
+ reviewed: 'Revise',
9
+ validated: 'Valide',
10
+ published: 'Publie',
11
  }
12
 
13
+ const STATUS_VARIANTS: Record<EditorialStatus, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
14
+ machine_draft: 'info',
15
+ needs_review: 'warning',
16
+ reviewed: 'default',
17
+ validated: 'success',
18
+ published: 'success',
19
  }
20
 
21
  interface Props {
 
30
 
31
  if (!visiblePublic && !visibleScholarly) return null
32
 
 
33
  const activeTab: 'public' | 'scholarly' =
34
  !visiblePublic && visibleScholarly ? 'scholarly' :
35
  !visibleScholarly && visiblePublic ? 'public' :
 
39
  const bothVisible = visiblePublic && visibleScholarly
40
 
41
  return (
42
+ <div className="p-2">
43
+ <div className="flex items-center justify-between mb-2">
44
+ <span className="text-retro-xs font-bold">Commentaire</span>
45
+ <RetroBadge variant={STATUS_VARIANTS[editorial.status]}>
 
 
 
 
46
  {STATUS_LABELS[editorial.status]}
47
+ </RetroBadge>
48
  </div>
49
 
50
  {bothVisible && (
51
+ <div className="flex gap-[2px] mb-2">
52
+ <RetroButton
53
+ size="sm"
54
+ pressed={activeTab === 'public'}
55
+ onClick={() => setTab('public')}
56
+ >
57
+ Public
58
+ </RetroButton>
59
+ <RetroButton
60
+ size="sm"
61
+ pressed={activeTab === 'scholarly'}
62
+ onClick={() => setTab('scholarly')}
63
+ >
64
+ Savant
65
+ </RetroButton>
66
  </div>
67
  )}
68
 
69
  {content ? (
70
+ <p className="text-retro-sm whitespace-pre-wrap font-retro leading-relaxed">
71
+ {content}
72
+ </p>
73
  ) : (
74
+ <p className="text-retro-sm text-retro-darkgray">Commentaire non disponible.</p>
75
  )}
76
  </div>
77
  )
frontend/src/components/LayerPanel.tsx CHANGED
@@ -1,16 +1,17 @@
1
  import type { FC } from 'react'
 
2
 
3
  const LAYER_LABELS: Record<string, string> = {
4
  image: 'Image',
5
- ocr_diplomatic: 'Transcription diplomatique',
6
- ocr_normalized: 'Transcription normalisΓ©e',
7
- translation_fr: 'Traduction (FR)',
8
- translation_en: 'Traduction (EN)',
9
- summary: 'RΓ©sumΓ©',
10
- scholarly_commentary: 'Commentaire savant',
11
- public_commentary: 'Commentaire public',
12
  iconography_detection: 'Iconographie',
13
- material_notes: 'Notes matΓ©rielles',
14
  uncertainty: 'Incertitudes',
15
  }
16
 
@@ -21,24 +22,16 @@ interface Props {
21
  }
22
 
23
  const LayerPanel: FC<Props> = ({ activeLayers, visibleLayers, onToggle }) => (
24
- <div className="border-b border-stone-200 bg-stone-50 px-4 py-3 shrink-0">
25
- <h3 className="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-2">
26
- Couches
27
- </h3>
28
- <div className="flex flex-wrap gap-x-4 gap-y-1.5">
29
  {activeLayers.map((layer) => (
30
- <label
31
  key={layer}
32
- className="flex items-center gap-1.5 text-xs text-stone-700 cursor-pointer select-none"
33
- >
34
- <input
35
- type="checkbox"
36
- checked={visibleLayers.has(layer)}
37
- onChange={() => onToggle(layer)}
38
- className="rounded border-stone-300 text-stone-700 focus:ring-stone-500"
39
- />
40
- {LAYER_LABELS[layer] ?? layer}
41
- </label>
42
  ))}
43
  </div>
44
  </div>
 
1
  import type { FC } from 'react'
2
+ import { RetroCheckbox } from './retro'
3
 
4
  const LAYER_LABELS: Record<string, string> = {
5
  image: 'Image',
6
+ ocr_diplomatic: 'Transcription',
7
+ ocr_normalized: 'Normalise',
8
+ translation_fr: 'Traduction FR',
9
+ translation_en: 'Traduction EN',
10
+ summary: 'Resume',
11
+ scholarly_commentary: 'Comm. savant',
12
+ public_commentary: 'Comm. public',
13
  iconography_detection: 'Iconographie',
14
+ material_notes: 'Notes mat.',
15
  uncertainty: 'Incertitudes',
16
  }
17
 
 
22
  }
23
 
24
  const LayerPanel: FC<Props> = ({ activeLayers, visibleLayers, onToggle }) => (
25
+ <div className="px-2 py-2 shrink-0 border-b border-retro-black bg-retro-gray">
26
+ <div className="text-retro-xs font-bold mb-1">Couches</div>
27
+ <div className="flex flex-wrap gap-x-3 gap-y-1">
 
 
28
  {activeLayers.map((layer) => (
29
+ <RetroCheckbox
30
  key={layer}
31
+ label={LAYER_LABELS[layer] ?? layer}
32
+ checked={visibleLayers.has(layer)}
33
+ onChange={() => onToggle(layer)}
34
+ />
 
 
 
 
 
 
35
  ))}
36
  </div>
37
  </div>
frontend/src/components/SearchBar.tsx CHANGED
@@ -41,7 +41,6 @@ export default function SearchBar({ onSelectResult }: Props) {
41
  }
42
  }, [query, runSearch])
43
 
44
- // Close dropdown on outside click
45
  useEffect(() => {
46
  const handler = (e: MouseEvent) => {
47
  if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
@@ -53,27 +52,45 @@ export default function SearchBar({ onSelectResult }: Props) {
53
  }, [])
54
 
55
  return (
56
- <div ref={containerRef} className="relative w-72">
57
  <div className="relative">
58
  <input
59
  type="search"
60
  value={query}
61
  onChange={(e) => setQuery(e.target.value)}
62
  onFocus={() => results.length > 0 && setOpen(true)}
63
- placeholder="Rechercher dans les manuscrits…"
64
- className="w-full bg-stone-800 text-stone-100 placeholder-stone-500 text-sm px-3 py-1.5 pr-8 rounded-md border border-stone-700 focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500"
 
 
 
 
 
 
 
65
  />
66
  {loading && (
67
- <span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-stone-400 text-xs">
68
- …
69
  </span>
70
  )}
71
  </div>
72
 
73
  {open && (
74
- <div className="absolute top-full left-0 right-0 mt-1 bg-white border border-stone-200 rounded-lg shadow-xl z-50 max-h-80 overflow-y-auto">
 
 
 
 
 
 
 
 
 
75
  {results.length === 0 ? (
76
- <div className="px-4 py-3 text-sm text-stone-400 italic">Aucun rΓ©sultat.</div>
 
 
77
  ) : (
78
  <ul>
79
  {results.map((r) => (
@@ -83,17 +100,20 @@ export default function SearchBar({ onSelectResult }: Props) {
83
  setOpen(false)
84
  onSelectResult?.(r)
85
  }}
86
- className="w-full text-left px-4 py-3 hover:bg-amber-50 border-b border-stone-100 last:border-0 transition-colors"
 
 
 
 
 
87
  >
88
  <div className="flex items-center justify-between gap-2">
89
- <span className="font-medium text-stone-800 text-sm">
90
- {r.folio_label}
91
- </span>
92
- <span className="text-xs text-stone-400 shrink-0">
93
- score : {r.score}
94
  </span>
95
  </div>
96
- <div className="text-xs text-stone-500 mt-0.5 truncate">
97
  {r.excerpt}
98
  </div>
99
  </button>
 
41
  }
42
  }, [query, runSearch])
43
 
 
44
  useEffect(() => {
45
  const handler = (e: MouseEvent) => {
46
  if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
 
52
  }, [])
53
 
54
  return (
55
+ <div ref={containerRef} className="relative w-64">
56
  <div className="relative">
57
  <input
58
  type="search"
59
  value={query}
60
  onChange={(e) => setQuery(e.target.value)}
61
  onFocus={() => results.length > 0 && setOpen(true)}
62
+ placeholder="Rechercher..."
63
+ className="
64
+ w-full px-2 py-[2px]
65
+ text-retro-sm font-retro
66
+ bg-retro-white text-retro-black
67
+ border border-retro-black
68
+ shadow-retro-well
69
+ placeholder:text-retro-darkgray
70
+ "
71
  />
72
  {loading && (
73
+ <span className="absolute right-2 top-1/2 -translate-y-1/2 text-retro-darkgray text-retro-xs">
74
+ ...
75
  </span>
76
  )}
77
  </div>
78
 
79
  {open && (
80
+ <div
81
+ className="
82
+ absolute top-full left-0 right-0 mt-[2px]
83
+ bg-retro-white
84
+ border-retro border-retro-black
85
+ shadow-retro-lg
86
+ z-50 max-h-60
87
+ overflow-y-auto retro-scroll
88
+ "
89
+ >
90
  {results.length === 0 ? (
91
+ <div className="px-2 py-2 text-retro-xs text-retro-darkgray">
92
+ Aucun resultat.
93
+ </div>
94
  ) : (
95
  <ul>
96
  {results.map((r) => (
 
100
  setOpen(false)
101
  onSelectResult?.(r)
102
  }}
103
+ className="
104
+ w-full text-left px-2 py-[3px]
105
+ text-retro-sm font-retro
106
+ hover:bg-retro-select hover:text-retro-select-text
107
+ border-b border-retro-gray last:border-0
108
+ "
109
  >
110
  <div className="flex items-center justify-between gap-2">
111
+ <span className="font-bold">{r.folio_label}</span>
112
+ <span className="text-retro-xs opacity-60">
113
+ {r.score}
 
 
114
  </span>
115
  </div>
116
+ <div className="text-retro-xs truncate opacity-70">
117
  {r.excerpt}
118
  </div>
119
  </button>
frontend/src/components/TranscriptionPanel.tsx CHANGED
@@ -1,20 +1,21 @@
1
  import type { FC } from 'react'
2
  import type { OCRResult, EditorialInfo, EditorialStatus } from '../lib/api.ts'
 
3
 
4
  const STATUS_LABELS: Record<EditorialStatus, string> = {
5
  machine_draft: 'Brouillon IA',
6
- needs_review: 'Γ€ rΓ©viser',
7
- reviewed: 'RΓ©visΓ©',
8
- validated: 'ValidΓ©',
9
- published: 'PubliΓ©',
10
  }
11
 
12
- const STATUS_COLORS: Record<EditorialStatus, string> = {
13
- machine_draft: 'bg-amber-100 text-amber-700',
14
- needs_review: 'bg-orange-100 text-orange-700',
15
- reviewed: 'bg-blue-100 text-blue-700',
16
- validated: 'bg-green-100 text-green-700',
17
- published: 'bg-emerald-100 text-emerald-700',
18
  }
19
 
20
  interface Props {
@@ -27,34 +28,30 @@ const TranscriptionPanel: FC<Props> = ({ ocr, editorial, visible }) => {
27
  if (!visible) return null
28
 
29
  return (
30
- <div className="px-4 py-4">
31
- <div className="flex items-center justify-between mb-3">
32
- <h3 className="text-xs font-semibold text-stone-500 uppercase tracking-wide">
33
- Transcription diplomatique
34
- </h3>
35
- <span
36
- className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[editorial.status]}`}
37
- >
38
  {STATUS_LABELS[editorial.status]}
39
- </span>
40
  </div>
41
  {ocr ? (
42
  <div>
43
  {ocr.diplomatic_text ? (
44
- <p className="text-sm text-stone-800 leading-relaxed font-serif whitespace-pre-wrap">
45
  {ocr.diplomatic_text}
46
  </p>
47
  ) : (
48
- <p className="text-sm text-stone-400 italic">Texte vide.</p>
49
  )}
50
  {ocr.confidence > 0 && (
51
- <div className="mt-2 text-xs text-stone-400">
52
- Confiance OCR : {(ocr.confidence * 100).toFixed(0)} %
53
  </div>
54
  )}
55
  </div>
56
  ) : (
57
- <p className="text-sm text-stone-400 italic">Transcription non disponible.</p>
58
  )}
59
  </div>
60
  )
 
1
  import type { FC } from 'react'
2
  import type { OCRResult, EditorialInfo, EditorialStatus } from '../lib/api.ts'
3
+ import { RetroBadge } from './retro'
4
 
5
  const STATUS_LABELS: Record<EditorialStatus, string> = {
6
  machine_draft: 'Brouillon IA',
7
+ needs_review: 'A reviser',
8
+ reviewed: 'Revise',
9
+ validated: 'Valide',
10
+ published: 'Publie',
11
  }
12
 
13
+ const STATUS_VARIANTS: Record<EditorialStatus, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
14
+ machine_draft: 'info',
15
+ needs_review: 'warning',
16
+ reviewed: 'default',
17
+ validated: 'success',
18
+ published: 'success',
19
  }
20
 
21
  interface Props {
 
28
  if (!visible) return null
29
 
30
  return (
31
+ <div className="p-2">
32
+ <div className="flex items-center justify-between mb-2">
33
+ <span className="text-retro-xs font-bold">Transcription diplomatique</span>
34
+ <RetroBadge variant={STATUS_VARIANTS[editorial.status]}>
 
 
 
 
35
  {STATUS_LABELS[editorial.status]}
36
+ </RetroBadge>
37
  </div>
38
  {ocr ? (
39
  <div>
40
  {ocr.diplomatic_text ? (
41
+ <p className="text-retro-sm whitespace-pre-wrap font-retro leading-relaxed">
42
  {ocr.diplomatic_text}
43
  </p>
44
  ) : (
45
+ <p className="text-retro-sm text-retro-darkgray">Texte vide.</p>
46
  )}
47
  {ocr.confidence > 0 && (
48
+ <div className="mt-2 text-retro-xs text-retro-darkgray">
49
+ Confiance : {(ocr.confidence * 100).toFixed(0)}% β€” Langue : {ocr.language}
50
  </div>
51
  )}
52
  </div>
53
  ) : (
54
+ <p className="text-retro-sm text-retro-darkgray">Transcription non disponible.</p>
55
  )}
56
  </div>
57
  )
frontend/src/components/TranslationPanel.tsx CHANGED
@@ -1,20 +1,21 @@
1
  import type { FC } from 'react'
2
  import type { Translation, EditorialInfo, EditorialStatus } from '../lib/api.ts'
 
3
 
4
  const STATUS_LABELS: Record<EditorialStatus, string> = {
5
  machine_draft: 'Brouillon IA',
6
- needs_review: 'Γ€ rΓ©viser',
7
- reviewed: 'RΓ©visΓ©',
8
- validated: 'ValidΓ©',
9
- published: 'PubliΓ©',
10
  }
11
 
12
- const STATUS_COLORS: Record<EditorialStatus, string> = {
13
- machine_draft: 'bg-amber-100 text-amber-700',
14
- needs_review: 'bg-orange-100 text-orange-700',
15
- reviewed: 'bg-blue-100 text-blue-700',
16
- validated: 'bg-green-100 text-green-700',
17
- published: 'bg-emerald-100 text-emerald-700',
18
  }
19
 
20
  interface Props {
@@ -27,23 +28,19 @@ const TranslationPanel: FC<Props> = ({ translation, editorial, visible }) => {
27
  if (!visible) return null
28
 
29
  return (
30
- <div className="px-4 py-4">
31
- <div className="flex items-center justify-between mb-3">
32
- <h3 className="text-xs font-semibold text-stone-500 uppercase tracking-wide">
33
- Traduction (FR)
34
- </h3>
35
- <span
36
- className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[editorial.status]}`}
37
- >
38
  {STATUS_LABELS[editorial.status]}
39
- </span>
40
  </div>
41
  {translation?.fr ? (
42
- <p className="text-sm text-stone-800 leading-relaxed whitespace-pre-wrap">
43
  {translation.fr}
44
  </p>
45
  ) : (
46
- <p className="text-sm text-stone-400 italic">Traduction non disponible.</p>
47
  )}
48
  </div>
49
  )
 
1
  import type { FC } from 'react'
2
  import type { Translation, EditorialInfo, EditorialStatus } from '../lib/api.ts'
3
+ import { RetroBadge } from './retro'
4
 
5
  const STATUS_LABELS: Record<EditorialStatus, string> = {
6
  machine_draft: 'Brouillon IA',
7
+ needs_review: 'A reviser',
8
+ reviewed: 'Revise',
9
+ validated: 'Valide',
10
+ published: 'Publie',
11
  }
12
 
13
+ const STATUS_VARIANTS: Record<EditorialStatus, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
14
+ machine_draft: 'info',
15
+ needs_review: 'warning',
16
+ reviewed: 'default',
17
+ validated: 'success',
18
+ published: 'success',
19
  }
20
 
21
  interface Props {
 
28
  if (!visible) return null
29
 
30
  return (
31
+ <div className="p-2">
32
+ <div className="flex items-center justify-between mb-2">
33
+ <span className="text-retro-xs font-bold">Traduction (FR)</span>
34
+ <RetroBadge variant={STATUS_VARIANTS[editorial.status]}>
 
 
 
 
35
  {STATUS_LABELS[editorial.status]}
36
+ </RetroBadge>
37
  </div>
38
  {translation?.fr ? (
39
+ <p className="text-retro-sm whitespace-pre-wrap font-retro leading-relaxed">
40
  {translation.fr}
41
  </p>
42
  ) : (
43
+ <p className="text-retro-sm text-retro-darkgray">Traduction non disponible.</p>
44
  )}
45
  </div>
46
  )
frontend/src/components/Viewer.tsx CHANGED
@@ -1,5 +1,6 @@
1
  import { useEffect, useRef, type FC } from 'react'
2
  import OpenSeadragon from 'openseadragon'
 
3
 
4
  interface Props {
5
  imageUrl: string
@@ -10,7 +11,6 @@ const Viewer: FC<Props> = ({ imageUrl, onViewerReady }) => {
10
  const containerRef = useRef<HTMLDivElement>(null)
11
  const viewerRef = useRef<OpenSeadragon.Viewer | null>(null)
12
 
13
- // Initialise OSD une seule fois
14
  useEffect(() => {
15
  if (!containerRef.current) return
16
 
@@ -32,7 +32,6 @@ const Viewer: FC<Props> = ({ imageUrl, onViewerReady }) => {
32
  }
33
  }, [])
34
 
35
- // Ouvre l'image Γ  chaque changement d'URL
36
  useEffect(() => {
37
  const viewer = viewerRef.current
38
  if (!viewer || !imageUrl) return
@@ -42,40 +41,39 @@ const Viewer: FC<Props> = ({ imageUrl, onViewerReady }) => {
42
  onViewerReady?.(viewer)
43
  })
44
  }, [imageUrl]) // eslint-disable-line react-hooks/exhaustive-deps
45
- // onViewerReady est intentionnellement exclu : c'est un callback stable
46
 
47
  return (
48
- <div className="relative w-full h-full bg-stone-800">
49
  <div ref={containerRef} className="w-full h-full" />
50
- <div className="absolute bottom-3 right-3 flex gap-1.5">
51
- <button
 
52
  onClick={() => viewerRef.current?.viewport.zoomBy(1.5)}
53
- className="w-8 h-8 bg-stone-800/80 text-stone-200 rounded hover:bg-stone-700 text-sm font-bold"
54
  title="Zoom +"
55
  >
56
  +
57
- </button>
58
- <button
 
59
  onClick={() => viewerRef.current?.viewport.zoomBy(0.67)}
60
- className="w-8 h-8 bg-stone-800/80 text-stone-200 rounded hover:bg-stone-700 text-sm font-bold"
61
- title="Zoom βˆ’"
62
  >
63
- βˆ’
64
- </button>
65
- <button
 
66
  onClick={() => viewerRef.current?.viewport.goHome()}
67
- className="w-8 h-8 bg-stone-800/80 text-stone-200 rounded hover:bg-stone-700 text-xs"
68
- title="RΓ©initialiser"
69
  >
70
- βŠ™
71
- </button>
72
- <button
 
73
  onClick={() => viewerRef.current?.setFullScreen(true)}
74
- className="w-8 h-8 bg-stone-800/80 text-stone-200 rounded hover:bg-stone-700 text-xs"
75
- title="Plein Γ©cran"
76
  >
77
- β›Ά
78
- </button>
79
  </div>
80
  </div>
81
  )
 
1
  import { useEffect, useRef, type FC } from 'react'
2
  import OpenSeadragon from 'openseadragon'
3
+ import { RetroButton } from './retro'
4
 
5
  interface Props {
6
  imageUrl: string
 
11
  const containerRef = useRef<HTMLDivElement>(null)
12
  const viewerRef = useRef<OpenSeadragon.Viewer | null>(null)
13
 
 
14
  useEffect(() => {
15
  if (!containerRef.current) return
16
 
 
32
  }
33
  }, [])
34
 
 
35
  useEffect(() => {
36
  const viewer = viewerRef.current
37
  if (!viewer || !imageUrl) return
 
41
  onViewerReady?.(viewer)
42
  })
43
  }, [imageUrl]) // eslint-disable-line react-hooks/exhaustive-deps
 
44
 
45
  return (
46
+ <div className="relative w-full h-full bg-retro-black">
47
  <div ref={containerRef} className="w-full h-full" />
48
+ <div className="absolute bottom-2 right-2 flex gap-[2px]">
49
+ <RetroButton
50
+ size="sm"
51
  onClick={() => viewerRef.current?.viewport.zoomBy(1.5)}
 
52
  title="Zoom +"
53
  >
54
  +
55
+ </RetroButton>
56
+ <RetroButton
57
+ size="sm"
58
  onClick={() => viewerRef.current?.viewport.zoomBy(0.67)}
59
+ title="Zoom -"
 
60
  >
61
+ -
62
+ </RetroButton>
63
+ <RetroButton
64
+ size="sm"
65
  onClick={() => viewerRef.current?.viewport.goHome()}
66
+ title="Reset"
 
67
  >
68
+ o
69
+ </RetroButton>
70
+ <RetroButton
71
+ size="sm"
72
  onClick={() => viewerRef.current?.setFullScreen(true)}
73
+ title="Plein ecran"
 
74
  >
75
+ []
76
+ </RetroButton>
77
  </div>
78
  </div>
79
  )
frontend/src/components/retro/RetroBadge.tsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from 'react'
2
+
3
+ type Variant = 'default' | 'success' | 'warning' | 'error' | 'info'
4
+
5
+ interface Props {
6
+ children: ReactNode
7
+ variant?: Variant
8
+ className?: string
9
+ }
10
+
11
+ const variantStyles: Record<Variant, string> = {
12
+ default: 'bg-retro-gray text-retro-black',
13
+ success: 'bg-retro-black text-retro-white',
14
+ warning: 'bg-retro-white text-retro-black border-dashed',
15
+ error: 'bg-retro-white text-retro-black font-bold',
16
+ info: 'bg-retro-light text-retro-black',
17
+ }
18
+
19
+ export default function RetroBadge({
20
+ children,
21
+ variant = 'default',
22
+ className = '',
23
+ }: Props) {
24
+ return (
25
+ <span
26
+ className={`
27
+ inline-block
28
+ px-2 py-[1px]
29
+ text-retro-xs font-retro
30
+ border border-retro-black
31
+ ${variantStyles[variant]}
32
+ ${className}
33
+ `}
34
+ >
35
+ {children}
36
+ </span>
37
+ )
38
+ }
frontend/src/components/retro/RetroButton.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode, ButtonHTMLAttributes } from 'react'
2
+
3
+ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
4
+ children: ReactNode
5
+ /** Render as a small compact button */
6
+ size?: 'sm' | 'md'
7
+ /** If true, renders in "pressed" state */
8
+ pressed?: boolean
9
+ }
10
+
11
+ export default function RetroButton({
12
+ children,
13
+ size = 'md',
14
+ pressed = false,
15
+ className = '',
16
+ disabled,
17
+ ...rest
18
+ }: Props) {
19
+ const padding = size === 'sm' ? 'px-2 py-[1px]' : 'px-3 py-[3px]'
20
+ const fontSize = size === 'sm' ? 'text-retro-xs' : 'text-retro-sm'
21
+
22
+ return (
23
+ <button
24
+ className={`
25
+ ${padding} ${fontSize}
26
+ font-retro font-medium
27
+ bg-retro-gray
28
+ border border-retro-black
29
+ ${pressed
30
+ ? 'shadow-retro-inset'
31
+ : 'shadow-retro-outset active:shadow-retro-inset'
32
+ }
33
+ ${disabled
34
+ ? 'text-retro-darkgray cursor-not-allowed'
35
+ : 'text-retro-black hover:bg-retro-light cursor-pointer'
36
+ }
37
+ select-none
38
+ ${className}
39
+ `}
40
+ disabled={disabled}
41
+ {...rest}
42
+ >
43
+ {children}
44
+ </button>
45
+ )
46
+ }
frontend/src/components/retro/RetroCheckbox.tsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface Props {
2
+ /** Label text next to the checkbox */
3
+ label: string
4
+ /** Controlled checked state */
5
+ checked: boolean
6
+ /** Change handler */
7
+ onChange: (checked: boolean) => void
8
+ /** Disabled state */
9
+ disabled?: boolean
10
+ /** Extra CSS classes on the wrapper */
11
+ className?: string
12
+ }
13
+
14
+ export default function RetroCheckbox({
15
+ label,
16
+ checked,
17
+ onChange,
18
+ disabled = false,
19
+ className = '',
20
+ }: Props) {
21
+ return (
22
+ <label
23
+ onClick={() => { if (!disabled) onChange(!checked) }}
24
+ className={`
25
+ inline-flex items-center gap-[6px]
26
+ text-retro-sm font-retro
27
+ ${disabled ? 'text-retro-darkgray cursor-not-allowed' : 'cursor-pointer'}
28
+ select-none
29
+ ${className}
30
+ `}
31
+ >
32
+ <span
33
+ className={`
34
+ inline-flex items-center justify-center
35
+ w-[13px] h-[13px]
36
+ border border-retro-black
37
+ bg-retro-white
38
+ shadow-retro-well
39
+ text-[10px] leading-none font-bold
40
+ shrink-0
41
+ `}
42
+ >
43
+ {checked && <span className="text-retro-black">x</span>}
44
+ </span>
45
+ {label}
46
+ </label>
47
+ )
48
+ }
frontend/src/components/retro/RetroIcon.tsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface Props {
2
+ /** Label displayed below the icon */
3
+ label: string
4
+ /** Emoji or text character used as the icon glyph */
5
+ glyph: string
6
+ /** Click handler */
7
+ onClick?: () => void
8
+ /** If true, renders in selected/highlighted state */
9
+ selected?: boolean
10
+ /** Extra CSS classes */
11
+ className?: string
12
+ }
13
+
14
+ export default function RetroIcon({
15
+ label,
16
+ glyph,
17
+ onClick,
18
+ selected = false,
19
+ className = '',
20
+ }: Props) {
21
+ return (
22
+ <button
23
+ onClick={onClick}
24
+ className={`
25
+ flex flex-col items-center gap-1
26
+ p-2 w-[80px]
27
+ cursor-pointer select-none
28
+ ${className}
29
+ `}
30
+ onDoubleClick={onClick}
31
+ >
32
+ {/* Icon box */}
33
+ <div
34
+ className={`
35
+ w-[48px] h-[48px]
36
+ flex items-center justify-center
37
+ border border-retro-black
38
+ text-[24px] leading-none
39
+ ${selected
40
+ ? 'bg-retro-black text-retro-white'
41
+ : 'bg-retro-white text-retro-black hover:bg-retro-light'
42
+ }
43
+ shadow-retro-outset
44
+ `}
45
+ >
46
+ {glyph}
47
+ </div>
48
+ {/* Label */}
49
+ <span
50
+ className={`
51
+ text-retro-xs text-center leading-tight
52
+ max-w-full break-words
53
+ ${selected
54
+ ? 'bg-retro-select text-retro-select-text px-1'
55
+ : 'text-retro-black'
56
+ }
57
+ `}
58
+ >
59
+ {label}
60
+ </span>
61
+ </button>
62
+ )
63
+ }
frontend/src/components/retro/RetroInput.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { InputHTMLAttributes } from 'react'
2
+
3
+ interface Props extends InputHTMLAttributes<HTMLInputElement> {
4
+ /** Optional label rendered above the input */
5
+ label?: string
6
+ }
7
+
8
+ export default function RetroInput({ label, className = '', ...rest }: Props) {
9
+ return (
10
+ <div className="flex flex-col gap-[2px]">
11
+ {label && (
12
+ <label className="text-retro-xs font-retro font-medium text-retro-black">
13
+ {label}
14
+ </label>
15
+ )}
16
+ <input
17
+ className={`
18
+ px-2 py-[3px]
19
+ text-retro-sm font-retro
20
+ bg-retro-white text-retro-black
21
+ border border-retro-black
22
+ shadow-retro-well
23
+ placeholder:text-retro-darkgray
24
+ ${className}
25
+ `}
26
+ {...rest}
27
+ />
28
+ </div>
29
+ )
30
+ }
frontend/src/components/retro/RetroMenuBar.tsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from 'react'
2
+
3
+ export interface MenuItem {
4
+ label: string
5
+ onClick?: () => void
6
+ disabled?: boolean
7
+ }
8
+
9
+ interface Props {
10
+ /** Left-aligned items (logo, menus) */
11
+ items?: MenuItem[]
12
+ /** Right-aligned content (search, status) */
13
+ right?: ReactNode
14
+ /** Extra CSS classes */
15
+ className?: string
16
+ }
17
+
18
+ export default function RetroMenuBar({ items = [], right, className = '' }: Props) {
19
+ return (
20
+ <div
21
+ className={`
22
+ flex items-center
23
+ bg-retro-gray
24
+ border-b-retro border-retro-black
25
+ shadow-retro-outset
26
+ px-1 py-[2px]
27
+ select-none shrink-0
28
+ ${className}
29
+ `}
30
+ >
31
+ {items.map((item, i) => (
32
+ <button
33
+ key={i}
34
+ onClick={item.onClick}
35
+ disabled={item.disabled}
36
+ className={`
37
+ px-3 py-[2px]
38
+ text-retro-sm font-retro font-medium
39
+ ${item.disabled
40
+ ? 'text-retro-darkgray cursor-not-allowed'
41
+ : 'text-retro-black hover:bg-retro-black hover:text-retro-white cursor-pointer'
42
+ }
43
+ `}
44
+ >
45
+ {item.label}
46
+ </button>
47
+ ))}
48
+ {right && (
49
+ <div className="ml-auto flex items-center gap-1">
50
+ {right}
51
+ </div>
52
+ )}
53
+ </div>
54
+ )
55
+ }
frontend/src/components/retro/RetroSelect.tsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { SelectHTMLAttributes } from 'react'
2
+
3
+ interface Props extends SelectHTMLAttributes<HTMLSelectElement> {
4
+ /** Optional label rendered above the select */
5
+ label?: string
6
+ /** Options: array of {value, label} */
7
+ options: { value: string; label: string }[]
8
+ }
9
+
10
+ export default function RetroSelect({ label, options, className = '', ...rest }: Props) {
11
+ return (
12
+ <div className="flex flex-col gap-[2px]">
13
+ {label && (
14
+ <label className="text-retro-xs font-retro font-medium text-retro-black">
15
+ {label}
16
+ </label>
17
+ )}
18
+ <select
19
+ className={`
20
+ px-2 py-[3px]
21
+ text-retro-sm font-retro
22
+ bg-retro-white text-retro-black
23
+ border border-retro-black
24
+ shadow-retro-well
25
+ cursor-pointer
26
+ ${className}
27
+ `}
28
+ {...rest}
29
+ >
30
+ {options.map((opt) => (
31
+ <option key={opt.value} value={opt.value}>
32
+ {opt.label}
33
+ </option>
34
+ ))}
35
+ </select>
36
+ </div>
37
+ )
38
+ }
frontend/src/components/retro/RetroTextarea.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { TextareaHTMLAttributes } from 'react'
2
+
3
+ interface Props extends TextareaHTMLAttributes<HTMLTextAreaElement> {
4
+ /** Optional label rendered above the textarea */
5
+ label?: string
6
+ }
7
+
8
+ export default function RetroTextarea({ label, className = '', ...rest }: Props) {
9
+ return (
10
+ <div className="flex flex-col gap-[2px]">
11
+ {label && (
12
+ <label className="text-retro-xs font-retro font-medium text-retro-black">
13
+ {label}
14
+ </label>
15
+ )}
16
+ <textarea
17
+ className={`
18
+ px-2 py-[3px]
19
+ text-retro-sm font-retro
20
+ bg-retro-white text-retro-black
21
+ border border-retro-black
22
+ shadow-retro-well
23
+ placeholder:text-retro-darkgray
24
+ retro-scroll
25
+ resize-y
26
+ ${className}
27
+ `}
28
+ {...rest}
29
+ />
30
+ </div>
31
+ )
32
+ }
frontend/src/components/retro/RetroWindow.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from 'react'
2
+
3
+ interface Props {
4
+ /** Window title shown in the title bar */
5
+ title: string
6
+ /** Optional status text displayed in the bottom status bar */
7
+ statusBar?: string
8
+ /** Called when the close button is clicked (omit to hide the button) */
9
+ onClose?: () => void
10
+ /** Extra CSS classes for the outer container */
11
+ className?: string
12
+ /** Content rendered inside the window body */
13
+ children: ReactNode
14
+ /** If true, window body is scrollable with retro scrollbars */
15
+ scrollable?: boolean
16
+ /** If true, the window is rendered as "active" (darker title bar) */
17
+ active?: boolean
18
+ }
19
+
20
+ export default function RetroWindow({
21
+ title,
22
+ statusBar,
23
+ onClose,
24
+ className = '',
25
+ children,
26
+ scrollable = false,
27
+ active = true,
28
+ }: Props) {
29
+ return (
30
+ <div
31
+ className={`
32
+ flex flex-col
33
+ border-retro border-retro-black bg-retro-gray
34
+ shadow-retro
35
+ ${className}
36
+ `}
37
+ >
38
+ {/* ── Title bar ──────────────────────────────────────────── */}
39
+ <div
40
+ className={`
41
+ flex items-center gap-2 px-2 py-[3px]
42
+ select-none shrink-0
43
+ ${active ? 'bg-retro-black text-retro-white' : 'bg-retro-darkgray text-retro-white'}
44
+ `}
45
+ >
46
+ {onClose && (
47
+ <button
48
+ onClick={onClose}
49
+ className="
50
+ w-[14px] h-[14px] flex items-center justify-center
51
+ border border-retro-white bg-retro-gray text-retro-black
52
+ text-[9px] leading-none font-bold
53
+ hover:bg-retro-white active:bg-retro-darkgray
54
+ "
55
+ aria-label="Fermer"
56
+ >
57
+ x
58
+ </button>
59
+ )}
60
+ <span className="flex-1 text-retro-xs font-bold truncate tracking-wide">
61
+ {title}
62
+ </span>
63
+ </div>
64
+
65
+ {/* ── Content area ───────────────────────────────────────── */}
66
+ <div
67
+ className={`
68
+ flex-1 bg-retro-white
69
+ border-t-0
70
+ m-[3px] mt-0
71
+ shadow-retro-well
72
+ ${scrollable ? 'overflow-auto retro-scroll' : 'overflow-hidden'}
73
+ `}
74
+ >
75
+ {children}
76
+ </div>
77
+
78
+ {/* ── Status bar (optional) ──────────────────────────────── */}
79
+ {statusBar !== undefined && (
80
+ <div
81
+ className="
82
+ px-2 py-[2px]
83
+ text-retro-xs text-retro-black
84
+ border-t border-retro-darkgray
85
+ bg-retro-gray
86
+ shadow-retro-well
87
+ mx-[3px] mb-[3px]
88
+ truncate shrink-0
89
+ "
90
+ >
91
+ {statusBar}
92
+ </div>
93
+ )}
94
+ </div>
95
+ )
96
+ }
frontend/src/components/retro/index.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export { default as RetroWindow } from './RetroWindow'
2
+ export { default as RetroButton } from './RetroButton'
3
+ export { default as RetroMenuBar } from './RetroMenuBar'
4
+ export { default as RetroIcon } from './RetroIcon'
5
+ export { default as RetroCheckbox } from './RetroCheckbox'
6
+ export { default as RetroInput } from './RetroInput'
7
+ export { default as RetroTextarea } from './RetroTextarea'
8
+ export { default as RetroSelect } from './RetroSelect'
9
+ export { default as RetroBadge } from './RetroBadge'
frontend/src/index.css CHANGED
@@ -1,3 +1,126 @@
 
 
 
1
  @tailwind base;
2
  @tailwind components;
3
  @tailwind utilities;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Google Font: IBM Plex Mono (must precede @tailwind) ───────────── */
2
+ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap');
3
+
4
  @tailwind base;
5
  @tailwind components;
6
  @tailwind utilities;
7
+
8
+ /* ══════════════════════════════════════════════════════════════════════
9
+ IIIF Studio β€” Retro Design System (global styles)
10
+ Inspired by Xerox Star, Smalltalk-80, GEOS, classic Mac
11
+ ══════════════════════════════════════════════════════════════════════ */
12
+
13
+ /* ── Base reset for retro look ─────────────────────────────────────── */
14
+ @layer base {
15
+ html {
16
+ font-family: 'IBM Plex Mono', 'Courier New', Courier, monospace;
17
+ font-size: 13px;
18
+ line-height: 1.5;
19
+ -webkit-font-smoothing: none;
20
+ -moz-osx-font-smoothing: unset;
21
+ background-color: #c0c0c0;
22
+ color: #000000;
23
+ }
24
+
25
+ body {
26
+ margin: 0;
27
+ padding: 0;
28
+ min-height: 100vh;
29
+ }
30
+
31
+ /* Disable subpixel antialiasing for that crisp pixel look */
32
+ * {
33
+ text-rendering: optimizeSpeed;
34
+ }
35
+
36
+ /* Focus ring: dotted black outline (classic retro) */
37
+ *:focus-visible {
38
+ outline: 1px dotted #000000;
39
+ outline-offset: 1px;
40
+ }
41
+
42
+ /* Selection: classic inverted */
43
+ ::selection {
44
+ background-color: #000080;
45
+ color: #ffffff;
46
+ }
47
+ }
48
+
49
+ /* ── Retro scrollbar (Windows 3.1 style) ───────────────────────────── */
50
+ @layer components {
51
+ /* Webkit-based browsers */
52
+ .retro-scroll::-webkit-scrollbar {
53
+ width: 16px;
54
+ height: 16px;
55
+ }
56
+
57
+ .retro-scroll::-webkit-scrollbar-track {
58
+ background:
59
+ repeating-conic-gradient(
60
+ #c0c0c0 0% 25%, #dfdfdf 0% 50%
61
+ ) 50% / 2px 2px;
62
+ }
63
+
64
+ .retro-scroll::-webkit-scrollbar-thumb {
65
+ background-color: #c0c0c0;
66
+ border: 1px solid #000000;
67
+ box-shadow:
68
+ inset -1px -1px 0 #808080,
69
+ inset 1px 1px 0 #ffffff;
70
+ }
71
+
72
+ .retro-scroll::-webkit-scrollbar-button {
73
+ background-color: #c0c0c0;
74
+ border: 1px solid #000000;
75
+ box-shadow:
76
+ inset -1px -1px 0 #808080,
77
+ inset 1px 1px 0 #ffffff;
78
+ display: block;
79
+ height: 16px;
80
+ width: 16px;
81
+ }
82
+
83
+ .retro-scroll::-webkit-scrollbar-corner {
84
+ background-color: #c0c0c0;
85
+ }
86
+
87
+ /* Firefox */
88
+ .retro-scroll {
89
+ scrollbar-width: auto;
90
+ scrollbar-color: #c0c0c0 #dfdfdf;
91
+ }
92
+ }
93
+
94
+ /* ── Desktop dithered background ───────────────────────────────────── */
95
+ @layer utilities {
96
+ .bg-retro-dither {
97
+ background-color: #c0c0c0;
98
+ background-image: url("data:image/svg+xml,%3Csvg width='4' height='4' viewBox='0 0 4 4' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='0' width='1' height='1' fill='%23a0a0a0'/%3E%3Crect x='2' y='2' width='1' height='1' fill='%23a0a0a0'/%3E%3C/svg%3E");
99
+ background-repeat: repeat;
100
+ }
101
+
102
+ .bg-retro-dither-dark {
103
+ background-color: #808080;
104
+ background-image: url("data:image/svg+xml,%3Csvg width='4' height='4' viewBox='0 0 4 4' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='0' width='1' height='1' fill='%23606060'/%3E%3Crect x='2' y='2' width='1' height='1' fill='%23606060'/%3E%3C/svg%3E");
105
+ background-repeat: repeat;
106
+ }
107
+
108
+ .bg-retro-dither-light {
109
+ background-color: #dfdfdf;
110
+ background-image: url("data:image/svg+xml,%3Csvg width='4' height='4' viewBox='0 0 4 4' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='0' width='1' height='1' fill='%23c8c8c8'/%3E%3Crect x='2' y='2' width='1' height='1' fill='%23c8c8c8'/%3E%3C/svg%3E");
111
+ background-repeat: repeat;
112
+ }
113
+
114
+ /* Horizontal lines pattern */
115
+ .bg-retro-lines {
116
+ background-color: #c0c0c0;
117
+ background-image: repeating-linear-gradient(
118
+ 0deg,
119
+ transparent,
120
+ transparent 1px,
121
+ #a0a0a0 1px,
122
+ #a0a0a0 2px
123
+ );
124
+ background-size: 100% 4px;
125
+ }
126
+ }
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
  )
frontend/src/pages/Editor.tsx CHANGED
@@ -7,6 +7,14 @@ import {
7
  type VersionInfo,
8
  } from '../lib/api.ts'
9
  import Viewer from '../components/Viewer.tsx'
 
 
 
 
 
 
 
 
10
 
11
  interface Props {
12
  pageId: string
@@ -15,6 +23,13 @@ interface Props {
15
 
16
  type Panel = 'transcription' | 'commentary' | 'regions' | 'history'
17
 
 
 
 
 
 
 
 
18
  export default function Editor({ pageId, onBack }: Props) {
19
  const [master, setMaster] = useState<PageMaster | null>(null)
20
  const [history, setHistory] = useState<VersionInfo[]>([])
@@ -25,7 +40,6 @@ export default function Editor({ pageId, onBack }: Props) {
25
  const [saveError, setSaveError] = useState<string | null>(null)
26
  const [saveSuccess, setSaveSuccess] = useState(false)
27
 
28
- // Editable field values
29
  const [ocrText, setOcrText] = useState('')
30
  const [commentaryPublic, setCommentaryPublic] = useState('')
31
  const [commentaryScholarly, setCommentaryScholarly] = useState('')
@@ -45,7 +59,6 @@ export default function Editor({ pageId, onBack }: Props) {
45
  setCommentaryPublic(m.commentary?.public ?? '')
46
  setCommentaryScholarly(m.commentary?.scholarly ?? '')
47
  setEditorialStatus(m.editorial.status)
48
- // Restore existing region validations from extensions
49
  const ext = (m as unknown as { extensions?: { region_validations?: Record<string, string> } }).extensions
50
  setRegionValidations(ext?.region_validations ?? {})
51
  } catch (e: unknown) {
@@ -107,249 +120,239 @@ export default function Editor({ pageId, onBack }: Props) {
107
  setRegionValidations((prev) => ({ ...prev, [regionId]: val }))
108
  }
109
 
 
110
  if (loading) {
111
  return (
112
- <div className="flex items-center justify-center h-screen text-stone-500">
113
- Chargement…
 
 
114
  </div>
115
  )
116
  }
117
 
118
  if (error) {
119
- return <div className="p-8 text-red-600">Erreur : {error}</div>
 
 
 
 
 
 
 
 
 
120
  }
121
 
122
  const imageUrl = master?.image?.derivative_web ?? master?.image?.master ?? ''
123
  const regions = master?.layout?.regions ?? []
124
 
125
  return (
126
- <div className="flex flex-col h-screen bg-stone-100">
127
- {/* ── Header ──────────────────────────────────────────────────────────── */}
128
- <header className="flex items-center gap-3 bg-stone-900 text-stone-100 px-5 py-2.5 shrink-0">
129
- <button
130
- onClick={onBack}
131
- className="text-stone-400 hover:text-stone-100 text-sm transition-colors"
132
- >
133
- ← Retour
134
- </button>
135
- <span className="text-stone-600">|</span>
136
- <span className="text-sm font-medium text-stone-200">
137
- Γ‰diteur β€” {master?.folio_label ?? pageId}
138
- </span>
139
- {master && (
140
- <span className="ml-2 text-xs text-stone-400">
141
- v{master.editorial.version} Β· {master.editorial.status}
142
- </span>
143
- )}
 
 
 
 
 
 
 
 
144
 
145
- <div className="ml-auto flex items-center gap-3">
146
- {saveSuccess && (
147
- <span className="text-green-400 text-xs">EnregistrΓ©</span>
148
- )}
149
- {saveError && (
150
- <span className="text-red-400 text-xs">{saveError}</span>
151
- )}
152
- <button
153
- onClick={() => void handleSave()}
154
- disabled={saving}
155
- className="px-4 py-1.5 bg-amber-600 hover:bg-amber-500 disabled:opacity-40 text-white text-sm rounded transition-colors"
156
- >
157
- {saving ? 'Enregistrement…' : 'Enregistrer'}
158
- </button>
159
- </div>
160
- </header>
161
 
162
- {/* ── Layout 50 / 50 ──────────────────────────────────────────────────── */}
163
- <div className="flex flex-1 overflow-hidden">
164
- {/* Visionneuse gauche */}
165
- <div className="relative" style={{ width: '50%' }}>
166
- <Viewer imageUrl={imageUrl} onViewerReady={() => {}} />
167
- {!imageUrl && (
168
- <div className="absolute inset-0 flex items-center justify-center bg-stone-200 text-stone-400 text-sm">
169
- AperΓ§u image non disponible
170
- </div>
171
- )}
172
- </div>
173
-
174
- {/* Panneaux droite */}
175
- <div
176
- className="flex flex-col border-l border-stone-200 bg-white"
177
- style={{ width: '50%' }}
178
  >
179
- {/* Onglets */}
180
- <div className="flex border-b border-stone-200 shrink-0">
181
- {(['transcription', 'commentary', 'regions', 'history'] as Panel[]).map((p) => (
182
- <button
183
- key={p}
184
- onClick={() => setActivePanel(p)}
185
- className={`flex-1 py-2.5 text-xs font-medium capitalize transition-colors ${
186
- activePanel === p
187
- ? 'border-b-2 border-amber-500 text-amber-700 bg-amber-50'
188
- : 'text-stone-500 hover:text-stone-800'
189
- }`}
190
- >
191
- {p === 'transcription' ? 'Transcription' :
192
- p === 'commentary' ? 'Commentaire' :
193
- p === 'regions' ? 'RΓ©gions' : 'Historique'}
194
- </button>
195
- ))}
196
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
- {/* Contenu du panneau actif */}
199
- <div className="flex-1 overflow-y-auto p-4">
200
 
201
- {/* ── Transcription ─────────────────────────────────────────── */}
202
- {activePanel === 'transcription' && (
203
- <div className="space-y-4">
204
- <div>
205
- <label className="block text-xs font-semibold text-stone-600 mb-1.5 uppercase tracking-wide">
206
- Texte diplomatique (OCR)
207
- </label>
208
- <textarea
209
  value={ocrText}
210
  onChange={(e) => setOcrText(e.target.value)}
211
  rows={12}
212
- className="w-full border border-stone-300 rounded-md px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-400 resize-y"
213
  />
214
- </div>
215
- <div>
216
- <label className="block text-xs font-semibold text-stone-600 mb-1.5 uppercase tracking-wide">
217
- Statut Γ©ditorial
218
- </label>
219
- <select
220
  value={editorialStatus}
221
  onChange={(e) => setEditorialStatus(e.target.value)}
222
- className="w-full border border-stone-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400"
223
- >
224
- <option value="machine_draft">machine_draft</option>
225
- <option value="needs_review">needs_review</option>
226
- <option value="reviewed">reviewed</option>
227
- <option value="validated">validated</option>
228
- <option value="published">published</option>
229
- </select>
 
 
 
 
 
230
  </div>
231
- {master?.ocr && (
232
- <div className="text-xs text-stone-400 space-y-0.5">
233
- <div>Langue : {master.ocr.language}</div>
234
- <div>Confiance : {(master.ocr.confidence * 100).toFixed(0)} %</div>
235
- </div>
236
- )}
237
- </div>
238
- )}
239
 
240
- {/* ── Commentaire ───────────────────────────────────────────── */}
241
- {activePanel === 'commentary' && (
242
- <div className="space-y-5">
243
- <div>
244
- <label className="block text-xs font-semibold text-stone-600 mb-1.5 uppercase tracking-wide">
245
- Commentaire public
246
- </label>
247
- <textarea
248
  value={commentaryPublic}
249
  onChange={(e) => setCommentaryPublic(e.target.value)}
250
  rows={6}
251
- className="w-full border border-stone-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 resize-y"
252
  />
253
- </div>
254
- <div>
255
- <label className="block text-xs font-semibold text-stone-600 mb-1.5 uppercase tracking-wide">
256
- Commentaire savant
257
- </label>
258
- <textarea
259
  value={commentaryScholarly}
260
  onChange={(e) => setCommentaryScholarly(e.target.value)}
261
  rows={8}
262
- className="w-full border border-stone-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 resize-y"
263
  />
264
  </div>
265
- </div>
266
- )}
267
 
268
- {/* ── RΓ©gions ───────────────────────────────────────────────── */}
269
- {activePanel === 'regions' && (
270
- <div className="space-y-2">
271
- {regions.length === 0 ? (
272
- <p className="text-sm text-stone-400 italic">Aucune rΓ©gion dΓ©tectΓ©e.</p>
273
- ) : (
274
- regions.map((region) => {
275
- const validation = regionValidations[region.id]
276
- return (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  <div
278
- key={region.id}
279
- className="flex items-center justify-between border border-stone-200 rounded-lg px-3 py-2.5 text-sm"
 
 
 
 
280
  >
281
  <div>
282
- <span className="font-medium text-stone-800 capitalize">
283
- {region.type.replace(/_/g, ' ')}
284
- </span>
285
- <span className="ml-2 text-xs text-stone-400 font-mono">{region.id}</span>
286
- <div className="text-xs text-stone-400">
287
- confiance : {(region.confidence * 100).toFixed(0)} %
288
  </div>
289
  </div>
290
- <div className="flex gap-2 ml-4 shrink-0">
291
- <button
292
- onClick={() => setRegionValidation(region.id, 'validated')}
293
- className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
294
- validation === 'validated'
295
- ? 'bg-green-600 text-white'
296
- : 'bg-stone-100 text-stone-600 hover:bg-green-100'
297
- }`}
298
- >
299
- Valider
300
- </button>
301
- <button
302
- onClick={() => setRegionValidation(region.id, 'rejected')}
303
- className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
304
- validation === 'rejected'
305
- ? 'bg-red-600 text-white'
306
- : 'bg-stone-100 text-stone-600 hover:bg-red-100'
307
- }`}
308
- >
309
- Rejeter
310
- </button>
311
- </div>
312
  </div>
313
- )
314
- })
315
- )}
316
- </div>
317
- )}
318
-
319
- {/* ── Historique ────────────────────────────────────────────── */}
320
- {activePanel === 'history' && (
321
- <div className="space-y-2">
322
- {history.length === 0 ? (
323
- <p className="text-sm text-stone-400 italic">
324
- Aucune version archivΓ©e.
325
- </p>
326
- ) : (
327
- history.map((v) => (
328
- <div
329
- key={v.version}
330
- className="flex items-center justify-between border border-stone-200 rounded-lg px-3 py-2.5 text-sm"
331
- >
332
- <div>
333
- <span className="font-medium text-stone-800">v{v.version}</span>
334
- <span className="ml-2 text-xs text-stone-500">{v.status}</span>
335
- <div className="text-xs text-stone-400 mt-0.5">
336
- {new Date(v.saved_at).toLocaleString('fr-FR')}
337
- </div>
338
- </div>
339
- <button
340
- onClick={() => void handleRestore(v.version)}
341
- disabled={saving}
342
- className="ml-4 px-3 py-1 text-xs bg-stone-100 text-stone-600 hover:bg-amber-100 hover:text-amber-700 disabled:opacity-40 rounded-md transition-colors"
343
- >
344
- Restaurer
345
- </button>
346
- </div>
347
- ))
348
- )}
349
- </div>
350
- )}
351
  </div>
352
- </div>
353
  </div>
354
  </div>
355
  )
 
7
  type VersionInfo,
8
  } from '../lib/api.ts'
9
  import Viewer from '../components/Viewer.tsx'
10
+ import {
11
+ RetroMenuBar,
12
+ RetroWindow,
13
+ RetroButton,
14
+ RetroTextarea,
15
+ RetroSelect,
16
+ RetroBadge,
17
+ } from '../components/retro'
18
 
19
  interface Props {
20
  pageId: string
 
23
 
24
  type Panel = 'transcription' | 'commentary' | 'regions' | 'history'
25
 
26
+ const PANEL_LABELS: Record<Panel, string> = {
27
+ transcription: 'Transcription',
28
+ commentary: 'Commentaire',
29
+ regions: 'Regions',
30
+ history: 'Historique',
31
+ }
32
+
33
  export default function Editor({ pageId, onBack }: Props) {
34
  const [master, setMaster] = useState<PageMaster | null>(null)
35
  const [history, setHistory] = useState<VersionInfo[]>([])
 
40
  const [saveError, setSaveError] = useState<string | null>(null)
41
  const [saveSuccess, setSaveSuccess] = useState(false)
42
 
 
43
  const [ocrText, setOcrText] = useState('')
44
  const [commentaryPublic, setCommentaryPublic] = useState('')
45
  const [commentaryScholarly, setCommentaryScholarly] = useState('')
 
59
  setCommentaryPublic(m.commentary?.public ?? '')
60
  setCommentaryScholarly(m.commentary?.scholarly ?? '')
61
  setEditorialStatus(m.editorial.status)
 
62
  const ext = (m as unknown as { extensions?: { region_validations?: Record<string, string> } }).extensions
63
  setRegionValidations(ext?.region_validations ?? {})
64
  } catch (e: unknown) {
 
120
  setRegionValidations((prev) => ({ ...prev, [regionId]: val }))
121
  }
122
 
123
+ // ── Loading / Error ─────────────────────────────────────────────────
124
  if (loading) {
125
  return (
126
+ <div className="min-h-screen bg-retro-dither flex items-center justify-center">
127
+ <RetroWindow title="Chargement" className="w-64">
128
+ <div className="p-4 text-retro-sm text-center">Chargement...</div>
129
+ </RetroWindow>
130
  </div>
131
  )
132
  }
133
 
134
  if (error) {
135
+ return (
136
+ <div className="min-h-screen bg-retro-dither flex items-center justify-center">
137
+ <RetroWindow title="Erreur" className="w-80">
138
+ <div className="p-4 text-retro-sm">
139
+ {error}
140
+ <div className="mt-2"><RetroButton onClick={onBack}>Retour</RetroButton></div>
141
+ </div>
142
+ </RetroWindow>
143
+ </div>
144
+ )
145
  }
146
 
147
  const imageUrl = master?.image?.derivative_web ?? master?.image?.master ?? ''
148
  const regions = master?.layout?.regions ?? []
149
 
150
  return (
151
+ <div className="flex flex-col h-screen bg-retro-dither">
152
+ {/* ── Menu bar ───────────────────────────────────────────────── */}
153
+ <RetroMenuBar
154
+ items={[
155
+ { label: 'IIIF Studio', onClick: onBack },
156
+ { label: `Editeur β€” ${master?.folio_label ?? pageId}` },
157
+ ]}
158
+ right={
159
+ <div className="flex items-center gap-1">
160
+ {master && (
161
+ <span className="text-retro-xs px-2">
162
+ v{master.editorial.version} β€” {master.editorial.status}
163
+ </span>
164
+ )}
165
+ {saveSuccess && <RetroBadge variant="success">OK</RetroBadge>}
166
+ {saveError && <RetroBadge variant="error">Err</RetroBadge>}
167
+ <RetroButton
168
+ size="sm"
169
+ onClick={() => void handleSave()}
170
+ disabled={saving}
171
+ >
172
+ {saving ? 'Saving...' : 'Sauvegarder'}
173
+ </RetroButton>
174
+ </div>
175
+ }
176
+ />
177
 
178
+ {/* ── Main layout 50/50 ──────────────────────────────────────── */}
179
+ <div className="flex flex-1 overflow-hidden p-1 gap-1">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
+ {/* ── Viewer window (left) ───────────────────────────────── */}
182
+ <RetroWindow
183
+ title={`Image β€” ${master?.folio_label ?? pageId}`}
184
+ className="flex-1 min-w-0"
 
 
 
 
 
 
 
 
 
 
 
 
185
  >
186
+ <div className="relative w-full h-full">
187
+ <Viewer imageUrl={imageUrl} onViewerReady={() => {}} />
188
+ {!imageUrl && (
189
+ <div className="absolute inset-0 flex items-center justify-center bg-retro-gray text-retro-darkgray text-retro-sm">
190
+ Apercu non disponible
191
+ </div>
192
+ )}
 
 
 
 
 
 
 
 
 
 
193
  </div>
194
+ </RetroWindow>
195
+
196
+ {/* ── Editor window (right) ──────────────────────────────── */}
197
+ <RetroWindow
198
+ title="Editeur"
199
+ className="flex-1 min-w-0"
200
+ scrollable
201
+ >
202
+ <div className="flex flex-col">
203
+ {/* ── Tab bar ──────────────────────────────────────── */}
204
+ <div className="flex shrink-0 border-b border-retro-black bg-retro-gray">
205
+ {(['transcription', 'commentary', 'regions', 'history'] as Panel[]).map((p) => (
206
+ <RetroButton
207
+ key={p}
208
+ size="sm"
209
+ pressed={activePanel === p}
210
+ onClick={() => setActivePanel(p)}
211
+ className="flex-1 border-0 border-r border-retro-darkgray last:border-r-0"
212
+ >
213
+ {PANEL_LABELS[p]}
214
+ </RetroButton>
215
+ ))}
216
+ </div>
217
 
218
+ {/* ── Panel content ─────────────────────────────────── */}
219
+ <div className="p-2">
220
 
221
+ {/* Transcription */}
222
+ {activePanel === 'transcription' && (
223
+ <div className="flex flex-col gap-2">
224
+ <RetroTextarea
225
+ label="Texte diplomatique (OCR)"
 
 
 
226
  value={ocrText}
227
  onChange={(e) => setOcrText(e.target.value)}
228
  rows={12}
 
229
  />
230
+ <RetroSelect
231
+ label="Statut editorial"
 
 
 
 
232
  value={editorialStatus}
233
  onChange={(e) => setEditorialStatus(e.target.value)}
234
+ options={[
235
+ { value: 'machine_draft', label: 'machine_draft' },
236
+ { value: 'needs_review', label: 'needs_review' },
237
+ { value: 'reviewed', label: 'reviewed' },
238
+ { value: 'validated', label: 'validated' },
239
+ { value: 'published', label: 'published' },
240
+ ]}
241
+ />
242
+ {master?.ocr && (
243
+ <div className="text-retro-xs text-retro-darkgray">
244
+ Langue: {master.ocr.language} β€” Confiance: {(master.ocr.confidence * 100).toFixed(0)}%
245
+ </div>
246
+ )}
247
  </div>
248
+ )}
 
 
 
 
 
 
 
249
 
250
+ {/* Commentary */}
251
+ {activePanel === 'commentary' && (
252
+ <div className="flex flex-col gap-2">
253
+ <RetroTextarea
254
+ label="Commentaire public"
 
 
 
255
  value={commentaryPublic}
256
  onChange={(e) => setCommentaryPublic(e.target.value)}
257
  rows={6}
 
258
  />
259
+ <RetroTextarea
260
+ label="Commentaire savant"
 
 
 
 
261
  value={commentaryScholarly}
262
  onChange={(e) => setCommentaryScholarly(e.target.value)}
263
  rows={8}
 
264
  />
265
  </div>
266
+ )}
 
267
 
268
+ {/* Regions */}
269
+ {activePanel === 'regions' && (
270
+ <div className="flex flex-col gap-[2px]">
271
+ {regions.length === 0 ? (
272
+ <p className="text-retro-sm text-retro-darkgray p-2">Aucune region detectee.</p>
273
+ ) : (
274
+ regions.map((region) => {
275
+ const validation = regionValidations[region.id]
276
+ return (
277
+ <div
278
+ key={region.id}
279
+ className="
280
+ flex items-center justify-between
281
+ border border-retro-black p-2
282
+ bg-retro-white
283
+ "
284
+ >
285
+ <div>
286
+ <span className="text-retro-sm font-bold capitalize">
287
+ {region.type.replace(/_/g, ' ')}
288
+ </span>
289
+ <span className="ml-2 text-retro-xs text-retro-darkgray">
290
+ {region.id}
291
+ </span>
292
+ <div className="text-retro-xs text-retro-darkgray">
293
+ confiance: {(region.confidence * 100).toFixed(0)}%
294
+ </div>
295
+ </div>
296
+ <div className="flex gap-[2px] ml-2 shrink-0">
297
+ <RetroButton
298
+ size="sm"
299
+ pressed={validation === 'validated'}
300
+ onClick={() => setRegionValidation(region.id, 'validated')}
301
+ >
302
+ OK
303
+ </RetroButton>
304
+ <RetroButton
305
+ size="sm"
306
+ pressed={validation === 'rejected'}
307
+ onClick={() => setRegionValidation(region.id, 'rejected')}
308
+ >
309
+ X
310
+ </RetroButton>
311
+ </div>
312
+ </div>
313
+ )
314
+ })
315
+ )}
316
+ </div>
317
+ )}
318
+
319
+ {/* History */}
320
+ {activePanel === 'history' && (
321
+ <div className="flex flex-col gap-[2px]">
322
+ {history.length === 0 ? (
323
+ <p className="text-retro-sm text-retro-darkgray p-2">Aucune version archivee.</p>
324
+ ) : (
325
+ history.map((v) => (
326
  <div
327
+ key={v.version}
328
+ className="
329
+ flex items-center justify-between
330
+ border border-retro-black p-2
331
+ bg-retro-white
332
+ "
333
  >
334
  <div>
335
+ <span className="text-retro-sm font-bold">v{v.version}</span>
336
+ <RetroBadge className="ml-2">{v.status}</RetroBadge>
337
+ <div className="text-retro-xs text-retro-darkgray mt-[2px]">
338
+ {new Date(v.saved_at).toLocaleString('fr-FR')}
 
 
339
  </div>
340
  </div>
341
+ <RetroButton
342
+ size="sm"
343
+ onClick={() => void handleRestore(v.version)}
344
+ disabled={saving}
345
+ >
346
+ Restaurer
347
+ </RetroButton>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  </div>
349
+ ))
350
+ )}
351
+ </div>
352
+ )}
353
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  </div>
355
+ </RetroWindow>
356
  </div>
357
  </div>
358
  )
frontend/src/pages/Home.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useEffect, useState } from 'react'
2
  import AdminNav from '../components/AdminNav.tsx'
3
  import SearchBar from '../components/SearchBar.tsx'
 
4
  import {
5
  fetchCorpora,
6
  fetchManuscripts,
@@ -8,6 +9,14 @@ import {
8
  type Manuscript,
9
  } from '../lib/api.ts'
10
 
 
 
 
 
 
 
 
 
11
  interface Props {
12
  onOpenManuscript: (manuscriptId: string, profileId: string) => void
13
  onOpenPage?: (pageId: string) => void
@@ -20,6 +29,7 @@ export default function Home({ onOpenManuscript, onOpenPage, onAdmin }: Props) {
20
  const [error, setError] = useState<string | null>(null)
21
  const [manuscripts, setManuscripts] = useState<Record<string, Manuscript[]>>({})
22
  const [expanding, setExpanding] = useState<string | null>(null)
 
23
 
24
  useEffect(() => {
25
  fetchCorpora()
@@ -29,7 +39,8 @@ export default function Home({ onOpenManuscript, onOpenPage, onAdmin }: Props) {
29
  }, [])
30
 
31
  const handleCorpusClick = async (corpus: Corpus) => {
32
- // Si dΓ©jΓ  chargΓ©, naviguer directement si un seul manuscrit
 
33
  const cached = manuscripts[corpus.id]
34
  if (cached) {
35
  if (cached.length === 1) onOpenManuscript(cached[0].id, corpus.profile_id)
@@ -42,95 +53,171 @@ export default function Home({ onOpenManuscript, onOpenPage, onAdmin }: Props) {
42
  setManuscripts((prev) => ({ ...prev, [corpus.id]: ms }))
43
  if (ms.length === 1) onOpenManuscript(ms[0].id, corpus.profile_id)
44
  } catch {
45
- // Γ‰chec silencieux β€” la liste reste vide
46
  } finally {
47
  setExpanding(null)
48
  }
49
  }
50
 
 
51
  if (loading) {
52
  return (
53
- <div className="flex items-center justify-center h-screen text-stone-500">
54
- Chargement…
 
 
 
 
55
  </div>
56
  )
57
  }
58
 
 
59
  if (error) {
60
  return (
61
- <div className="p-8 text-red-600">
62
- Erreur : {error}
 
 
 
 
 
63
  </div>
64
  )
65
  }
66
 
 
 
67
  return (
68
- <div className="min-h-screen bg-stone-50">
69
- <header className="bg-stone-900 text-stone-100 px-8 py-6 flex items-start justify-between">
70
- <div>
71
- <h1 className="text-2xl font-semibold tracking-tight">IIIF Studio</h1>
72
- <p className="text-stone-400 text-sm mt-1">
73
- Plateforme de gΓ©nΓ©ration d'Γ©ditions savantes augmentΓ©es
74
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  </div>
76
- <div className="flex items-center gap-4">
77
- <SearchBar onSelectResult={onOpenPage ? (r) => onOpenPage(r.page_id) : undefined} />
78
- <AdminNav onClick={onAdmin} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  </div>
80
- </header>
81
-
82
- <main className="max-w-3xl mx-auto py-10 px-8">
83
- <h2 className="text-sm font-semibold text-stone-500 uppercase tracking-wide mb-6">
84
- Corpus disponibles
85
- </h2>
86
-
87
- {corpora.length === 0 ? (
88
- <p className="text-stone-400 text-sm">
89
- Aucun corpus enregistrΓ©. CrΓ©ez-en un via{' '}
90
- <code className="bg-stone-200 px-1 rounded text-xs">POST /api/v1/corpora</code>.
91
- </p>
92
- ) : (
93
- <ul className="space-y-3">
94
- {corpora.map((corpus) => (
95
- <li key={corpus.id}>
96
- <button
97
- onClick={() => void handleCorpusClick(corpus)}
98
- className="w-full text-left bg-white border border-stone-200 rounded-lg px-6 py-4 hover:border-stone-400 hover:shadow-sm transition-all"
99
- >
100
- <div className="font-medium text-stone-900">{corpus.title}</div>
101
- <div className="text-xs text-stone-400 mt-1">
102
- Profil : {corpus.profile_id} Β· Slug : {corpus.slug}
103
- </div>
104
- </button>
105
-
106
- {expanding === corpus.id && (
107
- <div className="mt-2 ml-4 text-xs text-stone-400">Chargement…</div>
108
- )}
109
-
110
- {manuscripts[corpus.id] && manuscripts[corpus.id].length > 1 && (
111
- <ul className="mt-2 ml-4 space-y-1">
112
- {manuscripts[corpus.id].map((ms) => (
113
- <li key={ms.id}>
114
- <button
115
- onClick={() => onOpenManuscript(ms.id, corpus.profile_id)}
116
- className="text-sm text-stone-600 hover:text-stone-900 hover:underline text-left"
117
- >
118
- {ms.title}
119
- {ms.total_pages > 0 && (
120
- <span className="text-stone-400 ml-1">
121
- ({ms.total_pages} pages)
122
- </span>
123
- )}
124
- </button>
125
- </li>
126
- ))}
127
- </ul>
128
- )}
129
- </li>
130
- ))}
131
- </ul>
132
- )}
133
- </main>
134
  </div>
135
  )
136
  }
 
1
  import { useEffect, useState } from 'react'
2
  import AdminNav from '../components/AdminNav.tsx'
3
  import SearchBar from '../components/SearchBar.tsx'
4
+ import { RetroMenuBar, RetroWindow, RetroIcon } from '../components/retro'
5
  import {
6
  fetchCorpora,
7
  fetchManuscripts,
 
9
  type Manuscript,
10
  } from '../lib/api.ts'
11
 
12
+ /** Map profile IDs to desktop icon glyphs */
13
+ const PROFILE_GLYPHS: Record<string, string> = {
14
+ 'medieval-illuminated': '\u{1F4DC}',
15
+ 'medieval-textual': '\u{1F4D6}',
16
+ 'early-modern-print': '\u{1F5A8}',
17
+ 'modern-handwritten': '\u{270D}',
18
+ }
19
+
20
  interface Props {
21
  onOpenManuscript: (manuscriptId: string, profileId: string) => void
22
  onOpenPage?: (pageId: string) => void
 
29
  const [error, setError] = useState<string | null>(null)
30
  const [manuscripts, setManuscripts] = useState<Record<string, Manuscript[]>>({})
31
  const [expanding, setExpanding] = useState<string | null>(null)
32
+ const [selectedCorpus, setSelectedCorpus] = useState<Corpus | null>(null)
33
 
34
  useEffect(() => {
35
  fetchCorpora()
 
39
  }, [])
40
 
41
  const handleCorpusClick = async (corpus: Corpus) => {
42
+ setSelectedCorpus(corpus)
43
+
44
  const cached = manuscripts[corpus.id]
45
  if (cached) {
46
  if (cached.length === 1) onOpenManuscript(cached[0].id, corpus.profile_id)
 
53
  setManuscripts((prev) => ({ ...prev, [corpus.id]: ms }))
54
  if (ms.length === 1) onOpenManuscript(ms[0].id, corpus.profile_id)
55
  } catch {
56
+ // silent
57
  } finally {
58
  setExpanding(null)
59
  }
60
  }
61
 
62
+ // ── Loading state ───────────────────────────────────────────────────
63
  if (loading) {
64
  return (
65
+ <div className="min-h-screen bg-retro-dither flex items-center justify-center">
66
+ <RetroWindow title="IIIF Studio" className="w-72">
67
+ <div className="p-4 text-retro-sm text-center">
68
+ Chargement...
69
+ </div>
70
+ </RetroWindow>
71
  </div>
72
  )
73
  }
74
 
75
+ // ── Error state ─────────────────────────────────────────────────────
76
  if (error) {
77
  return (
78
+ <div className="min-h-screen bg-retro-dither flex items-center justify-center">
79
+ <RetroWindow title="Erreur" className="w-80">
80
+ <div className="p-4 text-retro-sm">
81
+ <p className="font-bold mb-2">Erreur de connexion</p>
82
+ <p className="text-retro-xs">{error}</p>
83
+ </div>
84
+ </RetroWindow>
85
  </div>
86
  )
87
  }
88
 
89
+ const selectedMs = selectedCorpus ? manuscripts[selectedCorpus.id] : undefined
90
+
91
  return (
92
+ <div className="min-h-screen bg-retro-dither flex flex-col">
93
+ {/* ── Menu bar (top of screen) ─────────────────────────────── */}
94
+ <RetroMenuBar
95
+ items={[
96
+ { label: 'IIIF Studio' },
97
+ { label: 'Fichier' },
98
+ { label: 'Corpus' },
99
+ { label: 'Aide' },
100
+ ]}
101
+ right={
102
+ <div className="flex items-center gap-1">
103
+ <SearchBar onSelectResult={onOpenPage ? (r) => onOpenPage(r.page_id) : undefined} />
104
+ <AdminNav onClick={onAdmin} />
105
+ </div>
106
+ }
107
+ />
108
+
109
+ {/* ── Desktop area ─────────────────────────────────────────── */}
110
+ <div className="flex-1 flex flex-col">
111
+
112
+ {/* ── Corpus list window (center) ──────────────────────── */}
113
+ <div className="flex-1 flex items-start justify-center p-6 gap-4">
114
+ <RetroWindow
115
+ title="Corpus disponibles"
116
+ statusBar={`${corpora.length} corpus enregistre${corpora.length > 1 ? 's' : ''}`}
117
+ className="w-full max-w-2xl"
118
+ scrollable
119
+ >
120
+ {corpora.length === 0 ? (
121
+ <div className="p-4 text-retro-sm text-retro-darkgray">
122
+ Aucun corpus enregistre. Creez-en un via Administration.
123
+ </div>
124
+ ) : (
125
+ <div className="divide-y divide-retro-gray">
126
+ {corpora.map((corpus) => (
127
+ <div key={corpus.id}>
128
+ <button
129
+ onClick={() => void handleCorpusClick(corpus)}
130
+ className={`
131
+ w-full text-left px-3 py-[6px] flex items-center gap-3
132
+ text-retro-sm font-retro
133
+ ${selectedCorpus?.id === corpus.id
134
+ ? 'bg-retro-select text-retro-select-text'
135
+ : 'hover:bg-retro-select hover:text-retro-select-text'
136
+ }
137
+ `}
138
+ >
139
+ <span className="text-[18px] leading-none shrink-0">
140
+ {PROFILE_GLYPHS[corpus.profile_id] || '\u{1F4C1}'}
141
+ </span>
142
+ <div className="flex-1 min-w-0">
143
+ <div className="font-bold truncate">{corpus.title}</div>
144
+ <div className={`text-retro-xs truncate ${
145
+ selectedCorpus?.id === corpus.id ? 'opacity-70' : 'text-retro-darkgray'
146
+ }`}>
147
+ {corpus.profile_id} β€” {corpus.slug}
148
+ </div>
149
+ </div>
150
+ </button>
151
+
152
+ {expanding === corpus.id && (
153
+ <div className="px-3 py-1 text-retro-xs text-retro-darkgray bg-retro-light">
154
+ Chargement...
155
+ </div>
156
+ )}
157
+ </div>
158
+ ))}
159
+ </div>
160
+ )}
161
+ </RetroWindow>
162
+
163
+ {/* ── Manuscripts sub-window (appears when a corpus has multiple) ── */}
164
+ {selectedMs && selectedMs.length > 1 && selectedCorpus && (
165
+ <RetroWindow
166
+ title={`Manuscrits β€” ${selectedCorpus.title}`}
167
+ onClose={() => setSelectedCorpus(null)}
168
+ statusBar={`${selectedMs.length} manuscrit${selectedMs.length > 1 ? 's' : ''}`}
169
+ className="w-80"
170
+ scrollable
171
+ >
172
+ <div className="divide-y divide-retro-gray">
173
+ {selectedMs.map((ms) => (
174
+ <button
175
+ key={ms.id}
176
+ onClick={() => onOpenManuscript(ms.id, selectedCorpus.profile_id)}
177
+ className="
178
+ w-full text-left px-3 py-[6px]
179
+ text-retro-sm font-retro
180
+ hover:bg-retro-select hover:text-retro-select-text
181
+ "
182
+ >
183
+ <div className="font-bold truncate">{ms.title}</div>
184
+ {ms.total_pages > 0 && (
185
+ <div className="text-retro-xs text-retro-darkgray">
186
+ {ms.total_pages} pages
187
+ </div>
188
+ )}
189
+ </button>
190
+ ))}
191
+ </div>
192
+ </RetroWindow>
193
+ )}
194
  </div>
195
+
196
+ {/* ── Desktop icons (bottom dock) ────────────────────────── */}
197
+ <div
198
+ className="
199
+ shrink-0 flex items-end justify-center gap-1 px-4 py-3
200
+ border-t-retro border-retro-black
201
+ bg-retro-gray
202
+ shadow-retro-outset
203
+ "
204
+ >
205
+ {corpora.map((corpus) => (
206
+ <RetroIcon
207
+ key={corpus.id}
208
+ glyph={PROFILE_GLYPHS[corpus.profile_id] || '\u{1F4C1}'}
209
+ label={corpus.slug}
210
+ selected={selectedCorpus?.id === corpus.id}
211
+ onClick={() => void handleCorpusClick(corpus)}
212
+ />
213
+ ))}
214
+ {corpora.length === 0 && (
215
+ <div className="text-retro-xs text-retro-darkgray py-2">
216
+ Aucun corpus
217
+ </div>
218
+ )}
219
  </div>
220
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  </div>
222
  )
223
  }
frontend/src/pages/Reader.tsx CHANGED
@@ -15,6 +15,7 @@ import LayerPanel from '../components/LayerPanel.tsx'
15
  import TranscriptionPanel from '../components/TranscriptionPanel.tsx'
16
  import TranslationPanel from '../components/TranslationPanel.tsx'
17
  import CommentaryPanel from '../components/CommentaryPanel.tsx'
 
18
 
19
  interface Props {
20
  manuscriptId: string
@@ -34,7 +35,6 @@ export default function Reader({ manuscriptId, profileId, onBack, onEdit }: Prop
34
  const [loading, setLoading] = useState(true)
35
  const [error, setError] = useState<string | null>(null)
36
 
37
- // Chargement initial : liste des pages + profil
38
  useEffect(() => {
39
  Promise.all([fetchPages(manuscriptId), fetchProfile(profileId)])
40
  .then(([pgs, prof]) => {
@@ -47,7 +47,6 @@ export default function Reader({ manuscriptId, profileId, onBack, onEdit }: Prop
47
  .finally(() => setLoading(false))
48
  }, [manuscriptId, profileId])
49
 
50
- // Chargement du master.json Γ  chaque changement de page
51
  useEffect(() => {
52
  if (pages.length === 0) return
53
  setMaster(null)
@@ -68,25 +67,38 @@ export default function Reader({ manuscriptId, profileId, onBack, onEdit }: Prop
68
  })
69
  }, [])
70
 
 
71
  if (loading) {
72
  return (
73
- <div className="flex items-center justify-center h-screen text-stone-500">
74
- Chargement…
 
 
75
  </div>
76
  )
77
  }
78
 
79
  if (error) {
80
- return <div className="p-8 text-red-600">Erreur : {error}</div>
 
 
 
 
 
 
81
  }
82
 
83
  if (pages.length === 0) {
84
  return (
85
- <div className="p-8 text-stone-500">
86
- Aucune page dans ce manuscrit.{' '}
87
- <button onClick={onBack} className="underline">
88
- Retour
89
- </button>
 
 
 
 
90
  </div>
91
  )
92
  }
@@ -96,106 +108,119 @@ export default function Reader({ manuscriptId, profileId, onBack, onEdit }: Prop
96
  const regions: Region[] = master?.layout?.regions ?? []
97
 
98
  return (
99
- <div className="flex flex-col h-screen bg-stone-100">
100
- {/* ── Barre de navigation ─────────────────────────────────────────────── */}
101
- <header className="flex items-center gap-3 bg-stone-900 text-stone-100 px-5 py-2.5 shrink-0">
102
- <button
103
- onClick={onBack}
104
- className="text-stone-400 hover:text-stone-100 text-sm transition-colors"
105
- >
106
- ← Corpus
107
- </button>
108
- <span className="text-stone-600">|</span>
109
- <span className="text-sm font-medium text-stone-200 truncate max-w-xs">
110
- {profile?.label ?? profileId}
111
- </span>
112
-
113
- <div className="ml-auto flex items-center gap-2">
114
- <span className="text-stone-400 text-xs">
115
- {currentPage.folio_label} β€” {currentIndex + 1} / {pages.length}
116
- </span>
117
- {onEdit && (
118
- <button
119
- onClick={() => onEdit(currentPage.id)}
120
- className="px-3 py-1 bg-amber-600 hover:bg-amber-500 rounded text-sm text-white transition-colors"
121
  >
122
- Γ‰diter cette page
123
- </button>
124
- )}
125
- <button
126
- disabled={currentIndex === 0}
127
- onClick={() => setCurrentIndex((i) => i - 1)}
128
- className="px-3 py-1 bg-stone-700 hover:bg-stone-600 disabled:opacity-30 rounded text-sm transition-colors"
129
- >
130
- ←
131
- </button>
132
- <button
133
- disabled={currentIndex === pages.length - 1}
134
- onClick={() => setCurrentIndex((i) => i + 1)}
135
- className="px-3 py-1 bg-stone-700 hover:bg-stone-600 disabled:opacity-30 rounded text-sm transition-colors"
136
- >
137
- β†’
138
- </button>
139
- </div>
140
- </header>
141
-
142
- {/* ── Contenu principal ───────────────────────────────────────────────── */}
143
- <div className="flex flex-1 overflow-hidden">
144
- {/* Visionneuse 70% */}
145
- <div className="relative flex flex-col" style={{ width: '70%' }}>
146
- <Viewer imageUrl={imageUrl} onViewerReady={handleViewerReady} />
147
- <RegionOverlay
148
- viewer={osdViewer}
149
- regions={regions}
150
- onRegionClick={setSelectedRegion}
151
- />
152
-
153
- {/* Fiche rΓ©gion (popup) */}
154
- {selectedRegion && (
155
- <div className="absolute bottom-14 left-4 bg-white/95 backdrop-blur-sm rounded-lg shadow-xl border border-stone-200 p-3 max-w-xs text-xs">
156
- <div className="flex items-center justify-between gap-4 mb-1.5">
157
- <span className="font-semibold text-stone-800 capitalize">
158
- {selectedRegion.type.replace(/_/g, ' ')}
159
- </span>
160
- <button
161
- onClick={() => setSelectedRegion(null)}
162
- className="text-stone-400 hover:text-stone-700 leading-none"
163
- >
164
- βœ•
165
- </button>
166
- </div>
167
- <div className="space-y-0.5 text-stone-500">
168
- <div>id : <span className="font-mono">{selectedRegion.id}</span></div>
169
- <div>confiance : {(selectedRegion.confidence * 100).toFixed(0)} %</div>
170
- <div>bbox : [{selectedRegion.bbox.join(', ')}]</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  </div>
172
- </div>
173
- )}
174
 
175
- {/* Indicateur page non analysΓ©e */}
176
- {!master && !loading && imageUrl && (
177
- <div className="absolute top-3 left-3 bg-amber-500/90 text-white text-xs px-3 py-1 rounded-full">
178
- Page non analysΓ©e
179
- </div>
180
- )}
181
- </div>
 
182
 
183
- {/* Panneaux droite 30% */}
184
- <div
185
- className="flex flex-col overflow-hidden border-l border-stone-200 bg-white"
186
- style={{ width: '30%' }}
 
187
  >
188
- {profile && (
189
- <LayerPanel
190
- activeLayers={profile.active_layers}
191
- visibleLayers={visibleLayers}
192
- onToggle={toggleLayer}
193
- />
194
- )}
 
 
195
 
196
- <div className="flex-1 overflow-y-auto divide-y divide-stone-100">
197
  {master ? (
198
- <>
199
  <TranscriptionPanel
200
  ocr={master.ocr}
201
  editorial={master.editorial}
@@ -212,18 +237,17 @@ export default function Reader({ manuscriptId, profileId, onBack, onEdit }: Prop
212
  visiblePublic={visibleLayers.has('public_commentary')}
213
  visibleScholarly={visibleLayers.has('scholarly_commentary')}
214
  />
215
- </>
216
  ) : (
217
- <div className="p-4 text-sm text-stone-400 italic">
218
- {imageUrl ? (
219
- <span>Page non encore analysΓ©e par l&apos;IA.</span>
220
- ) : (
221
- <span>Aucune image associΓ©e Γ  cette page.</span>
222
- )}
223
  </div>
224
  )}
225
  </div>
226
- </div>
227
  </div>
228
  </div>
229
  )
 
15
  import TranscriptionPanel from '../components/TranscriptionPanel.tsx'
16
  import TranslationPanel from '../components/TranslationPanel.tsx'
17
  import CommentaryPanel from '../components/CommentaryPanel.tsx'
18
+ import { RetroMenuBar, RetroWindow, RetroButton, RetroBadge } from '../components/retro'
19
 
20
  interface Props {
21
  manuscriptId: string
 
35
  const [loading, setLoading] = useState(true)
36
  const [error, setError] = useState<string | null>(null)
37
 
 
38
  useEffect(() => {
39
  Promise.all([fetchPages(manuscriptId), fetchProfile(profileId)])
40
  .then(([pgs, prof]) => {
 
47
  .finally(() => setLoading(false))
48
  }, [manuscriptId, profileId])
49
 
 
50
  useEffect(() => {
51
  if (pages.length === 0) return
52
  setMaster(null)
 
67
  })
68
  }, [])
69
 
70
+ // ── Loading ─────────────────────────────────────────────────────────
71
  if (loading) {
72
  return (
73
+ <div className="min-h-screen bg-retro-dither flex items-center justify-center">
74
+ <RetroWindow title="Chargement" className="w-64">
75
+ <div className="p-4 text-retro-sm text-center">Chargement...</div>
76
+ </RetroWindow>
77
  </div>
78
  )
79
  }
80
 
81
  if (error) {
82
+ return (
83
+ <div className="min-h-screen bg-retro-dither flex items-center justify-center">
84
+ <RetroWindow title="Erreur" className="w-80">
85
+ <div className="p-4 text-retro-sm">{error}</div>
86
+ </RetroWindow>
87
+ </div>
88
+ )
89
  }
90
 
91
  if (pages.length === 0) {
92
  return (
93
+ <div className="min-h-screen bg-retro-dither flex items-center justify-center">
94
+ <RetroWindow title="Manuscrit vide" className="w-80">
95
+ <div className="p-4 text-retro-sm">
96
+ Aucune page dans ce manuscrit.
97
+ <div className="mt-2">
98
+ <RetroButton onClick={onBack}>Retour</RetroButton>
99
+ </div>
100
+ </div>
101
+ </RetroWindow>
102
  </div>
103
  )
104
  }
 
108
  const regions: Region[] = master?.layout?.regions ?? []
109
 
110
  return (
111
+ <div className="flex flex-col h-screen bg-retro-dither">
112
+ {/* ── Menu bar ───────────────────────────────────────────────── */}
113
+ <RetroMenuBar
114
+ items={[
115
+ { label: 'IIIF Studio', onClick: onBack },
116
+ { label: profile?.label ?? profileId },
117
+ ]}
118
+ right={
119
+ <div className="flex items-center gap-1">
120
+ <span className="text-retro-xs px-2">
121
+ {currentPage.folio_label} β€” {currentIndex + 1}/{pages.length}
122
+ </span>
123
+ <RetroButton
124
+ size="sm"
125
+ disabled={currentIndex === 0}
126
+ onClick={() => setCurrentIndex((i) => i - 1)}
 
 
 
 
 
 
127
  >
128
+ Prev
129
+ </RetroButton>
130
+ <RetroButton
131
+ size="sm"
132
+ disabled={currentIndex === pages.length - 1}
133
+ onClick={() => setCurrentIndex((i) => i + 1)}
134
+ >
135
+ Next
136
+ </RetroButton>
137
+ {onEdit && (
138
+ <RetroButton size="sm" onClick={() => onEdit(currentPage.id)}>
139
+ Editer
140
+ </RetroButton>
141
+ )}
142
+ </div>
143
+ }
144
+ />
145
+
146
+ {/* ── Main content ───────────────────────────────────────────── */}
147
+ <div className="flex flex-1 overflow-hidden p-1 gap-1">
148
+
149
+ {/* ── Viewer window (left, 70%) ──────────────────────────── */}
150
+ <RetroWindow
151
+ title={`Folio ${currentPage.folio_label}`}
152
+ statusBar={
153
+ master
154
+ ? `${master.editorial.status} β€” v${master.editorial.version}`
155
+ : imageUrl ? 'Page non analysee' : 'Aucune image'
156
+ }
157
+ className="flex-[7] min-w-0"
158
+ >
159
+ <div className="relative w-full h-full">
160
+ <Viewer imageUrl={imageUrl} onViewerReady={handleViewerReady} />
161
+ <RegionOverlay
162
+ viewer={osdViewer}
163
+ regions={regions}
164
+ onRegionClick={setSelectedRegion}
165
+ />
166
+
167
+ {/* Region info popup */}
168
+ {selectedRegion && (
169
+ <div
170
+ className="
171
+ absolute bottom-12 left-2
172
+ border-retro border-retro-black
173
+ bg-retro-white shadow-retro
174
+ text-retro-xs p-2 max-w-[220px]
175
+ "
176
+ >
177
+ <div className="flex items-center justify-between gap-2 mb-1">
178
+ <span className="font-bold capitalize">
179
+ {selectedRegion.type.replace(/_/g, ' ')}
180
+ </span>
181
+ <button
182
+ onClick={() => setSelectedRegion(null)}
183
+ className="text-retro-black font-bold hover:bg-retro-black hover:text-retro-white px-1"
184
+ >
185
+ x
186
+ </button>
187
+ </div>
188
+ <div className="space-y-[1px] text-retro-darkgray">
189
+ <div>id: {selectedRegion.id}</div>
190
+ <div>confiance: {(selectedRegion.confidence * 100).toFixed(0)}%</div>
191
+ <div>bbox: [{selectedRegion.bbox.join(', ')}]</div>
192
+ </div>
193
  </div>
194
+ )}
 
195
 
196
+ {/* Not analyzed badge */}
197
+ {!master && !loading && imageUrl && (
198
+ <div className="absolute top-2 left-2">
199
+ <RetroBadge variant="warning">Non analysee</RetroBadge>
200
+ </div>
201
+ )}
202
+ </div>
203
+ </RetroWindow>
204
 
205
+ {/* ── Right panels (30%) ─────────────────────────────────── */}
206
+ <RetroWindow
207
+ title="Analyse"
208
+ className="flex-[3] min-w-0"
209
+ scrollable
210
  >
211
+ <div className="flex flex-col">
212
+ {/* Layer toggles */}
213
+ {profile && (
214
+ <LayerPanel
215
+ activeLayers={profile.active_layers}
216
+ visibleLayers={visibleLayers}
217
+ onToggle={toggleLayer}
218
+ />
219
+ )}
220
 
221
+ {/* Content panels */}
222
  {master ? (
223
+ <div className="divide-y divide-retro-gray">
224
  <TranscriptionPanel
225
  ocr={master.ocr}
226
  editorial={master.editorial}
 
237
  visiblePublic={visibleLayers.has('public_commentary')}
238
  visibleScholarly={visibleLayers.has('scholarly_commentary')}
239
  />
240
+ </div>
241
  ) : (
242
+ <div className="p-3 text-retro-sm text-retro-darkgray">
243
+ {imageUrl
244
+ ? 'Page non encore analysee par l\'IA.'
245
+ : 'Aucune image associee a cette page.'
246
+ }
 
247
  </div>
248
  )}
249
  </div>
250
+ </RetroWindow>
251
  </div>
252
  </div>
253
  )
frontend/tailwind.config.js CHANGED
@@ -2,7 +2,53 @@
2
  export default {
3
  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
4
  theme: {
5
- extend: {},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  },
7
  plugins: [],
8
  }
 
2
  export default {
3
  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
4
  theme: {
5
+ extend: {
6
+ /* ── Retro palette ─────────────────────────────────────────── */
7
+ colors: {
8
+ retro: {
9
+ white: '#ffffff',
10
+ light: '#dfdfdf',
11
+ gray: '#c0c0c0',
12
+ darkgray: '#808080',
13
+ black: '#000000',
14
+ /* Accent for selected / active items */
15
+ select: '#000080',
16
+ 'select-text': '#ffffff',
17
+ },
18
+ },
19
+
20
+ /* ── Retro fonts ───────────────────────────────────────────── */
21
+ fontFamily: {
22
+ retro: ['"IBM Plex Mono"', '"Courier New"', 'Courier', 'monospace'],
23
+ mono: ['"IBM Plex Mono"', '"Courier New"', 'Courier', 'monospace'],
24
+ },
25
+
26
+ /* ── Hard drop shadows (no blur) ───────────────────────────── */
27
+ boxShadow: {
28
+ 'retro': '2px 2px 0px 0px #000000',
29
+ 'retro-lg': '3px 3px 0px 0px #000000',
30
+ /* Outset bevel (button at rest) */
31
+ 'retro-outset': 'inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff',
32
+ /* Inset bevel (button pressed) */
33
+ 'retro-inset': 'inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff',
34
+ /* Window inner area */
35
+ 'retro-well': 'inset 1px 1px 0 #808080, inset -1px -1px 0 #dfdfdf',
36
+ },
37
+
38
+ /* ── Spacing / sizing tokens ───────────────────────────────── */
39
+ borderWidth: {
40
+ 'retro': '2px',
41
+ },
42
+
43
+ fontSize: {
44
+ 'retro-xs': ['11px', { lineHeight: '16px' }],
45
+ 'retro-sm': ['12px', { lineHeight: '18px' }],
46
+ 'retro-base': ['13px', { lineHeight: '20px' }],
47
+ 'retro-lg': ['15px', { lineHeight: '22px' }],
48
+ 'retro-xl': ['18px', { lineHeight: '26px' }],
49
+ 'retro-2xl': ['22px', { lineHeight: '30px' }],
50
+ },
51
+ },
52
  },
53
  plugins: [],
54
  }