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

feat(frontend): Sprint R4 — retro Editor

Browse files

Redesign the Editor page with retro-computing aesthetic:

- RetroMenuBar with page info, version/status, save button
- Two RetroWindows side by side: Image viewer + Editor panel
- Tab bar with RetroButtons (Transcription/Commentaire/Regions/Historique)
- RetroTextarea for OCR text and commentary editing
- RetroSelect for editorial status
- Region list with RetroButton validate/reject controls
- History list with RetroBadge status + Restaurer buttons
- Loading/error states in centered RetroWindow dialogs
- All forms use retro primitives (inset wells, pixel font)

Build passes (tsc + vite).

https://claude.ai/code/session_01WWohTtw2CxGRawmpH1tyrY

Files changed (1) hide show
  1. frontend/src/pages/Editor.tsx +204 -201
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
  )