Spaces:
Build error
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 +19 -8
- backend/app/api/v1/corpora.py +27 -1
- backend/app/api/v1/manuscripts.py +44 -0
- backend/app/main.py +20 -8
- frontend/index.html +12 -0
- frontend/package.json +26 -0
- frontend/postcss.config.js +6 -0
- frontend/src/App.tsx +29 -0
- frontend/src/components/CommentaryPanel.tsx +81 -0
- frontend/src/components/LayerPanel.tsx +47 -0
- frontend/src/components/RegionOverlay.tsx +74 -0
- frontend/src/components/TranscriptionPanel.tsx +63 -0
- frontend/src/components/TranslationPanel.tsx +52 -0
- frontend/src/components/Viewer.tsx +84 -0
- frontend/src/index.css +3 -0
- frontend/src/lib/api.ts +149 -0
- frontend/src/main.tsx +10 -0
- frontend/src/pages/Home.tsx +126 -0
- frontend/src/pages/Reader.tsx +219 -0
- frontend/tailwind.config.js +8 -0
- frontend/tsconfig.json +21 -0
- frontend/tsconfig.node.json +11 -0
- frontend/vite.config.ts +14 -0
- infra/Dockerfile +21 -7
|
@@ -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 |
|
|
@@ -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())
|
|
@@ -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())
|
|
@@ -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")
|
|
@@ -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>
|
|
@@ -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 |
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
|
@@ -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}`)
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
})
|
|
@@ -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 |
|