Claude commited on
Commit
eec7490
·
unverified ·
1 Parent(s): 93ec6a5

feat(sprint5): frontend React minimal — visionneuse + 4 couches

Browse files

## Backend
- GET /api/v1/corpora/{id}/manuscripts (nouveau endpoint)
- GET /api/v1/manuscripts/{id}/pages (nouveau router manuscripts.py)
- main.py : SPA catch-all — sert /app/static/ en prod, /docs en dev
- Dockerfile + infra/Dockerfile : multi-stage (node:20-slim + python:3.11-slim)

## Frontend (frontend/)
- React 18 + Vite 5 + TypeScript strict + Tailwind CSS v3
- OpenSeadragon 4.x : zoom / pan / plein écran
- Viewer.tsx + RegionOverlay.tsx : overlays SVG par type de région
- LayerPanel.tsx : cases à cocher pilotées par CorpusProfile.active_layers
- TranscriptionPanel / TranslationPanel / CommentaryPanel (onglets Public/Savant)
- Home.tsx : liste des corpus, navigation vers manuscrits
- Reader.tsx : layout 70/30, navigation page précédente/suivante
- lib/api.ts : types TypeScript + fetchCorpora/fetchPages/fetchMasterJson…

https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg

Dockerfile CHANGED
@@ -1,16 +1,24 @@
1
- # Scriptorium AI — image de production
2
  # Ce fichier est la copie exacte de infra/Dockerfile.
3
  # Il est requis à la racine du dépôt pour HuggingFace Spaces (SDK docker).
4
  #
5
  # Build depuis la racine du dépôt :
6
  # docker build -t scriptorium-ai .
7
- #
8
- # Structure attendue dans l'image :
9
- # /app/backend/app/ ← source Python (importable via PYTHONPATH)
10
- # /app/profiles/ ← profils JSON
11
- # /app/prompts/ ← templates de prompts
12
- # /app/data/ ← créé vide ; à monter en volume pour les artefacts
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  FROM python:3.11-slim
15
 
16
  WORKDIR /app
@@ -25,11 +33,14 @@ RUN mkdir -p /tmp/build/app \
25
  && pip install --no-cache-dir /tmp/build/ \
26
  && rm -rf /tmp/build
27
 
28
- # ── Code source ────────────────────────────────────────────────────────────
29
  COPY backend/app ./backend/app
30
  COPY profiles/ ./profiles/
31
  COPY prompts/ ./prompts/
32
 
 
 
 
33
  # ── Répertoire des artefacts (vide dans l'image ; monté en volume) ─────────
34
  RUN mkdir -p /app/data
35
 
 
1
+ # Scriptorium AI — image de production (multi-stage)
2
  # Ce fichier est la copie exacte de infra/Dockerfile.
3
  # Il est requis à la racine du dépôt pour HuggingFace Spaces (SDK docker).
4
  #
5
  # Build depuis la racine du dépôt :
6
  # docker build -t scriptorium-ai .
 
 
 
 
 
 
7
 
8
+ # ── Stage 1 : build du frontend React ────────────────────────────────────────
9
+ FROM node:20-slim AS frontend-builder
10
+
11
+ WORKDIR /frontend
12
+
13
+ # Installer les dépendances (cache layer séparé)
14
+ COPY frontend/package.json ./
15
+ RUN npm install
16
+
17
+ # Copier les sources et builder
18
+ COPY frontend/ ./
19
+ RUN npm run build
20
+
21
+ # ── Stage 2 : image Python finale ────────────────────────────────────────────
22
  FROM python:3.11-slim
23
 
24
  WORKDIR /app
 
33
  && pip install --no-cache-dir /tmp/build/ \
34
  && rm -rf /tmp/build
35
 
36
+ # ── Code source backend ────────────────────────────────────────────────────
37
  COPY backend/app ./backend/app
38
  COPY profiles/ ./profiles/
39
  COPY prompts/ ./prompts/
40
 
41
+ # ── Frontend buildé ────────────────────────────────────────────────────────
42
+ COPY --from=frontend-builder /frontend/dist ./static
43
+
44
  # ── Répertoire des artefacts (vide dans l'image ; monté en volume) ─────────
45
  RUN mkdir -p /app/data
46
 
backend/app/api/v1/corpora.py CHANGED
@@ -5,6 +5,7 @@ GET /api/v1/corpora
5
  POST /api/v1/corpora
6
  GET /api/v1/corpora/{corpus_id}
7
  DELETE /api/v1/corpora/{corpus_id}
 
8
 
9
  Règle : toute logique métier est dans les services, jamais dans les routers.
10
  """
@@ -19,7 +20,7 @@ from sqlalchemy import select
19
  from sqlalchemy.ext.asyncio import AsyncSession
20
 
21
  # 3. local
22
- from app.models.corpus import CorpusModel
23
  from app.models.database import get_db
24
 
25
  router = APIRouter(prefix="/corpora", tags=["corpora"])
@@ -44,6 +45,17 @@ class CorpusResponse(BaseModel):
44
  updated_at: datetime
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
47
  # ── Endpoints ────────────────────────────────────────────────────────────────
48
 
49
  @router.get("", response_model=list[CorpusResponse])
@@ -97,3 +109,17 @@ async def delete_corpus(corpus_id: str, db: AsyncSession = Depends(get_db)) -> N
97
  raise HTTPException(status_code=404, detail="Corpus introuvable")
98
  await db.delete(corpus)
99
  await db.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  POST /api/v1/corpora
6
  GET /api/v1/corpora/{corpus_id}
7
  DELETE /api/v1/corpora/{corpus_id}
8
+ GET /api/v1/corpora/{corpus_id}/manuscripts
9
 
10
  Règle : toute logique métier est dans les services, jamais dans les routers.
11
  """
 
20
  from sqlalchemy.ext.asyncio import AsyncSession
21
 
22
  # 3. local
23
+ from app.models.corpus import CorpusModel, ManuscriptModel
24
  from app.models.database import get_db
25
 
26
  router = APIRouter(prefix="/corpora", tags=["corpora"])
 
45
  updated_at: datetime
46
 
47
 
48
+ class ManuscriptResponse(BaseModel):
49
+ model_config = ConfigDict(from_attributes=True)
50
+
51
+ id: str
52
+ corpus_id: str
53
+ title: str
54
+ shelfmark: str | None
55
+ date_label: str | None
56
+ total_pages: int
57
+
58
+
59
  # ── Endpoints ────────────────────────────────────────────────────────────────
60
 
61
  @router.get("", response_model=list[CorpusResponse])
 
109
  raise HTTPException(status_code=404, detail="Corpus introuvable")
110
  await db.delete(corpus)
111
  await db.commit()
112
+
113
+
114
+ @router.get("/{corpus_id}/manuscripts", response_model=list[ManuscriptResponse])
115
+ async def list_manuscripts(
116
+ corpus_id: str, db: AsyncSession = Depends(get_db)
117
+ ) -> list[ManuscriptModel]:
118
+ """Retourne tous les manuscrits d'un corpus."""
119
+ corpus = await db.get(CorpusModel, corpus_id)
120
+ if corpus is None:
121
+ raise HTTPException(status_code=404, detail="Corpus introuvable")
122
+ result = await db.execute(
123
+ select(ManuscriptModel).where(ManuscriptModel.corpus_id == corpus_id)
124
+ )
125
+ return list(result.scalars().all())
backend/app/api/v1/manuscripts.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Endpoints pour les manuscrits (R10 — préfixe /api/v1/).
3
+
4
+ GET /api/v1/manuscripts/{manuscript_id}/pages
5
+ """
6
+ # 2. third-party
7
+ from fastapi import APIRouter, Depends, HTTPException
8
+ from pydantic import BaseModel, ConfigDict
9
+ from sqlalchemy import select
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ # 3. local
13
+ from app.models.corpus import ManuscriptModel, PageModel
14
+ from app.models.database import get_db
15
+
16
+ router = APIRouter(prefix="/manuscripts", tags=["manuscripts"])
17
+
18
+
19
+ class PageResponse(BaseModel):
20
+ model_config = ConfigDict(from_attributes=True)
21
+
22
+ id: str
23
+ manuscript_id: str
24
+ folio_label: str
25
+ sequence: int
26
+ image_master_path: str | None
27
+ processing_status: str
28
+ confidence_summary: float | None
29
+
30
+
31
+ @router.get("/{manuscript_id}/pages", response_model=list[PageResponse])
32
+ async def list_pages(
33
+ manuscript_id: str, db: AsyncSession = Depends(get_db)
34
+ ) -> list[PageModel]:
35
+ """Retourne toutes les pages d'un manuscrit, triées par séquence."""
36
+ ms = await db.get(ManuscriptModel, manuscript_id)
37
+ if ms is None:
38
+ raise HTTPException(status_code=404, detail="Manuscrit introuvable")
39
+ result = await db.execute(
40
+ select(PageModel)
41
+ .where(PageModel.manuscript_id == manuscript_id)
42
+ .order_by(PageModel.sequence)
43
+ )
44
+ return list(result.scalars().all())
backend/app/main.py CHANGED
@@ -8,15 +8,16 @@ La BDD SQLite est créée automatiquement au démarrage (lifespan).
8
  # 1. stdlib
9
  import logging
10
  from contextlib import asynccontextmanager
 
11
 
12
  # 2. third-party
13
  from fastapi import FastAPI
14
  from fastapi.middleware.cors import CORSMiddleware
15
- from fastapi.responses import RedirectResponse
16
 
17
  # 3. local — on importe les modèles pour que Base.metadata les connaisse
18
  import app.models # noqa: F401 (enregistrement des modèles SQLAlchemy)
19
- from app.api.v1 import corpora, export, ingest, jobs, models_api, pages, profiles
20
  from app.models.database import Base, engine
21
 
22
  logger = logging.getLogger(__name__)
@@ -49,19 +50,30 @@ app.add_middleware(
49
  allow_headers=["*"],
50
  )
51
 
52
- # ── Root redirect → /docs ─────────────────────────────────────────────────────
53
- @app.get("/", include_in_schema=False)
54
- async def root() -> RedirectResponse:
55
- return RedirectResponse(url="/docs")
56
-
57
-
58
  # ── Routers (préfixe /api/v1/ — R10) ─────────────────────────────────────────
59
  _V1_PREFIX = "/api/v1"
60
 
61
  app.include_router(corpora.router, prefix=_V1_PREFIX)
 
62
  app.include_router(pages.router, prefix=_V1_PREFIX)
63
  app.include_router(export.router, prefix=_V1_PREFIX)
64
  app.include_router(profiles.router, prefix=_V1_PREFIX)
65
  app.include_router(jobs.router, prefix=_V1_PREFIX)
66
  app.include_router(ingest.router, prefix=_V1_PREFIX)
67
  app.include_router(models_api.router, prefix=_V1_PREFIX)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  # 1. stdlib
9
  import logging
10
  from contextlib import asynccontextmanager
11
+ from pathlib import Path
12
 
13
  # 2. third-party
14
  from fastapi import FastAPI
15
  from fastapi.middleware.cors import CORSMiddleware
16
+ from fastapi.responses import FileResponse, RedirectResponse
17
 
18
  # 3. local — on importe les modèles pour que Base.metadata les connaisse
19
  import app.models # noqa: F401 (enregistrement des modèles SQLAlchemy)
20
+ from app.api.v1 import corpora, export, ingest, jobs, manuscripts, models_api, pages, profiles
21
  from app.models.database import Base, engine
22
 
23
  logger = logging.getLogger(__name__)
 
50
  allow_headers=["*"],
51
  )
52
 
 
 
 
 
 
 
53
  # ── Routers (préfixe /api/v1/ — R10) ─────────────────────────────────────────
54
  _V1_PREFIX = "/api/v1"
55
 
56
  app.include_router(corpora.router, prefix=_V1_PREFIX)
57
+ app.include_router(manuscripts.router, prefix=_V1_PREFIX)
58
  app.include_router(pages.router, prefix=_V1_PREFIX)
59
  app.include_router(export.router, prefix=_V1_PREFIX)
60
  app.include_router(profiles.router, prefix=_V1_PREFIX)
61
  app.include_router(jobs.router, prefix=_V1_PREFIX)
62
  app.include_router(ingest.router, prefix=_V1_PREFIX)
63
  app.include_router(models_api.router, prefix=_V1_PREFIX)
64
+
65
+ # ── Serving frontend SPA (production) ou redirect /docs (dev) ────────────────
66
+ _STATIC_DIR = Path("/app/static")
67
+
68
+
69
+ @app.get("/{full_path:path}", include_in_schema=False)
70
+ async def serve_frontend(full_path: str) -> FileResponse | RedirectResponse:
71
+ """En production sert le frontend React (SPA). En dev redirige vers /docs."""
72
+ if _STATIC_DIR.is_dir():
73
+ candidate = _STATIC_DIR / full_path
74
+ if candidate.is_file():
75
+ return FileResponse(candidate)
76
+ index = _STATIC_DIR / "index.html"
77
+ if index.exists():
78
+ return FileResponse(index)
79
+ return RedirectResponse(url="/docs")
frontend/index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Scriptorium AI</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
frontend/package.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "scriptorium-ai-frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "tsc && vite build",
8
+ "preview": "vite preview"
9
+ },
10
+ "dependencies": {
11
+ "openseadragon": "^4.1.0",
12
+ "react": "^18.3.1",
13
+ "react-dom": "^18.3.1"
14
+ },
15
+ "devDependencies": {
16
+ "@types/openseadragon": "^3.0.10",
17
+ "@types/react": "^18.3.3",
18
+ "@types/react-dom": "^18.3.0",
19
+ "@vitejs/plugin-react": "^4.3.1",
20
+ "autoprefixer": "^10.4.19",
21
+ "postcss": "^8.4.38",
22
+ "tailwindcss": "^3.4.4",
23
+ "typescript": "^5.5.3",
24
+ "vite": "^5.3.4"
25
+ }
26
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/src/App.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import Home from './pages/Home.tsx'
3
+ import Reader from './pages/Reader.tsx'
4
+
5
+ type View =
6
+ | { name: 'home' }
7
+ | { name: 'reader'; manuscriptId: string; profileId: string }
8
+
9
+ export default function App() {
10
+ const [view, setView] = useState<View>({ name: 'home' })
11
+
12
+ if (view.name === 'reader') {
13
+ return (
14
+ <Reader
15
+ manuscriptId={view.manuscriptId}
16
+ profileId={view.profileId}
17
+ onBack={() => setView({ name: 'home' })}
18
+ />
19
+ )
20
+ }
21
+
22
+ return (
23
+ <Home
24
+ onOpenManuscript={(manuscriptId, profileId) =>
25
+ setView({ name: 'reader', manuscriptId, profileId })
26
+ }
27
+ />
28
+ )
29
+ }
frontend/src/components/CommentaryPanel.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {
21
+ commentary: Commentary | null
22
+ editorial: EditorialInfo
23
+ visiblePublic: boolean
24
+ visibleScholarly: boolean
25
+ }
26
+
27
+ const CommentaryPanel: FC<Props> = ({ commentary, editorial, visiblePublic, visibleScholarly }) => {
28
+ const [tab, setTab] = useState<'public' | 'scholarly'>('public')
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' :
36
+ tab
37
+
38
+ const content = activeTab === 'public' ? commentary?.public : commentary?.scholarly
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
+ )
79
+ }
80
+
81
+ export default CommentaryPanel
frontend/src/components/LayerPanel.tsx ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
17
+ interface Props {
18
+ activeLayers: string[]
19
+ visibleLayers: Set<string>
20
+ onToggle: (layer: string) => void
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>
45
+ )
46
+
47
+ export default LayerPanel
frontend/src/components/RegionOverlay.tsx ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, type FC } from 'react'
2
+ import type OpenSeadragon from 'openseadragon'
3
+ import type { Region } from '../lib/api.ts'
4
+
5
+ const REGION_COLORS: Record<string, string> = {
6
+ text_block: '#3b82f6', // bleu
7
+ miniature: '#f59e0b', // or
8
+ decorated_initial: '#10b981', // vert
9
+ margin: '#6b7280', // gris
10
+ rubric: '#ef4444', // rouge
11
+ other: '#8b5cf6', // violet
12
+ }
13
+
14
+ interface Props {
15
+ viewer: OpenSeadragon.Viewer | null
16
+ regions: Region[]
17
+ onRegionClick: (region: Region) => void
18
+ }
19
+
20
+ const RegionOverlay: FC<Props> = ({ viewer, regions, onRegionClick }) => {
21
+ useEffect(() => {
22
+ if (!viewer) return
23
+
24
+ const addOverlays = () => {
25
+ viewer.clearOverlays()
26
+ const item = viewer.world.getItemAt(0)
27
+ if (!item) return
28
+
29
+ for (const region of regions) {
30
+ const [x, y, w, h] = region.bbox
31
+ const color = REGION_COLORS[region.type] ?? REGION_COLORS['other']
32
+
33
+ const el = document.createElement('div')
34
+ el.style.border = `2px solid ${color}`
35
+ el.style.boxSizing = 'border-box'
36
+ el.style.cursor = 'pointer'
37
+ el.title = `${region.type} · ${(region.confidence * 100).toFixed(0)} %`
38
+
39
+ el.addEventListener('mouseenter', () => {
40
+ el.style.backgroundColor = `${color}33`
41
+ })
42
+ el.addEventListener('mouseleave', () => {
43
+ el.style.backgroundColor = ''
44
+ })
45
+ el.addEventListener('click', (e: MouseEvent) => {
46
+ e.stopPropagation()
47
+ onRegionClick(region)
48
+ })
49
+
50
+ const rect = item.imageToViewportRectangle(x, y, w, h)
51
+ viewer.addOverlay(el, rect)
52
+ }
53
+ }
54
+
55
+ if (viewer.isOpen()) {
56
+ addOverlays()
57
+ } else {
58
+ viewer.addOnceHandler('open', addOverlays)
59
+ }
60
+
61
+ return () => {
62
+ // Nettoyage : retire les overlays au prochain rendu
63
+ try {
64
+ viewer.clearOverlays()
65
+ } catch {
66
+ // viewer peut avoir été détruit lors du démontage
67
+ }
68
+ }
69
+ }, [viewer, regions, onRegionClick])
70
+
71
+ return null
72
+ }
73
+
74
+ export default RegionOverlay
frontend/src/components/TranscriptionPanel.tsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {
21
+ ocr: OCRResult | null
22
+ editorial: EditorialInfo
23
+ visible: boolean
24
+ }
25
+
26
+ 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
+ )
61
+ }
62
+
63
+ export default TranscriptionPanel
frontend/src/components/TranslationPanel.tsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {
21
+ translation: Translation | null
22
+ editorial: EditorialInfo
23
+ visible: boolean
24
+ }
25
+
26
+ 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
+ )
50
+ }
51
+
52
+ export default TranslationPanel
frontend/src/components/Viewer.tsx ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, type FC } from 'react'
2
+ import OpenSeadragon from 'openseadragon'
3
+
4
+ interface Props {
5
+ imageUrl: string
6
+ onViewerReady?: (viewer: OpenSeadragon.Viewer) => void
7
+ }
8
+
9
+ 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
+
17
+ const viewer = OpenSeadragon({
18
+ element: containerRef.current,
19
+ showNavigationControl: false,
20
+ gestureSettingsMouse: { clickToZoom: false, scrollToZoom: true, dragToPan: true },
21
+ gestureSettingsTouch: { scrollToZoom: true, dragToPan: true, pinchToZoom: true },
22
+ animationTime: 0.3,
23
+ minZoomLevel: 0.1,
24
+ maxZoomLevel: 20,
25
+ })
26
+
27
+ viewerRef.current = viewer
28
+
29
+ return () => {
30
+ viewer.destroy()
31
+ viewerRef.current = null
32
+ }
33
+ }, [])
34
+
35
+ // Ouvre l'image à chaque changement d'URL
36
+ useEffect(() => {
37
+ const viewer = viewerRef.current
38
+ if (!viewer || !imageUrl) return
39
+
40
+ viewer.open({ type: 'image', url: imageUrl })
41
+ viewer.addOnceHandler('open', () => {
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
+ )
82
+ }
83
+
84
+ export default Viewer
frontend/src/index.css ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
frontend/src/lib/api.ts ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const BASE_URL: string = import.meta.env.VITE_API_URL ?? ''
2
+
3
+ // ── Types ─────────────────────────────────────────────────────────────────────
4
+
5
+ export interface Corpus {
6
+ id: string
7
+ slug: string
8
+ title: string
9
+ profile_id: string
10
+ created_at: string
11
+ updated_at: string
12
+ }
13
+
14
+ export interface Manuscript {
15
+ id: string
16
+ corpus_id: string
17
+ title: string
18
+ shelfmark: string | null
19
+ date_label: string | null
20
+ total_pages: number
21
+ }
22
+
23
+ export interface Page {
24
+ id: string
25
+ manuscript_id: string
26
+ folio_label: string
27
+ sequence: number
28
+ image_master_path: string | null
29
+ processing_status: string
30
+ confidence_summary: number | null
31
+ }
32
+
33
+ export type RegionType =
34
+ | 'text_block'
35
+ | 'miniature'
36
+ | 'decorated_initial'
37
+ | 'margin'
38
+ | 'rubric'
39
+ | 'other'
40
+
41
+ export interface Region {
42
+ id: string
43
+ type: RegionType
44
+ bbox: [number, number, number, number]
45
+ confidence: number
46
+ polygon?: number[][] | null
47
+ parent_region_id?: string | null
48
+ }
49
+
50
+ export interface OCRResult {
51
+ diplomatic_text: string
52
+ language: string
53
+ confidence: number
54
+ uncertain_segments: string[]
55
+ }
56
+
57
+ export interface Translation {
58
+ fr: string
59
+ en: string
60
+ }
61
+
62
+ export interface CommentaryClaim {
63
+ claim: string
64
+ evidence_region_ids: string[]
65
+ certainty: 'high' | 'medium' | 'low' | 'speculative'
66
+ }
67
+
68
+ export interface Commentary {
69
+ public: string
70
+ scholarly: string
71
+ claims: CommentaryClaim[]
72
+ }
73
+
74
+ export type EditorialStatus =
75
+ | 'machine_draft'
76
+ | 'needs_review'
77
+ | 'reviewed'
78
+ | 'validated'
79
+ | 'published'
80
+
81
+ export interface EditorialInfo {
82
+ status: EditorialStatus
83
+ validated: boolean
84
+ validated_by: string | null
85
+ version: number
86
+ notes: string[]
87
+ }
88
+
89
+ export interface ImageInfo {
90
+ master?: string
91
+ derivative_web?: string
92
+ iiif_base?: string
93
+ width?: number
94
+ height?: number
95
+ }
96
+
97
+ export interface PageMaster {
98
+ schema_version: string
99
+ page_id: string
100
+ corpus_profile: string
101
+ manuscript_id: string
102
+ folio_label: string
103
+ sequence: number
104
+ image: ImageInfo
105
+ layout: { regions: Region[] }
106
+ ocr: OCRResult | null
107
+ translation: Translation | null
108
+ summary: { short: string; detailed: string } | null
109
+ commentary: Commentary | null
110
+ editorial: EditorialInfo
111
+ }
112
+
113
+ export interface CorpusProfile {
114
+ profile_id: string
115
+ label: string
116
+ language_hints: string[]
117
+ script_type: string
118
+ active_layers: string[]
119
+ uncertainty_config: { flag_below: number; min_acceptable: number }
120
+ export_config: { mets: boolean; alto: boolean; tei: boolean }
121
+ }
122
+
123
+ // ── Fetch helpers ─────────────────────────────────────────────────────────────
124
+
125
+ async function get<T>(path: string): Promise<T> {
126
+ const resp = await fetch(`${BASE_URL}${path}`)
127
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} — ${path}`)
128
+ return resp.json() as Promise<T>
129
+ }
130
+
131
+ // ── API functions ─────────────────────────────────────────────────────────────
132
+
133
+ export const fetchCorpora = (): Promise<Corpus[]> =>
134
+ get('/api/v1/corpora')
135
+
136
+ export const fetchManuscripts = (corpusId: string): Promise<Manuscript[]> =>
137
+ get(`/api/v1/corpora/${corpusId}/manuscripts`)
138
+
139
+ export const fetchPages = (manuscriptId: string): Promise<Page[]> =>
140
+ get(`/api/v1/manuscripts/${manuscriptId}/pages`)
141
+
142
+ export const fetchMasterJson = (pageId: string): Promise<PageMaster> =>
143
+ get(`/api/v1/pages/${pageId}/master-json`)
144
+
145
+ export const fetchManifest = (manuscriptId: string): Promise<unknown> =>
146
+ get(`/api/v1/manuscripts/${manuscriptId}/iiif-manifest`)
147
+
148
+ export const fetchProfile = (profileId: string): Promise<CorpusProfile> =>
149
+ get(`/api/v1/profiles/${profileId}`)
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.tsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
frontend/src/pages/Home.tsx ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react'
2
+ import {
3
+ fetchCorpora,
4
+ fetchManuscripts,
5
+ type Corpus,
6
+ type Manuscript,
7
+ } from '../lib/api.ts'
8
+
9
+ interface Props {
10
+ onOpenManuscript: (manuscriptId: string, profileId: string) => void
11
+ }
12
+
13
+ export default function Home({ onOpenManuscript }: Props) {
14
+ const [corpora, setCorpora] = useState<Corpus[]>([])
15
+ const [loading, setLoading] = useState(true)
16
+ const [error, setError] = useState<string | null>(null)
17
+ const [manuscripts, setManuscripts] = useState<Record<string, Manuscript[]>>({})
18
+ const [expanding, setExpanding] = useState<string | null>(null)
19
+
20
+ useEffect(() => {
21
+ fetchCorpora()
22
+ .then(setCorpora)
23
+ .catch((e: Error) => setError(e.message))
24
+ .finally(() => setLoading(false))
25
+ }, [])
26
+
27
+ const handleCorpusClick = async (corpus: Corpus) => {
28
+ // Si déjà chargé, naviguer directement si un seul manuscrit
29
+ const cached = manuscripts[corpus.id]
30
+ if (cached) {
31
+ if (cached.length === 1) onOpenManuscript(cached[0].id, corpus.profile_id)
32
+ return
33
+ }
34
+
35
+ setExpanding(corpus.id)
36
+ try {
37
+ const ms = await fetchManuscripts(corpus.id)
38
+ setManuscripts((prev) => ({ ...prev, [corpus.id]: ms }))
39
+ if (ms.length === 1) onOpenManuscript(ms[0].id, corpus.profile_id)
40
+ } catch {
41
+ // Échec silencieux — la liste reste vide
42
+ } finally {
43
+ setExpanding(null)
44
+ }
45
+ }
46
+
47
+ if (loading) {
48
+ return (
49
+ <div className="flex items-center justify-center h-screen text-stone-500">
50
+ Chargement…
51
+ </div>
52
+ )
53
+ }
54
+
55
+ if (error) {
56
+ return (
57
+ <div className="p-8 text-red-600">
58
+ Erreur : {error}
59
+ </div>
60
+ )
61
+ }
62
+
63
+ return (
64
+ <div className="min-h-screen bg-stone-50">
65
+ <header className="bg-stone-900 text-stone-100 px-8 py-6">
66
+ <h1 className="text-2xl font-semibold tracking-tight">Scriptorium AI</h1>
67
+ <p className="text-stone-400 text-sm mt-1">
68
+ Plateforme de génération d'éditions savantes augmentées
69
+ </p>
70
+ </header>
71
+
72
+ <main className="max-w-3xl mx-auto py-10 px-8">
73
+ <h2 className="text-sm font-semibold text-stone-500 uppercase tracking-wide mb-6">
74
+ Corpus disponibles
75
+ </h2>
76
+
77
+ {corpora.length === 0 ? (
78
+ <p className="text-stone-400 text-sm">
79
+ Aucun corpus enregistré. Créez-en un via{' '}
80
+ <code className="bg-stone-200 px-1 rounded text-xs">POST /api/v1/corpora</code>.
81
+ </p>
82
+ ) : (
83
+ <ul className="space-y-3">
84
+ {corpora.map((corpus) => (
85
+ <li key={corpus.id}>
86
+ <button
87
+ onClick={() => void handleCorpusClick(corpus)}
88
+ 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"
89
+ >
90
+ <div className="font-medium text-stone-900">{corpus.title}</div>
91
+ <div className="text-xs text-stone-400 mt-1">
92
+ Profil : {corpus.profile_id} · Slug : {corpus.slug}
93
+ </div>
94
+ </button>
95
+
96
+ {expanding === corpus.id && (
97
+ <div className="mt-2 ml-4 text-xs text-stone-400">Chargement…</div>
98
+ )}
99
+
100
+ {manuscripts[corpus.id] && manuscripts[corpus.id].length > 1 && (
101
+ <ul className="mt-2 ml-4 space-y-1">
102
+ {manuscripts[corpus.id].map((ms) => (
103
+ <li key={ms.id}>
104
+ <button
105
+ onClick={() => onOpenManuscript(ms.id, corpus.profile_id)}
106
+ className="text-sm text-stone-600 hover:text-stone-900 hover:underline text-left"
107
+ >
108
+ {ms.title}
109
+ {ms.total_pages > 0 && (
110
+ <span className="text-stone-400 ml-1">
111
+ ({ms.total_pages} pages)
112
+ </span>
113
+ )}
114
+ </button>
115
+ </li>
116
+ ))}
117
+ </ul>
118
+ )}
119
+ </li>
120
+ ))}
121
+ </ul>
122
+ )}
123
+ </main>
124
+ </div>
125
+ )
126
+ }
frontend/src/pages/Reader.tsx ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import type OpenSeadragon from 'openseadragon'
3
+ import {
4
+ fetchPages,
5
+ fetchMasterJson,
6
+ fetchProfile,
7
+ type Page,
8
+ type PageMaster,
9
+ type CorpusProfile,
10
+ type Region,
11
+ } from '../lib/api.ts'
12
+ import Viewer from '../components/Viewer.tsx'
13
+ import RegionOverlay from '../components/RegionOverlay.tsx'
14
+ 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
21
+ profileId: string
22
+ onBack: () => void
23
+ }
24
+
25
+ export default function Reader({ manuscriptId, profileId, onBack }: Props) {
26
+ const [pages, setPages] = useState<Page[]>([])
27
+ const [currentIndex, setCurrentIndex] = useState(0)
28
+ const [master, setMaster] = useState<PageMaster | null>(null)
29
+ const [profile, setProfile] = useState<CorpusProfile | null>(null)
30
+ const [visibleLayers, setVisibleLayers] = useState<Set<string>>(new Set())
31
+ const [osdViewer, setOsdViewer] = useState<OpenSeadragon.Viewer | null>(null)
32
+ const [selectedRegion, setSelectedRegion] = useState<Region | null>(null)
33
+ const [loading, setLoading] = useState(true)
34
+ const [error, setError] = useState<string | null>(null)
35
+
36
+ // Chargement initial : liste des pages + profil
37
+ useEffect(() => {
38
+ Promise.all([fetchPages(manuscriptId), fetchProfile(profileId)])
39
+ .then(([pgs, prof]) => {
40
+ const sorted = [...pgs].sort((a, b) => a.sequence - b.sequence)
41
+ setPages(sorted)
42
+ setProfile(prof)
43
+ setVisibleLayers(new Set(prof.active_layers))
44
+ })
45
+ .catch((e: Error) => setError(e.message))
46
+ .finally(() => setLoading(false))
47
+ }, [manuscriptId, profileId])
48
+
49
+ // Chargement du master.json à chaque changement de page
50
+ useEffect(() => {
51
+ if (pages.length === 0) return
52
+ setMaster(null)
53
+ setSelectedRegion(null)
54
+ fetchMasterJson(pages[currentIndex].id).then(setMaster).catch(() => setMaster(null))
55
+ }, [pages, currentIndex])
56
+
57
+ const handleViewerReady = useCallback((v: OpenSeadragon.Viewer) => {
58
+ setOsdViewer(v)
59
+ }, [])
60
+
61
+ const toggleLayer = useCallback((layer: string) => {
62
+ setVisibleLayers((prev) => {
63
+ const next = new Set(prev)
64
+ if (next.has(layer)) next.delete(layer)
65
+ else next.add(layer)
66
+ return next
67
+ })
68
+ }, [])
69
+
70
+ if (loading) {
71
+ return (
72
+ <div className="flex items-center justify-center h-screen text-stone-500">
73
+ Chargement…
74
+ </div>
75
+ )
76
+ }
77
+
78
+ if (error) {
79
+ return <div className="p-8 text-red-600">Erreur : {error}</div>
80
+ }
81
+
82
+ if (pages.length === 0) {
83
+ return (
84
+ <div className="p-8 text-stone-500">
85
+ Aucune page dans ce manuscrit.{' '}
86
+ <button onClick={onBack} className="underline">
87
+ Retour
88
+ </button>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ const currentPage = pages[currentIndex]
94
+ const imageUrl = currentPage.image_master_path ?? ''
95
+ const regions: Region[] = master?.layout?.regions ?? []
96
+
97
+ return (
98
+ <div className="flex flex-col h-screen bg-stone-100">
99
+ {/* ── Barre de navigation ─────────────────────────────────────────────── */}
100
+ <header className="flex items-center gap-3 bg-stone-900 text-stone-100 px-5 py-2.5 shrink-0">
101
+ <button
102
+ onClick={onBack}
103
+ className="text-stone-400 hover:text-stone-100 text-sm transition-colors"
104
+ >
105
+ ← Corpus
106
+ </button>
107
+ <span className="text-stone-600">|</span>
108
+ <span className="text-sm font-medium text-stone-200 truncate max-w-xs">
109
+ {profile?.label ?? profileId}
110
+ </span>
111
+
112
+ <div className="ml-auto flex items-center gap-2">
113
+ <span className="text-stone-400 text-xs">
114
+ {currentPage.folio_label} — {currentIndex + 1} / {pages.length}
115
+ </span>
116
+ <button
117
+ disabled={currentIndex === 0}
118
+ onClick={() => setCurrentIndex((i) => i - 1)}
119
+ className="px-3 py-1 bg-stone-700 hover:bg-stone-600 disabled:opacity-30 rounded text-sm transition-colors"
120
+ >
121
+
122
+ </button>
123
+ <button
124
+ disabled={currentIndex === pages.length - 1}
125
+ onClick={() => setCurrentIndex((i) => i + 1)}
126
+ className="px-3 py-1 bg-stone-700 hover:bg-stone-600 disabled:opacity-30 rounded text-sm transition-colors"
127
+ >
128
+
129
+ </button>
130
+ </div>
131
+ </header>
132
+
133
+ {/* ── Contenu principal ───────────────────────────────────────────────── */}
134
+ <div className="flex flex-1 overflow-hidden">
135
+ {/* Visionneuse 70% */}
136
+ <div className="relative flex flex-col" style={{ width: '70%' }}>
137
+ <Viewer imageUrl={imageUrl} onViewerReady={handleViewerReady} />
138
+ <RegionOverlay
139
+ viewer={osdViewer}
140
+ regions={regions}
141
+ onRegionClick={setSelectedRegion}
142
+ />
143
+
144
+ {/* Fiche région (popup) */}
145
+ {selectedRegion && (
146
+ <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">
147
+ <div className="flex items-center justify-between gap-4 mb-1.5">
148
+ <span className="font-semibold text-stone-800 capitalize">
149
+ {selectedRegion.type.replace(/_/g, ' ')}
150
+ </span>
151
+ <button
152
+ onClick={() => setSelectedRegion(null)}
153
+ className="text-stone-400 hover:text-stone-700 leading-none"
154
+ >
155
+
156
+ </button>
157
+ </div>
158
+ <div className="space-y-0.5 text-stone-500">
159
+ <div>id : <span className="font-mono">{selectedRegion.id}</span></div>
160
+ <div>confiance : {(selectedRegion.confidence * 100).toFixed(0)} %</div>
161
+ <div>bbox : [{selectedRegion.bbox.join(', ')}]</div>
162
+ </div>
163
+ </div>
164
+ )}
165
+
166
+ {/* Indicateur page non analysée */}
167
+ {!master && !loading && imageUrl && (
168
+ <div className="absolute top-3 left-3 bg-amber-500/90 text-white text-xs px-3 py-1 rounded-full">
169
+ Page non analysée
170
+ </div>
171
+ )}
172
+ </div>
173
+
174
+ {/* Panneaux droite 30% */}
175
+ <div
176
+ className="flex flex-col overflow-hidden border-l border-stone-200 bg-white"
177
+ style={{ width: '30%' }}
178
+ >
179
+ {profile && (
180
+ <LayerPanel
181
+ activeLayers={profile.active_layers}
182
+ visibleLayers={visibleLayers}
183
+ onToggle={toggleLayer}
184
+ />
185
+ )}
186
+
187
+ <div className="flex-1 overflow-y-auto divide-y divide-stone-100">
188
+ {master ? (
189
+ <>
190
+ <TranscriptionPanel
191
+ ocr={master.ocr}
192
+ editorial={master.editorial}
193
+ visible={visibleLayers.has('ocr_diplomatic')}
194
+ />
195
+ <TranslationPanel
196
+ translation={master.translation}
197
+ editorial={master.editorial}
198
+ visible={visibleLayers.has('translation_fr')}
199
+ />
200
+ <CommentaryPanel
201
+ commentary={master.commentary}
202
+ editorial={master.editorial}
203
+ visiblePublic={visibleLayers.has('public_commentary')}
204
+ visibleScholarly={visibleLayers.has('scholarly_commentary')}
205
+ />
206
+ </>
207
+ ) : (
208
+ <div className="p-4 text-sm text-stone-400 italic">
209
+ {imageUrl
210
+ ? 'Cette page n'a pas encore été analysée par l'IA.'
211
+ : 'Aucune image associée à cette page.'}
212
+ </div>
213
+ )}
214
+ </div>
215
+ </div>
216
+ </div>
217
+ </div>
218
+ )
219
+ }
frontend/tailwind.config.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
4
+ theme: {
5
+ extend: {},
6
+ },
7
+ plugins: [],
8
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true
18
+ },
19
+ "include": ["src"],
20
+ "references": [{ "path": "./tsconfig.node.json" }]
21
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true,
8
+ "strict": true
9
+ },
10
+ "include": ["vite.config.ts"]
11
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ proxy: {
8
+ '/api/v1': {
9
+ target: 'http://localhost:7860',
10
+ changeOrigin: true,
11
+ },
12
+ },
13
+ },
14
+ })
infra/Dockerfile CHANGED
@@ -1,4 +1,5 @@
1
- # Scriptorium AI — image de production
 
2
  # Build depuis la racine du dépôt :
3
  # docker build -f infra/Dockerfile -t scriptorium-ai .
4
  #
@@ -6,16 +7,27 @@
6
  # /app/backend/app/ ← source Python (importable via PYTHONPATH)
7
  # /app/profiles/ ← profils JSON
8
  # /app/prompts/ ← templates de prompts
 
9
  # /app/data/ ← créé vide ; à monter en volume pour les artefacts
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  FROM python:3.11-slim
12
 
13
  WORKDIR /app
14
 
15
- # ── Dépendances système (lxml utilise des wheels binaires pré-compilés ;
16
- # aucun outil de build requis sur cette image) ─────────────────────────────
17
- # Aucun paquet système supplémentaire nécessaire.
18
-
19
  # ── Dépendances Python ─────────────────────────────────────────────────────
20
  # On copie uniquement pyproject.toml pour exploiter le cache de layers Docker.
21
  # Un stub app/__init__.py satisfait setuptools (discover packages) sans avoir
@@ -26,12 +38,14 @@ RUN mkdir -p /tmp/build/app \
26
  && pip install --no-cache-dir /tmp/build/ \
27
  && rm -rf /tmp/build
28
 
29
- # ── Code source ────────────────────────────────────────────────────────────
30
- # Copié APRÈS l'installation des dépendances pour conserver le cache.
31
  COPY backend/app ./backend/app
32
  COPY profiles/ ./profiles/
33
  COPY prompts/ ./prompts/
34
 
 
 
 
35
  # ── Répertoire des artefacts (vide dans l'image ; monté en volume) ─────────
36
  RUN mkdir -p /app/data
37
 
 
1
+ # Scriptorium AI — image de production (multi-stage)
2
+ # Ce fichier est la copie exacte de Dockerfile (racine).
3
  # Build depuis la racine du dépôt :
4
  # docker build -f infra/Dockerfile -t scriptorium-ai .
5
  #
 
7
  # /app/backend/app/ ← source Python (importable via PYTHONPATH)
8
  # /app/profiles/ ← profils JSON
9
  # /app/prompts/ ← templates de prompts
10
+ # /app/static/ ← frontend React buildé
11
  # /app/data/ ← créé vide ; à monter en volume pour les artefacts
12
 
13
+ # ── Stage 1 : build du frontend React ────────────────────────────────────────
14
+ FROM node:20-slim AS frontend-builder
15
+
16
+ WORKDIR /frontend
17
+
18
+ # Installer les dépendances (cache layer séparé)
19
+ COPY frontend/package.json ./
20
+ RUN npm install
21
+
22
+ # Copier les sources et builder
23
+ COPY frontend/ ./
24
+ RUN npm run build
25
+
26
+ # ── Stage 2 : image Python finale ────────────────────────────────────────────
27
  FROM python:3.11-slim
28
 
29
  WORKDIR /app
30
 
 
 
 
 
31
  # ── Dépendances Python ─────────────────────────────────────────────────────
32
  # On copie uniquement pyproject.toml pour exploiter le cache de layers Docker.
33
  # Un stub app/__init__.py satisfait setuptools (discover packages) sans avoir
 
38
  && pip install --no-cache-dir /tmp/build/ \
39
  && rm -rf /tmp/build
40
 
41
+ # ── Code source backend ────────────────────────────────────────────────────
 
42
  COPY backend/app ./backend/app
43
  COPY profiles/ ./profiles/
44
  COPY prompts/ ./prompts/
45
 
46
+ # ── Frontend buildé ────────────────────────────────────────────────────────
47
+ COPY --from=frontend-builder /frontend/dist ./static
48
+
49
  # ── Répertoire des artefacts (vide dans l'image ; monté en volume) ─────────
50
  RUN mkdir -p /app/data
51