Spaces:
Build error
Build error
Merge pull request #32 from maribakulj/claude/find-commit-dcec666-sI5Qn
Browse files- .gitignore +4 -0
- frontend/src/components/AdminNav.tsx +5 -6
- frontend/src/components/CommentaryPanel.tsx +35 -36
- frontend/src/components/LayerPanel.tsx +17 -24
- frontend/src/components/SearchBar.tsx +35 -15
- frontend/src/components/TranscriptionPanel.tsx +21 -24
- frontend/src/components/TranslationPanel.tsx +18 -21
- frontend/src/components/Viewer.tsx +21 -23
- frontend/src/components/retro/RetroBadge.tsx +38 -0
- frontend/src/components/retro/RetroButton.tsx +46 -0
- frontend/src/components/retro/RetroCheckbox.tsx +48 -0
- frontend/src/components/retro/RetroIcon.tsx +63 -0
- frontend/src/components/retro/RetroInput.tsx +30 -0
- frontend/src/components/retro/RetroMenuBar.tsx +55 -0
- frontend/src/components/retro/RetroSelect.tsx +38 -0
- frontend/src/components/retro/RetroTextarea.tsx +32 -0
- frontend/src/components/retro/RetroWindow.tsx +96 -0
- frontend/src/components/retro/index.ts +9 -0
- frontend/src/index.css +123 -0
- frontend/src/pages/Admin.tsx +233 -482
- frontend/src/pages/Editor.tsx +204 -201
- frontend/src/pages/Home.tsx +157 -70
- frontend/src/pages/Reader.tsx +135 -111
- frontend/tailwind.config.js +47 -1
.gitignore
CHANGED
|
@@ -5,6 +5,10 @@ data/
|
|
| 5 |
*.db
|
| 6 |
*.sqlite
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
# Python
|
| 9 |
__pycache__/
|
| 10 |
*.py[cod]
|
|
|
|
| 5 |
*.db
|
| 6 |
*.sqlite
|
| 7 |
|
| 8 |
+
# Node / Frontend
|
| 9 |
+
node_modules/
|
| 10 |
+
package-lock.json
|
| 11 |
+
|
| 12 |
# Python
|
| 13 |
__pycache__/
|
| 14 |
*.py[cod]
|
frontend/src/components/AdminNav.tsx
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
|
|
|
|
|
| 1 |
interface AdminNavProps {
|
| 2 |
onClick: () => void
|
| 3 |
}
|
| 4 |
|
| 5 |
export default function AdminNav({ onClick }: AdminNavProps) {
|
| 6 |
return (
|
| 7 |
-
<
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
>
|
| 11 |
-
Administration
|
| 12 |
-
</button>
|
| 13 |
)
|
| 14 |
}
|
|
|
|
| 1 |
+
import { RetroButton } from './retro'
|
| 2 |
+
|
| 3 |
interface AdminNavProps {
|
| 4 |
onClick: () => void
|
| 5 |
}
|
| 6 |
|
| 7 |
export default function AdminNav({ onClick }: AdminNavProps) {
|
| 8 |
return (
|
| 9 |
+
<RetroButton size="sm" onClick={onClick}>
|
| 10 |
+
Admin
|
| 11 |
+
</RetroButton>
|
|
|
|
|
|
|
|
|
|
| 12 |
)
|
| 13 |
}
|
frontend/src/components/CommentaryPanel.tsx
CHANGED
|
@@ -1,20 +1,21 @@
|
|
| 1 |
import { useState, type FC } from 'react'
|
| 2 |
import type { Commentary, EditorialInfo, EditorialStatus } from '../lib/api.ts'
|
|
|
|
| 3 |
|
| 4 |
const STATUS_LABELS: Record<EditorialStatus, string> = {
|
| 5 |
machine_draft: 'Brouillon IA',
|
| 6 |
-
needs_review: '
|
| 7 |
-
reviewed: '
|
| 8 |
-
validated: '
|
| 9 |
-
published: '
|
| 10 |
}
|
| 11 |
|
| 12 |
-
const
|
| 13 |
-
machine_draft: '
|
| 14 |
-
needs_review: '
|
| 15 |
-
reviewed: '
|
| 16 |
-
validated: '
|
| 17 |
-
published: '
|
| 18 |
}
|
| 19 |
|
| 20 |
interface Props {
|
|
@@ -29,7 +30,6 @@ const CommentaryPanel: FC<Props> = ({ commentary, editorial, visiblePublic, visi
|
|
| 29 |
|
| 30 |
if (!visiblePublic && !visibleScholarly) return null
|
| 31 |
|
| 32 |
-
// Si une seule couche est visible, forcer l'onglet correspondant
|
| 33 |
const activeTab: 'public' | 'scholarly' =
|
| 34 |
!visiblePublic && visibleScholarly ? 'scholarly' :
|
| 35 |
!visibleScholarly && visiblePublic ? 'public' :
|
|
@@ -39,40 +39,39 @@ const CommentaryPanel: FC<Props> = ({ commentary, editorial, visiblePublic, visi
|
|
| 39 |
const bothVisible = visiblePublic && visibleScholarly
|
| 40 |
|
| 41 |
return (
|
| 42 |
-
<div className="
|
| 43 |
-
<div className="flex items-center justify-between mb-
|
| 44 |
-
<
|
| 45 |
-
|
| 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 |
-
</
|
| 52 |
</div>
|
| 53 |
|
| 54 |
{bothVisible && (
|
| 55 |
-
<div className="flex gap-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
| 69 |
</div>
|
| 70 |
)}
|
| 71 |
|
| 72 |
{content ? (
|
| 73 |
-
<p className="text-sm
|
|
|
|
|
|
|
| 74 |
) : (
|
| 75 |
-
<p className="text-sm text-
|
| 76 |
)}
|
| 77 |
</div>
|
| 78 |
)
|
|
|
|
| 1 |
import { useState, type FC } from 'react'
|
| 2 |
import type { Commentary, EditorialInfo, EditorialStatus } from '../lib/api.ts'
|
| 3 |
+
import { RetroBadge, RetroButton } from './retro'
|
| 4 |
|
| 5 |
const STATUS_LABELS: Record<EditorialStatus, string> = {
|
| 6 |
machine_draft: 'Brouillon IA',
|
| 7 |
+
needs_review: 'A reviser',
|
| 8 |
+
reviewed: 'Revise',
|
| 9 |
+
validated: 'Valide',
|
| 10 |
+
published: 'Publie',
|
| 11 |
}
|
| 12 |
|
| 13 |
+
const STATUS_VARIANTS: Record<EditorialStatus, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
| 14 |
+
machine_draft: 'info',
|
| 15 |
+
needs_review: 'warning',
|
| 16 |
+
reviewed: 'default',
|
| 17 |
+
validated: 'success',
|
| 18 |
+
published: 'success',
|
| 19 |
}
|
| 20 |
|
| 21 |
interface Props {
|
|
|
|
| 30 |
|
| 31 |
if (!visiblePublic && !visibleScholarly) return null
|
| 32 |
|
|
|
|
| 33 |
const activeTab: 'public' | 'scholarly' =
|
| 34 |
!visiblePublic && visibleScholarly ? 'scholarly' :
|
| 35 |
!visibleScholarly && visiblePublic ? 'public' :
|
|
|
|
| 39 |
const bothVisible = visiblePublic && visibleScholarly
|
| 40 |
|
| 41 |
return (
|
| 42 |
+
<div className="p-2">
|
| 43 |
+
<div className="flex items-center justify-between mb-2">
|
| 44 |
+
<span className="text-retro-xs font-bold">Commentaire</span>
|
| 45 |
+
<RetroBadge variant={STATUS_VARIANTS[editorial.status]}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
{STATUS_LABELS[editorial.status]}
|
| 47 |
+
</RetroBadge>
|
| 48 |
</div>
|
| 49 |
|
| 50 |
{bothVisible && (
|
| 51 |
+
<div className="flex gap-[2px] mb-2">
|
| 52 |
+
<RetroButton
|
| 53 |
+
size="sm"
|
| 54 |
+
pressed={activeTab === 'public'}
|
| 55 |
+
onClick={() => setTab('public')}
|
| 56 |
+
>
|
| 57 |
+
Public
|
| 58 |
+
</RetroButton>
|
| 59 |
+
<RetroButton
|
| 60 |
+
size="sm"
|
| 61 |
+
pressed={activeTab === 'scholarly'}
|
| 62 |
+
onClick={() => setTab('scholarly')}
|
| 63 |
+
>
|
| 64 |
+
Savant
|
| 65 |
+
</RetroButton>
|
| 66 |
</div>
|
| 67 |
)}
|
| 68 |
|
| 69 |
{content ? (
|
| 70 |
+
<p className="text-retro-sm whitespace-pre-wrap font-retro leading-relaxed">
|
| 71 |
+
{content}
|
| 72 |
+
</p>
|
| 73 |
) : (
|
| 74 |
+
<p className="text-retro-sm text-retro-darkgray">Commentaire non disponible.</p>
|
| 75 |
)}
|
| 76 |
</div>
|
| 77 |
)
|
frontend/src/components/LayerPanel.tsx
CHANGED
|
@@ -1,16 +1,17 @@
|
|
| 1 |
import type { FC } from 'react'
|
|
|
|
| 2 |
|
| 3 |
const LAYER_LABELS: Record<string, string> = {
|
| 4 |
image: 'Image',
|
| 5 |
-
ocr_diplomatic: 'Transcription
|
| 6 |
-
ocr_normalized: '
|
| 7 |
-
translation_fr: 'Traduction
|
| 8 |
-
translation_en: 'Traduction
|
| 9 |
-
summary: '
|
| 10 |
-
scholarly_commentary: '
|
| 11 |
-
public_commentary: '
|
| 12 |
iconography_detection: 'Iconographie',
|
| 13 |
-
material_notes: 'Notes
|
| 14 |
uncertainty: 'Incertitudes',
|
| 15 |
}
|
| 16 |
|
|
@@ -21,24 +22,16 @@ interface Props {
|
|
| 21 |
}
|
| 22 |
|
| 23 |
const LayerPanel: FC<Props> = ({ activeLayers, visibleLayers, onToggle }) => (
|
| 24 |
-
<div className="
|
| 25 |
-
<
|
| 26 |
-
|
| 27 |
-
</h3>
|
| 28 |
-
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
| 29 |
{activeLayers.map((layer) => (
|
| 30 |
-
<
|
| 31 |
key={layer}
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
checked={visibleLayers.has(layer)}
|
| 37 |
-
onChange={() => onToggle(layer)}
|
| 38 |
-
className="rounded border-stone-300 text-stone-700 focus:ring-stone-500"
|
| 39 |
-
/>
|
| 40 |
-
{LAYER_LABELS[layer] ?? layer}
|
| 41 |
-
</label>
|
| 42 |
))}
|
| 43 |
</div>
|
| 44 |
</div>
|
|
|
|
| 1 |
import type { FC } from 'react'
|
| 2 |
+
import { RetroCheckbox } from './retro'
|
| 3 |
|
| 4 |
const LAYER_LABELS: Record<string, string> = {
|
| 5 |
image: 'Image',
|
| 6 |
+
ocr_diplomatic: 'Transcription',
|
| 7 |
+
ocr_normalized: 'Normalise',
|
| 8 |
+
translation_fr: 'Traduction FR',
|
| 9 |
+
translation_en: 'Traduction EN',
|
| 10 |
+
summary: 'Resume',
|
| 11 |
+
scholarly_commentary: 'Comm. savant',
|
| 12 |
+
public_commentary: 'Comm. public',
|
| 13 |
iconography_detection: 'Iconographie',
|
| 14 |
+
material_notes: 'Notes mat.',
|
| 15 |
uncertainty: 'Incertitudes',
|
| 16 |
}
|
| 17 |
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
const LayerPanel: FC<Props> = ({ activeLayers, visibleLayers, onToggle }) => (
|
| 25 |
+
<div className="px-2 py-2 shrink-0 border-b border-retro-black bg-retro-gray">
|
| 26 |
+
<div className="text-retro-xs font-bold mb-1">Couches</div>
|
| 27 |
+
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
|
|
|
|
|
|
| 28 |
{activeLayers.map((layer) => (
|
| 29 |
+
<RetroCheckbox
|
| 30 |
key={layer}
|
| 31 |
+
label={LAYER_LABELS[layer] ?? layer}
|
| 32 |
+
checked={visibleLayers.has(layer)}
|
| 33 |
+
onChange={() => onToggle(layer)}
|
| 34 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
))}
|
| 36 |
</div>
|
| 37 |
</div>
|
frontend/src/components/SearchBar.tsx
CHANGED
|
@@ -41,7 +41,6 @@ export default function SearchBar({ onSelectResult }: Props) {
|
|
| 41 |
}
|
| 42 |
}, [query, runSearch])
|
| 43 |
|
| 44 |
-
// Close dropdown on outside click
|
| 45 |
useEffect(() => {
|
| 46 |
const handler = (e: MouseEvent) => {
|
| 47 |
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
@@ -53,27 +52,45 @@ export default function SearchBar({ onSelectResult }: Props) {
|
|
| 53 |
}, [])
|
| 54 |
|
| 55 |
return (
|
| 56 |
-
<div ref={containerRef} className="relative w-
|
| 57 |
<div className="relative">
|
| 58 |
<input
|
| 59 |
type="search"
|
| 60 |
value={query}
|
| 61 |
onChange={(e) => setQuery(e.target.value)}
|
| 62 |
onFocus={() => results.length > 0 && setOpen(true)}
|
| 63 |
-
placeholder="Rechercher
|
| 64 |
-
className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
/>
|
| 66 |
{loading && (
|
| 67 |
-
<span className="absolute right-2
|
| 68 |
-
|
| 69 |
</span>
|
| 70 |
)}
|
| 71 |
</div>
|
| 72 |
|
| 73 |
{open && (
|
| 74 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
{results.length === 0 ? (
|
| 76 |
-
<div className="px-
|
|
|
|
|
|
|
| 77 |
) : (
|
| 78 |
<ul>
|
| 79 |
{results.map((r) => (
|
|
@@ -83,17 +100,20 @@ export default function SearchBar({ onSelectResult }: Props) {
|
|
| 83 |
setOpen(false)
|
| 84 |
onSelectResult?.(r)
|
| 85 |
}}
|
| 86 |
-
className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
>
|
| 88 |
<div className="flex items-center justify-between gap-2">
|
| 89 |
-
<span className="font-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
<span className="text-xs text-stone-400 shrink-0">
|
| 93 |
-
score : {r.score}
|
| 94 |
</span>
|
| 95 |
</div>
|
| 96 |
-
<div className="text-xs
|
| 97 |
{r.excerpt}
|
| 98 |
</div>
|
| 99 |
</button>
|
|
|
|
| 41 |
}
|
| 42 |
}, [query, runSearch])
|
| 43 |
|
|
|
|
| 44 |
useEffect(() => {
|
| 45 |
const handler = (e: MouseEvent) => {
|
| 46 |
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
|
|
| 52 |
}, [])
|
| 53 |
|
| 54 |
return (
|
| 55 |
+
<div ref={containerRef} className="relative w-64">
|
| 56 |
<div className="relative">
|
| 57 |
<input
|
| 58 |
type="search"
|
| 59 |
value={query}
|
| 60 |
onChange={(e) => setQuery(e.target.value)}
|
| 61 |
onFocus={() => results.length > 0 && setOpen(true)}
|
| 62 |
+
placeholder="Rechercher..."
|
| 63 |
+
className="
|
| 64 |
+
w-full px-2 py-[2px]
|
| 65 |
+
text-retro-sm font-retro
|
| 66 |
+
bg-retro-white text-retro-black
|
| 67 |
+
border border-retro-black
|
| 68 |
+
shadow-retro-well
|
| 69 |
+
placeholder:text-retro-darkgray
|
| 70 |
+
"
|
| 71 |
/>
|
| 72 |
{loading && (
|
| 73 |
+
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-retro-darkgray text-retro-xs">
|
| 74 |
+
...
|
| 75 |
</span>
|
| 76 |
)}
|
| 77 |
</div>
|
| 78 |
|
| 79 |
{open && (
|
| 80 |
+
<div
|
| 81 |
+
className="
|
| 82 |
+
absolute top-full left-0 right-0 mt-[2px]
|
| 83 |
+
bg-retro-white
|
| 84 |
+
border-retro border-retro-black
|
| 85 |
+
shadow-retro-lg
|
| 86 |
+
z-50 max-h-60
|
| 87 |
+
overflow-y-auto retro-scroll
|
| 88 |
+
"
|
| 89 |
+
>
|
| 90 |
{results.length === 0 ? (
|
| 91 |
+
<div className="px-2 py-2 text-retro-xs text-retro-darkgray">
|
| 92 |
+
Aucun resultat.
|
| 93 |
+
</div>
|
| 94 |
) : (
|
| 95 |
<ul>
|
| 96 |
{results.map((r) => (
|
|
|
|
| 100 |
setOpen(false)
|
| 101 |
onSelectResult?.(r)
|
| 102 |
}}
|
| 103 |
+
className="
|
| 104 |
+
w-full text-left px-2 py-[3px]
|
| 105 |
+
text-retro-sm font-retro
|
| 106 |
+
hover:bg-retro-select hover:text-retro-select-text
|
| 107 |
+
border-b border-retro-gray last:border-0
|
| 108 |
+
"
|
| 109 |
>
|
| 110 |
<div className="flex items-center justify-between gap-2">
|
| 111 |
+
<span className="font-bold">{r.folio_label}</span>
|
| 112 |
+
<span className="text-retro-xs opacity-60">
|
| 113 |
+
{r.score}
|
|
|
|
|
|
|
| 114 |
</span>
|
| 115 |
</div>
|
| 116 |
+
<div className="text-retro-xs truncate opacity-70">
|
| 117 |
{r.excerpt}
|
| 118 |
</div>
|
| 119 |
</button>
|
frontend/src/components/TranscriptionPanel.tsx
CHANGED
|
@@ -1,20 +1,21 @@
|
|
| 1 |
import type { FC } from 'react'
|
| 2 |
import type { OCRResult, EditorialInfo, EditorialStatus } from '../lib/api.ts'
|
|
|
|
| 3 |
|
| 4 |
const STATUS_LABELS: Record<EditorialStatus, string> = {
|
| 5 |
machine_draft: 'Brouillon IA',
|
| 6 |
-
needs_review: '
|
| 7 |
-
reviewed: '
|
| 8 |
-
validated: '
|
| 9 |
-
published: '
|
| 10 |
}
|
| 11 |
|
| 12 |
-
const
|
| 13 |
-
machine_draft: '
|
| 14 |
-
needs_review: '
|
| 15 |
-
reviewed: '
|
| 16 |
-
validated: '
|
| 17 |
-
published: '
|
| 18 |
}
|
| 19 |
|
| 20 |
interface Props {
|
|
@@ -27,34 +28,30 @@ const TranscriptionPanel: FC<Props> = ({ ocr, editorial, visible }) => {
|
|
| 27 |
if (!visible) return null
|
| 28 |
|
| 29 |
return (
|
| 30 |
-
<div className="
|
| 31 |
-
<div className="flex items-center justify-between mb-
|
| 32 |
-
<
|
| 33 |
-
|
| 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 |
-
</
|
| 40 |
</div>
|
| 41 |
{ocr ? (
|
| 42 |
<div>
|
| 43 |
{ocr.diplomatic_text ? (
|
| 44 |
-
<p className="text-sm
|
| 45 |
{ocr.diplomatic_text}
|
| 46 |
</p>
|
| 47 |
) : (
|
| 48 |
-
<p className="text-sm text-
|
| 49 |
)}
|
| 50 |
{ocr.confidence > 0 && (
|
| 51 |
-
<div className="mt-2 text-xs text-
|
| 52 |
-
Confiance
|
| 53 |
</div>
|
| 54 |
)}
|
| 55 |
</div>
|
| 56 |
) : (
|
| 57 |
-
<p className="text-sm text-
|
| 58 |
)}
|
| 59 |
</div>
|
| 60 |
)
|
|
|
|
| 1 |
import type { FC } from 'react'
|
| 2 |
import type { OCRResult, EditorialInfo, EditorialStatus } from '../lib/api.ts'
|
| 3 |
+
import { RetroBadge } from './retro'
|
| 4 |
|
| 5 |
const STATUS_LABELS: Record<EditorialStatus, string> = {
|
| 6 |
machine_draft: 'Brouillon IA',
|
| 7 |
+
needs_review: 'A reviser',
|
| 8 |
+
reviewed: 'Revise',
|
| 9 |
+
validated: 'Valide',
|
| 10 |
+
published: 'Publie',
|
| 11 |
}
|
| 12 |
|
| 13 |
+
const STATUS_VARIANTS: Record<EditorialStatus, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
| 14 |
+
machine_draft: 'info',
|
| 15 |
+
needs_review: 'warning',
|
| 16 |
+
reviewed: 'default',
|
| 17 |
+
validated: 'success',
|
| 18 |
+
published: 'success',
|
| 19 |
}
|
| 20 |
|
| 21 |
interface Props {
|
|
|
|
| 28 |
if (!visible) return null
|
| 29 |
|
| 30 |
return (
|
| 31 |
+
<div className="p-2">
|
| 32 |
+
<div className="flex items-center justify-between mb-2">
|
| 33 |
+
<span className="text-retro-xs font-bold">Transcription diplomatique</span>
|
| 34 |
+
<RetroBadge variant={STATUS_VARIANTS[editorial.status]}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
{STATUS_LABELS[editorial.status]}
|
| 36 |
+
</RetroBadge>
|
| 37 |
</div>
|
| 38 |
{ocr ? (
|
| 39 |
<div>
|
| 40 |
{ocr.diplomatic_text ? (
|
| 41 |
+
<p className="text-retro-sm whitespace-pre-wrap font-retro leading-relaxed">
|
| 42 |
{ocr.diplomatic_text}
|
| 43 |
</p>
|
| 44 |
) : (
|
| 45 |
+
<p className="text-retro-sm text-retro-darkgray">Texte vide.</p>
|
| 46 |
)}
|
| 47 |
{ocr.confidence > 0 && (
|
| 48 |
+
<div className="mt-2 text-retro-xs text-retro-darkgray">
|
| 49 |
+
Confiance : {(ocr.confidence * 100).toFixed(0)}% β Langue : {ocr.language}
|
| 50 |
</div>
|
| 51 |
)}
|
| 52 |
</div>
|
| 53 |
) : (
|
| 54 |
+
<p className="text-retro-sm text-retro-darkgray">Transcription non disponible.</p>
|
| 55 |
)}
|
| 56 |
</div>
|
| 57 |
)
|
frontend/src/components/TranslationPanel.tsx
CHANGED
|
@@ -1,20 +1,21 @@
|
|
| 1 |
import type { FC } from 'react'
|
| 2 |
import type { Translation, EditorialInfo, EditorialStatus } from '../lib/api.ts'
|
|
|
|
| 3 |
|
| 4 |
const STATUS_LABELS: Record<EditorialStatus, string> = {
|
| 5 |
machine_draft: 'Brouillon IA',
|
| 6 |
-
needs_review: '
|
| 7 |
-
reviewed: '
|
| 8 |
-
validated: '
|
| 9 |
-
published: '
|
| 10 |
}
|
| 11 |
|
| 12 |
-
const
|
| 13 |
-
machine_draft: '
|
| 14 |
-
needs_review: '
|
| 15 |
-
reviewed: '
|
| 16 |
-
validated: '
|
| 17 |
-
published: '
|
| 18 |
}
|
| 19 |
|
| 20 |
interface Props {
|
|
@@ -27,23 +28,19 @@ const TranslationPanel: FC<Props> = ({ translation, editorial, visible }) => {
|
|
| 27 |
if (!visible) return null
|
| 28 |
|
| 29 |
return (
|
| 30 |
-
<div className="
|
| 31 |
-
<div className="flex items-center justify-between mb-
|
| 32 |
-
<
|
| 33 |
-
|
| 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 |
-
</
|
| 40 |
</div>
|
| 41 |
{translation?.fr ? (
|
| 42 |
-
<p className="text-sm
|
| 43 |
{translation.fr}
|
| 44 |
</p>
|
| 45 |
) : (
|
| 46 |
-
<p className="text-sm text-
|
| 47 |
)}
|
| 48 |
</div>
|
| 49 |
)
|
|
|
|
| 1 |
import type { FC } from 'react'
|
| 2 |
import type { Translation, EditorialInfo, EditorialStatus } from '../lib/api.ts'
|
| 3 |
+
import { RetroBadge } from './retro'
|
| 4 |
|
| 5 |
const STATUS_LABELS: Record<EditorialStatus, string> = {
|
| 6 |
machine_draft: 'Brouillon IA',
|
| 7 |
+
needs_review: 'A reviser',
|
| 8 |
+
reviewed: 'Revise',
|
| 9 |
+
validated: 'Valide',
|
| 10 |
+
published: 'Publie',
|
| 11 |
}
|
| 12 |
|
| 13 |
+
const STATUS_VARIANTS: Record<EditorialStatus, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
| 14 |
+
machine_draft: 'info',
|
| 15 |
+
needs_review: 'warning',
|
| 16 |
+
reviewed: 'default',
|
| 17 |
+
validated: 'success',
|
| 18 |
+
published: 'success',
|
| 19 |
}
|
| 20 |
|
| 21 |
interface Props {
|
|
|
|
| 28 |
if (!visible) return null
|
| 29 |
|
| 30 |
return (
|
| 31 |
+
<div className="p-2">
|
| 32 |
+
<div className="flex items-center justify-between mb-2">
|
| 33 |
+
<span className="text-retro-xs font-bold">Traduction (FR)</span>
|
| 34 |
+
<RetroBadge variant={STATUS_VARIANTS[editorial.status]}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
{STATUS_LABELS[editorial.status]}
|
| 36 |
+
</RetroBadge>
|
| 37 |
</div>
|
| 38 |
{translation?.fr ? (
|
| 39 |
+
<p className="text-retro-sm whitespace-pre-wrap font-retro leading-relaxed">
|
| 40 |
{translation.fr}
|
| 41 |
</p>
|
| 42 |
) : (
|
| 43 |
+
<p className="text-retro-sm text-retro-darkgray">Traduction non disponible.</p>
|
| 44 |
)}
|
| 45 |
</div>
|
| 46 |
)
|
frontend/src/components/Viewer.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { useEffect, useRef, type FC } from 'react'
|
| 2 |
import OpenSeadragon from 'openseadragon'
|
|
|
|
| 3 |
|
| 4 |
interface Props {
|
| 5 |
imageUrl: string
|
|
@@ -10,7 +11,6 @@ const Viewer: FC<Props> = ({ imageUrl, onViewerReady }) => {
|
|
| 10 |
const containerRef = useRef<HTMLDivElement>(null)
|
| 11 |
const viewerRef = useRef<OpenSeadragon.Viewer | null>(null)
|
| 12 |
|
| 13 |
-
// Initialise OSD une seule fois
|
| 14 |
useEffect(() => {
|
| 15 |
if (!containerRef.current) return
|
| 16 |
|
|
@@ -32,7 +32,6 @@ const Viewer: FC<Props> = ({ imageUrl, onViewerReady }) => {
|
|
| 32 |
}
|
| 33 |
}, [])
|
| 34 |
|
| 35 |
-
// Ouvre l'image Γ chaque changement d'URL
|
| 36 |
useEffect(() => {
|
| 37 |
const viewer = viewerRef.current
|
| 38 |
if (!viewer || !imageUrl) return
|
|
@@ -42,40 +41,39 @@ const Viewer: FC<Props> = ({ imageUrl, onViewerReady }) => {
|
|
| 42 |
onViewerReady?.(viewer)
|
| 43 |
})
|
| 44 |
}, [imageUrl]) // eslint-disable-line react-hooks/exhaustive-deps
|
| 45 |
-
// onViewerReady est intentionnellement exclu : c'est un callback stable
|
| 46 |
|
| 47 |
return (
|
| 48 |
-
<div className="relative w-full h-full bg-
|
| 49 |
<div ref={containerRef} className="w-full h-full" />
|
| 50 |
-
<div className="absolute bottom-
|
| 51 |
-
<
|
|
|
|
| 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 |
-
</
|
| 58 |
-
<
|
|
|
|
| 59 |
onClick={() => viewerRef.current?.viewport.zoomBy(0.67)}
|
| 60 |
-
|
| 61 |
-
title="Zoom β"
|
| 62 |
>
|
| 63 |
-
|
| 64 |
-
</
|
| 65 |
-
<
|
|
|
|
| 66 |
onClick={() => viewerRef.current?.viewport.goHome()}
|
| 67 |
-
|
| 68 |
-
title="RΓ©initialiser"
|
| 69 |
>
|
| 70 |
-
|
| 71 |
-
</
|
| 72 |
-
<
|
|
|
|
| 73 |
onClick={() => viewerRef.current?.setFullScreen(true)}
|
| 74 |
-
|
| 75 |
-
title="Plein Γ©cran"
|
| 76 |
>
|
| 77 |
-
|
| 78 |
-
</
|
| 79 |
</div>
|
| 80 |
</div>
|
| 81 |
)
|
|
|
|
| 1 |
import { useEffect, useRef, type FC } from 'react'
|
| 2 |
import OpenSeadragon from 'openseadragon'
|
| 3 |
+
import { RetroButton } from './retro'
|
| 4 |
|
| 5 |
interface Props {
|
| 6 |
imageUrl: string
|
|
|
|
| 11 |
const containerRef = useRef<HTMLDivElement>(null)
|
| 12 |
const viewerRef = useRef<OpenSeadragon.Viewer | null>(null)
|
| 13 |
|
|
|
|
| 14 |
useEffect(() => {
|
| 15 |
if (!containerRef.current) return
|
| 16 |
|
|
|
|
| 32 |
}
|
| 33 |
}, [])
|
| 34 |
|
|
|
|
| 35 |
useEffect(() => {
|
| 36 |
const viewer = viewerRef.current
|
| 37 |
if (!viewer || !imageUrl) return
|
|
|
|
| 41 |
onViewerReady?.(viewer)
|
| 42 |
})
|
| 43 |
}, [imageUrl]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
| 44 |
|
| 45 |
return (
|
| 46 |
+
<div className="relative w-full h-full bg-retro-black">
|
| 47 |
<div ref={containerRef} className="w-full h-full" />
|
| 48 |
+
<div className="absolute bottom-2 right-2 flex gap-[2px]">
|
| 49 |
+
<RetroButton
|
| 50 |
+
size="sm"
|
| 51 |
onClick={() => viewerRef.current?.viewport.zoomBy(1.5)}
|
|
|
|
| 52 |
title="Zoom +"
|
| 53 |
>
|
| 54 |
+
|
| 55 |
+
</RetroButton>
|
| 56 |
+
<RetroButton
|
| 57 |
+
size="sm"
|
| 58 |
onClick={() => viewerRef.current?.viewport.zoomBy(0.67)}
|
| 59 |
+
title="Zoom -"
|
|
|
|
| 60 |
>
|
| 61 |
+
-
|
| 62 |
+
</RetroButton>
|
| 63 |
+
<RetroButton
|
| 64 |
+
size="sm"
|
| 65 |
onClick={() => viewerRef.current?.viewport.goHome()}
|
| 66 |
+
title="Reset"
|
|
|
|
| 67 |
>
|
| 68 |
+
o
|
| 69 |
+
</RetroButton>
|
| 70 |
+
<RetroButton
|
| 71 |
+
size="sm"
|
| 72 |
onClick={() => viewerRef.current?.setFullScreen(true)}
|
| 73 |
+
title="Plein ecran"
|
|
|
|
| 74 |
>
|
| 75 |
+
[]
|
| 76 |
+
</RetroButton>
|
| 77 |
</div>
|
| 78 |
</div>
|
| 79 |
)
|
frontend/src/components/retro/RetroBadge.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from 'react'
|
| 2 |
+
|
| 3 |
+
type Variant = 'default' | 'success' | 'warning' | 'error' | 'info'
|
| 4 |
+
|
| 5 |
+
interface Props {
|
| 6 |
+
children: ReactNode
|
| 7 |
+
variant?: Variant
|
| 8 |
+
className?: string
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const variantStyles: Record<Variant, string> = {
|
| 12 |
+
default: 'bg-retro-gray text-retro-black',
|
| 13 |
+
success: 'bg-retro-black text-retro-white',
|
| 14 |
+
warning: 'bg-retro-white text-retro-black border-dashed',
|
| 15 |
+
error: 'bg-retro-white text-retro-black font-bold',
|
| 16 |
+
info: 'bg-retro-light text-retro-black',
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export default function RetroBadge({
|
| 20 |
+
children,
|
| 21 |
+
variant = 'default',
|
| 22 |
+
className = '',
|
| 23 |
+
}: Props) {
|
| 24 |
+
return (
|
| 25 |
+
<span
|
| 26 |
+
className={`
|
| 27 |
+
inline-block
|
| 28 |
+
px-2 py-[1px]
|
| 29 |
+
text-retro-xs font-retro
|
| 30 |
+
border border-retro-black
|
| 31 |
+
${variantStyles[variant]}
|
| 32 |
+
${className}
|
| 33 |
+
`}
|
| 34 |
+
>
|
| 35 |
+
{children}
|
| 36 |
+
</span>
|
| 37 |
+
)
|
| 38 |
+
}
|
frontend/src/components/retro/RetroButton.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode, ButtonHTMLAttributes } from 'react'
|
| 2 |
+
|
| 3 |
+
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
| 4 |
+
children: ReactNode
|
| 5 |
+
/** Render as a small compact button */
|
| 6 |
+
size?: 'sm' | 'md'
|
| 7 |
+
/** If true, renders in "pressed" state */
|
| 8 |
+
pressed?: boolean
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function RetroButton({
|
| 12 |
+
children,
|
| 13 |
+
size = 'md',
|
| 14 |
+
pressed = false,
|
| 15 |
+
className = '',
|
| 16 |
+
disabled,
|
| 17 |
+
...rest
|
| 18 |
+
}: Props) {
|
| 19 |
+
const padding = size === 'sm' ? 'px-2 py-[1px]' : 'px-3 py-[3px]'
|
| 20 |
+
const fontSize = size === 'sm' ? 'text-retro-xs' : 'text-retro-sm'
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<button
|
| 24 |
+
className={`
|
| 25 |
+
${padding} ${fontSize}
|
| 26 |
+
font-retro font-medium
|
| 27 |
+
bg-retro-gray
|
| 28 |
+
border border-retro-black
|
| 29 |
+
${pressed
|
| 30 |
+
? 'shadow-retro-inset'
|
| 31 |
+
: 'shadow-retro-outset active:shadow-retro-inset'
|
| 32 |
+
}
|
| 33 |
+
${disabled
|
| 34 |
+
? 'text-retro-darkgray cursor-not-allowed'
|
| 35 |
+
: 'text-retro-black hover:bg-retro-light cursor-pointer'
|
| 36 |
+
}
|
| 37 |
+
select-none
|
| 38 |
+
${className}
|
| 39 |
+
`}
|
| 40 |
+
disabled={disabled}
|
| 41 |
+
{...rest}
|
| 42 |
+
>
|
| 43 |
+
{children}
|
| 44 |
+
</button>
|
| 45 |
+
)
|
| 46 |
+
}
|
frontend/src/components/retro/RetroCheckbox.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface Props {
|
| 2 |
+
/** Label text next to the checkbox */
|
| 3 |
+
label: string
|
| 4 |
+
/** Controlled checked state */
|
| 5 |
+
checked: boolean
|
| 6 |
+
/** Change handler */
|
| 7 |
+
onChange: (checked: boolean) => void
|
| 8 |
+
/** Disabled state */
|
| 9 |
+
disabled?: boolean
|
| 10 |
+
/** Extra CSS classes on the wrapper */
|
| 11 |
+
className?: string
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export default function RetroCheckbox({
|
| 15 |
+
label,
|
| 16 |
+
checked,
|
| 17 |
+
onChange,
|
| 18 |
+
disabled = false,
|
| 19 |
+
className = '',
|
| 20 |
+
}: Props) {
|
| 21 |
+
return (
|
| 22 |
+
<label
|
| 23 |
+
onClick={() => { if (!disabled) onChange(!checked) }}
|
| 24 |
+
className={`
|
| 25 |
+
inline-flex items-center gap-[6px]
|
| 26 |
+
text-retro-sm font-retro
|
| 27 |
+
${disabled ? 'text-retro-darkgray cursor-not-allowed' : 'cursor-pointer'}
|
| 28 |
+
select-none
|
| 29 |
+
${className}
|
| 30 |
+
`}
|
| 31 |
+
>
|
| 32 |
+
<span
|
| 33 |
+
className={`
|
| 34 |
+
inline-flex items-center justify-center
|
| 35 |
+
w-[13px] h-[13px]
|
| 36 |
+
border border-retro-black
|
| 37 |
+
bg-retro-white
|
| 38 |
+
shadow-retro-well
|
| 39 |
+
text-[10px] leading-none font-bold
|
| 40 |
+
shrink-0
|
| 41 |
+
`}
|
| 42 |
+
>
|
| 43 |
+
{checked && <span className="text-retro-black">x</span>}
|
| 44 |
+
</span>
|
| 45 |
+
{label}
|
| 46 |
+
</label>
|
| 47 |
+
)
|
| 48 |
+
}
|
frontend/src/components/retro/RetroIcon.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface Props {
|
| 2 |
+
/** Label displayed below the icon */
|
| 3 |
+
label: string
|
| 4 |
+
/** Emoji or text character used as the icon glyph */
|
| 5 |
+
glyph: string
|
| 6 |
+
/** Click handler */
|
| 7 |
+
onClick?: () => void
|
| 8 |
+
/** If true, renders in selected/highlighted state */
|
| 9 |
+
selected?: boolean
|
| 10 |
+
/** Extra CSS classes */
|
| 11 |
+
className?: string
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export default function RetroIcon({
|
| 15 |
+
label,
|
| 16 |
+
glyph,
|
| 17 |
+
onClick,
|
| 18 |
+
selected = false,
|
| 19 |
+
className = '',
|
| 20 |
+
}: Props) {
|
| 21 |
+
return (
|
| 22 |
+
<button
|
| 23 |
+
onClick={onClick}
|
| 24 |
+
className={`
|
| 25 |
+
flex flex-col items-center gap-1
|
| 26 |
+
p-2 w-[80px]
|
| 27 |
+
cursor-pointer select-none
|
| 28 |
+
${className}
|
| 29 |
+
`}
|
| 30 |
+
onDoubleClick={onClick}
|
| 31 |
+
>
|
| 32 |
+
{/* Icon box */}
|
| 33 |
+
<div
|
| 34 |
+
className={`
|
| 35 |
+
w-[48px] h-[48px]
|
| 36 |
+
flex items-center justify-center
|
| 37 |
+
border border-retro-black
|
| 38 |
+
text-[24px] leading-none
|
| 39 |
+
${selected
|
| 40 |
+
? 'bg-retro-black text-retro-white'
|
| 41 |
+
: 'bg-retro-white text-retro-black hover:bg-retro-light'
|
| 42 |
+
}
|
| 43 |
+
shadow-retro-outset
|
| 44 |
+
`}
|
| 45 |
+
>
|
| 46 |
+
{glyph}
|
| 47 |
+
</div>
|
| 48 |
+
{/* Label */}
|
| 49 |
+
<span
|
| 50 |
+
className={`
|
| 51 |
+
text-retro-xs text-center leading-tight
|
| 52 |
+
max-w-full break-words
|
| 53 |
+
${selected
|
| 54 |
+
? 'bg-retro-select text-retro-select-text px-1'
|
| 55 |
+
: 'text-retro-black'
|
| 56 |
+
}
|
| 57 |
+
`}
|
| 58 |
+
>
|
| 59 |
+
{label}
|
| 60 |
+
</span>
|
| 61 |
+
</button>
|
| 62 |
+
)
|
| 63 |
+
}
|
frontend/src/components/retro/RetroInput.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { InputHTMLAttributes } from 'react'
|
| 2 |
+
|
| 3 |
+
interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
| 4 |
+
/** Optional label rendered above the input */
|
| 5 |
+
label?: string
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export default function RetroInput({ label, className = '', ...rest }: Props) {
|
| 9 |
+
return (
|
| 10 |
+
<div className="flex flex-col gap-[2px]">
|
| 11 |
+
{label && (
|
| 12 |
+
<label className="text-retro-xs font-retro font-medium text-retro-black">
|
| 13 |
+
{label}
|
| 14 |
+
</label>
|
| 15 |
+
)}
|
| 16 |
+
<input
|
| 17 |
+
className={`
|
| 18 |
+
px-2 py-[3px]
|
| 19 |
+
text-retro-sm font-retro
|
| 20 |
+
bg-retro-white text-retro-black
|
| 21 |
+
border border-retro-black
|
| 22 |
+
shadow-retro-well
|
| 23 |
+
placeholder:text-retro-darkgray
|
| 24 |
+
${className}
|
| 25 |
+
`}
|
| 26 |
+
{...rest}
|
| 27 |
+
/>
|
| 28 |
+
</div>
|
| 29 |
+
)
|
| 30 |
+
}
|
frontend/src/components/retro/RetroMenuBar.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from 'react'
|
| 2 |
+
|
| 3 |
+
export interface MenuItem {
|
| 4 |
+
label: string
|
| 5 |
+
onClick?: () => void
|
| 6 |
+
disabled?: boolean
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface Props {
|
| 10 |
+
/** Left-aligned items (logo, menus) */
|
| 11 |
+
items?: MenuItem[]
|
| 12 |
+
/** Right-aligned content (search, status) */
|
| 13 |
+
right?: ReactNode
|
| 14 |
+
/** Extra CSS classes */
|
| 15 |
+
className?: string
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function RetroMenuBar({ items = [], right, className = '' }: Props) {
|
| 19 |
+
return (
|
| 20 |
+
<div
|
| 21 |
+
className={`
|
| 22 |
+
flex items-center
|
| 23 |
+
bg-retro-gray
|
| 24 |
+
border-b-retro border-retro-black
|
| 25 |
+
shadow-retro-outset
|
| 26 |
+
px-1 py-[2px]
|
| 27 |
+
select-none shrink-0
|
| 28 |
+
${className}
|
| 29 |
+
`}
|
| 30 |
+
>
|
| 31 |
+
{items.map((item, i) => (
|
| 32 |
+
<button
|
| 33 |
+
key={i}
|
| 34 |
+
onClick={item.onClick}
|
| 35 |
+
disabled={item.disabled}
|
| 36 |
+
className={`
|
| 37 |
+
px-3 py-[2px]
|
| 38 |
+
text-retro-sm font-retro font-medium
|
| 39 |
+
${item.disabled
|
| 40 |
+
? 'text-retro-darkgray cursor-not-allowed'
|
| 41 |
+
: 'text-retro-black hover:bg-retro-black hover:text-retro-white cursor-pointer'
|
| 42 |
+
}
|
| 43 |
+
`}
|
| 44 |
+
>
|
| 45 |
+
{item.label}
|
| 46 |
+
</button>
|
| 47 |
+
))}
|
| 48 |
+
{right && (
|
| 49 |
+
<div className="ml-auto flex items-center gap-1">
|
| 50 |
+
{right}
|
| 51 |
+
</div>
|
| 52 |
+
)}
|
| 53 |
+
</div>
|
| 54 |
+
)
|
| 55 |
+
}
|
frontend/src/components/retro/RetroSelect.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { SelectHTMLAttributes } from 'react'
|
| 2 |
+
|
| 3 |
+
interface Props extends SelectHTMLAttributes<HTMLSelectElement> {
|
| 4 |
+
/** Optional label rendered above the select */
|
| 5 |
+
label?: string
|
| 6 |
+
/** Options: array of {value, label} */
|
| 7 |
+
options: { value: string; label: string }[]
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export default function RetroSelect({ label, options, className = '', ...rest }: Props) {
|
| 11 |
+
return (
|
| 12 |
+
<div className="flex flex-col gap-[2px]">
|
| 13 |
+
{label && (
|
| 14 |
+
<label className="text-retro-xs font-retro font-medium text-retro-black">
|
| 15 |
+
{label}
|
| 16 |
+
</label>
|
| 17 |
+
)}
|
| 18 |
+
<select
|
| 19 |
+
className={`
|
| 20 |
+
px-2 py-[3px]
|
| 21 |
+
text-retro-sm font-retro
|
| 22 |
+
bg-retro-white text-retro-black
|
| 23 |
+
border border-retro-black
|
| 24 |
+
shadow-retro-well
|
| 25 |
+
cursor-pointer
|
| 26 |
+
${className}
|
| 27 |
+
`}
|
| 28 |
+
{...rest}
|
| 29 |
+
>
|
| 30 |
+
{options.map((opt) => (
|
| 31 |
+
<option key={opt.value} value={opt.value}>
|
| 32 |
+
{opt.label}
|
| 33 |
+
</option>
|
| 34 |
+
))}
|
| 35 |
+
</select>
|
| 36 |
+
</div>
|
| 37 |
+
)
|
| 38 |
+
}
|
frontend/src/components/retro/RetroTextarea.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { TextareaHTMLAttributes } from 'react'
|
| 2 |
+
|
| 3 |
+
interface Props extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
| 4 |
+
/** Optional label rendered above the textarea */
|
| 5 |
+
label?: string
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export default function RetroTextarea({ label, className = '', ...rest }: Props) {
|
| 9 |
+
return (
|
| 10 |
+
<div className="flex flex-col gap-[2px]">
|
| 11 |
+
{label && (
|
| 12 |
+
<label className="text-retro-xs font-retro font-medium text-retro-black">
|
| 13 |
+
{label}
|
| 14 |
+
</label>
|
| 15 |
+
)}
|
| 16 |
+
<textarea
|
| 17 |
+
className={`
|
| 18 |
+
px-2 py-[3px]
|
| 19 |
+
text-retro-sm font-retro
|
| 20 |
+
bg-retro-white text-retro-black
|
| 21 |
+
border border-retro-black
|
| 22 |
+
shadow-retro-well
|
| 23 |
+
placeholder:text-retro-darkgray
|
| 24 |
+
retro-scroll
|
| 25 |
+
resize-y
|
| 26 |
+
${className}
|
| 27 |
+
`}
|
| 28 |
+
{...rest}
|
| 29 |
+
/>
|
| 30 |
+
</div>
|
| 31 |
+
)
|
| 32 |
+
}
|
frontend/src/components/retro/RetroWindow.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from 'react'
|
| 2 |
+
|
| 3 |
+
interface Props {
|
| 4 |
+
/** Window title shown in the title bar */
|
| 5 |
+
title: string
|
| 6 |
+
/** Optional status text displayed in the bottom status bar */
|
| 7 |
+
statusBar?: string
|
| 8 |
+
/** Called when the close button is clicked (omit to hide the button) */
|
| 9 |
+
onClose?: () => void
|
| 10 |
+
/** Extra CSS classes for the outer container */
|
| 11 |
+
className?: string
|
| 12 |
+
/** Content rendered inside the window body */
|
| 13 |
+
children: ReactNode
|
| 14 |
+
/** If true, window body is scrollable with retro scrollbars */
|
| 15 |
+
scrollable?: boolean
|
| 16 |
+
/** If true, the window is rendered as "active" (darker title bar) */
|
| 17 |
+
active?: boolean
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function RetroWindow({
|
| 21 |
+
title,
|
| 22 |
+
statusBar,
|
| 23 |
+
onClose,
|
| 24 |
+
className = '',
|
| 25 |
+
children,
|
| 26 |
+
scrollable = false,
|
| 27 |
+
active = true,
|
| 28 |
+
}: Props) {
|
| 29 |
+
return (
|
| 30 |
+
<div
|
| 31 |
+
className={`
|
| 32 |
+
flex flex-col
|
| 33 |
+
border-retro border-retro-black bg-retro-gray
|
| 34 |
+
shadow-retro
|
| 35 |
+
${className}
|
| 36 |
+
`}
|
| 37 |
+
>
|
| 38 |
+
{/* ββ Title bar ββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 39 |
+
<div
|
| 40 |
+
className={`
|
| 41 |
+
flex items-center gap-2 px-2 py-[3px]
|
| 42 |
+
select-none shrink-0
|
| 43 |
+
${active ? 'bg-retro-black text-retro-white' : 'bg-retro-darkgray text-retro-white'}
|
| 44 |
+
`}
|
| 45 |
+
>
|
| 46 |
+
{onClose && (
|
| 47 |
+
<button
|
| 48 |
+
onClick={onClose}
|
| 49 |
+
className="
|
| 50 |
+
w-[14px] h-[14px] flex items-center justify-center
|
| 51 |
+
border border-retro-white bg-retro-gray text-retro-black
|
| 52 |
+
text-[9px] leading-none font-bold
|
| 53 |
+
hover:bg-retro-white active:bg-retro-darkgray
|
| 54 |
+
"
|
| 55 |
+
aria-label="Fermer"
|
| 56 |
+
>
|
| 57 |
+
x
|
| 58 |
+
</button>
|
| 59 |
+
)}
|
| 60 |
+
<span className="flex-1 text-retro-xs font-bold truncate tracking-wide">
|
| 61 |
+
{title}
|
| 62 |
+
</span>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
{/* ββ Content area βββββββββββββββββββββββββββββββββββββββββ */}
|
| 66 |
+
<div
|
| 67 |
+
className={`
|
| 68 |
+
flex-1 bg-retro-white
|
| 69 |
+
border-t-0
|
| 70 |
+
m-[3px] mt-0
|
| 71 |
+
shadow-retro-well
|
| 72 |
+
${scrollable ? 'overflow-auto retro-scroll' : 'overflow-hidden'}
|
| 73 |
+
`}
|
| 74 |
+
>
|
| 75 |
+
{children}
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
{/* ββ Status bar (optional) ββββββββββββββββββββββββββββββββ */}
|
| 79 |
+
{statusBar !== undefined && (
|
| 80 |
+
<div
|
| 81 |
+
className="
|
| 82 |
+
px-2 py-[2px]
|
| 83 |
+
text-retro-xs text-retro-black
|
| 84 |
+
border-t border-retro-darkgray
|
| 85 |
+
bg-retro-gray
|
| 86 |
+
shadow-retro-well
|
| 87 |
+
mx-[3px] mb-[3px]
|
| 88 |
+
truncate shrink-0
|
| 89 |
+
"
|
| 90 |
+
>
|
| 91 |
+
{statusBar}
|
| 92 |
+
</div>
|
| 93 |
+
)}
|
| 94 |
+
</div>
|
| 95 |
+
)
|
| 96 |
+
}
|
frontend/src/components/retro/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { default as RetroWindow } from './RetroWindow'
|
| 2 |
+
export { default as RetroButton } from './RetroButton'
|
| 3 |
+
export { default as RetroMenuBar } from './RetroMenuBar'
|
| 4 |
+
export { default as RetroIcon } from './RetroIcon'
|
| 5 |
+
export { default as RetroCheckbox } from './RetroCheckbox'
|
| 6 |
+
export { default as RetroInput } from './RetroInput'
|
| 7 |
+
export { default as RetroTextarea } from './RetroTextarea'
|
| 8 |
+
export { default as RetroSelect } from './RetroSelect'
|
| 9 |
+
export { default as RetroBadge } from './RetroBadge'
|
frontend/src/index.css
CHANGED
|
@@ -1,3 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
@tailwind base;
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ββ Google Font: IBM Plex Mono (must precede @tailwind) βββββββββββββ */
|
| 2 |
+
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap');
|
| 3 |
+
|
| 4 |
@tailwind base;
|
| 5 |
@tailwind components;
|
| 6 |
@tailwind utilities;
|
| 7 |
+
|
| 8 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 9 |
+
IIIF Studio β Retro Design System (global styles)
|
| 10 |
+
Inspired by Xerox Star, Smalltalk-80, GEOS, classic Mac
|
| 11 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 12 |
+
|
| 13 |
+
/* ββ Base reset for retro look βββββββββββββββββββββββββββββββββββββββ */
|
| 14 |
+
@layer base {
|
| 15 |
+
html {
|
| 16 |
+
font-family: 'IBM Plex Mono', 'Courier New', Courier, monospace;
|
| 17 |
+
font-size: 13px;
|
| 18 |
+
line-height: 1.5;
|
| 19 |
+
-webkit-font-smoothing: none;
|
| 20 |
+
-moz-osx-font-smoothing: unset;
|
| 21 |
+
background-color: #c0c0c0;
|
| 22 |
+
color: #000000;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
body {
|
| 26 |
+
margin: 0;
|
| 27 |
+
padding: 0;
|
| 28 |
+
min-height: 100vh;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/* Disable subpixel antialiasing for that crisp pixel look */
|
| 32 |
+
* {
|
| 33 |
+
text-rendering: optimizeSpeed;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* Focus ring: dotted black outline (classic retro) */
|
| 37 |
+
*:focus-visible {
|
| 38 |
+
outline: 1px dotted #000000;
|
| 39 |
+
outline-offset: 1px;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/* Selection: classic inverted */
|
| 43 |
+
::selection {
|
| 44 |
+
background-color: #000080;
|
| 45 |
+
color: #ffffff;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/* ββ Retro scrollbar (Windows 3.1 style) βββββββββββββββββββββββββββββ */
|
| 50 |
+
@layer components {
|
| 51 |
+
/* Webkit-based browsers */
|
| 52 |
+
.retro-scroll::-webkit-scrollbar {
|
| 53 |
+
width: 16px;
|
| 54 |
+
height: 16px;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.retro-scroll::-webkit-scrollbar-track {
|
| 58 |
+
background:
|
| 59 |
+
repeating-conic-gradient(
|
| 60 |
+
#c0c0c0 0% 25%, #dfdfdf 0% 50%
|
| 61 |
+
) 50% / 2px 2px;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.retro-scroll::-webkit-scrollbar-thumb {
|
| 65 |
+
background-color: #c0c0c0;
|
| 66 |
+
border: 1px solid #000000;
|
| 67 |
+
box-shadow:
|
| 68 |
+
inset -1px -1px 0 #808080,
|
| 69 |
+
inset 1px 1px 0 #ffffff;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.retro-scroll::-webkit-scrollbar-button {
|
| 73 |
+
background-color: #c0c0c0;
|
| 74 |
+
border: 1px solid #000000;
|
| 75 |
+
box-shadow:
|
| 76 |
+
inset -1px -1px 0 #808080,
|
| 77 |
+
inset 1px 1px 0 #ffffff;
|
| 78 |
+
display: block;
|
| 79 |
+
height: 16px;
|
| 80 |
+
width: 16px;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.retro-scroll::-webkit-scrollbar-corner {
|
| 84 |
+
background-color: #c0c0c0;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/* Firefox */
|
| 88 |
+
.retro-scroll {
|
| 89 |
+
scrollbar-width: auto;
|
| 90 |
+
scrollbar-color: #c0c0c0 #dfdfdf;
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* ββ Desktop dithered background βββββββββββββββββββββββββββββββββββββ */
|
| 95 |
+
@layer utilities {
|
| 96 |
+
.bg-retro-dither {
|
| 97 |
+
background-color: #c0c0c0;
|
| 98 |
+
background-image: url("data:image/svg+xml,%3Csvg width='4' height='4' viewBox='0 0 4 4' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='0' width='1' height='1' fill='%23a0a0a0'/%3E%3Crect x='2' y='2' width='1' height='1' fill='%23a0a0a0'/%3E%3C/svg%3E");
|
| 99 |
+
background-repeat: repeat;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.bg-retro-dither-dark {
|
| 103 |
+
background-color: #808080;
|
| 104 |
+
background-image: url("data:image/svg+xml,%3Csvg width='4' height='4' viewBox='0 0 4 4' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='0' width='1' height='1' fill='%23606060'/%3E%3Crect x='2' y='2' width='1' height='1' fill='%23606060'/%3E%3C/svg%3E");
|
| 105 |
+
background-repeat: repeat;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.bg-retro-dither-light {
|
| 109 |
+
background-color: #dfdfdf;
|
| 110 |
+
background-image: url("data:image/svg+xml,%3Csvg width='4' height='4' viewBox='0 0 4 4' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='0' width='1' height='1' fill='%23c8c8c8'/%3E%3Crect x='2' y='2' width='1' height='1' fill='%23c8c8c8'/%3E%3C/svg%3E");
|
| 111 |
+
background-repeat: repeat;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/* Horizontal lines pattern */
|
| 115 |
+
.bg-retro-lines {
|
| 116 |
+
background-color: #c0c0c0;
|
| 117 |
+
background-image: repeating-linear-gradient(
|
| 118 |
+
0deg,
|
| 119 |
+
transparent,
|
| 120 |
+
transparent 1px,
|
| 121 |
+
#a0a0a0 1px,
|
| 122 |
+
#a0a0a0 2px
|
| 123 |
+
);
|
| 124 |
+
background-size: 100% 4px;
|
| 125 |
+
}
|
| 126 |
+
}
|
frontend/src/pages/Admin.tsx
CHANGED
|
@@ -24,6 +24,15 @@ import {
|
|
| 24 |
type Job,
|
| 25 |
type CreateCorpusInput,
|
| 26 |
} from '../lib/api.ts'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
type IngestSubTab = 'urls' | 'manifest' | 'files'
|
| 29 |
|
|
@@ -35,27 +44,16 @@ interface Props {
|
|
| 35 |
|
| 36 |
function ErrorMsg({ message }: { message: string }) {
|
| 37 |
return (
|
| 38 |
-
<
|
| 39 |
-
{message}
|
| 40 |
-
</
|
| 41 |
)
|
| 42 |
}
|
| 43 |
|
| 44 |
function SuccessMsg({ message }: { message: string }) {
|
| 45 |
return (
|
| 46 |
-
<
|
| 47 |
-
{message}
|
| 48 |
-
</p>
|
| 49 |
-
)
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
// ββ SectionCard βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
-
|
| 54 |
-
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
|
| 55 |
-
return (
|
| 56 |
-
<div className="bg-white border border-stone-200 rounded-lg p-6 mb-4">
|
| 57 |
-
<h3 className="text-base font-semibold text-stone-800 mb-4">{title}</h3>
|
| 58 |
-
{children}
|
| 59 |
</div>
|
| 60 |
)
|
| 61 |
}
|
|
@@ -89,7 +87,7 @@ function CreateCorpusPanel({ onCreated }: CreateCorpusPanelProps) {
|
|
| 89 |
setLoading(true)
|
| 90 |
try {
|
| 91 |
const corpus = await createCorpus(form)
|
| 92 |
-
setSuccess(`Corpus
|
| 93 |
setForm((f) => ({ ...f, slug: '', title: '' }))
|
| 94 |
onCreated(corpus)
|
| 95 |
} catch (err) {
|
|
@@ -99,71 +97,45 @@ function CreateCorpusPanel({ onCreated }: CreateCorpusPanelProps) {
|
|
| 99 |
}
|
| 100 |
}
|
| 101 |
|
| 102 |
-
const inputClass =
|
| 103 |
-
'border border-stone-300 rounded px-3 py-2 text-sm w-full focus:outline-none focus:ring-2 focus:ring-stone-400'
|
| 104 |
-
|
| 105 |
return (
|
| 106 |
-
<
|
| 107 |
-
<
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
| 131 |
-
required
|
| 132 |
-
placeholder="ex. Beatus de Saint-Sever"
|
| 133 |
-
className={inputClass}
|
| 134 |
/>
|
| 135 |
-
|
| 136 |
-
<div>
|
| 137 |
-
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 138 |
-
Profil
|
| 139 |
-
</label>
|
| 140 |
-
{profiles.length === 0 ? (
|
| 141 |
-
<p className="text-sm text-stone-400">Chargement des profilsβ¦</p>
|
| 142 |
-
) : (
|
| 143 |
-
<select
|
| 144 |
-
value={form.profile_id}
|
| 145 |
-
onChange={(e) => setForm((f) => ({ ...f, profile_id: e.target.value }))}
|
| 146 |
-
className="border border-stone-300 rounded px-3 py-2 text-sm w-full bg-white focus:outline-none focus:ring-2 focus:ring-stone-400"
|
| 147 |
-
>
|
| 148 |
-
{profiles.map((p) => (
|
| 149 |
-
<option key={p.profile_id} value={p.profile_id}>
|
| 150 |
-
{p.label} ({p.profile_id})
|
| 151 |
-
</option>
|
| 152 |
-
))}
|
| 153 |
-
</select>
|
| 154 |
-
)}
|
| 155 |
-
</div>
|
| 156 |
{error && <ErrorMsg message={error} />}
|
| 157 |
{success && <SuccessMsg message={success} />}
|
| 158 |
-
<
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
| 165 |
</form>
|
| 166 |
-
</
|
| 167 |
)
|
| 168 |
}
|
| 169 |
|
|
@@ -178,19 +150,16 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
|
|
| 178 |
const [providers, setProviders] = useState<ProviderInfo[]>([])
|
| 179 |
const [loadingProviders, setLoadingProviders] = useState(true)
|
| 180 |
const [providersError, setProvidersError] = useState<string | null>(null)
|
| 181 |
-
|
| 182 |
const [selectedProvider, setSelectedProvider] = useState<string>('')
|
| 183 |
const [models, setModels] = useState<ModelInfo[]>([])
|
| 184 |
const [loadingModels, setLoadingModels] = useState(false)
|
| 185 |
const [modelsError, setModelsError] = useState<string | null>(null)
|
| 186 |
const [selectedModelId, setSelectedModelId] = useState('')
|
| 187 |
-
|
| 188 |
const [currentModel, setCurrentModel] = useState<CorpusModelConfig | null>(null)
|
| 189 |
const [savingModel, setSavingModel] = useState(false)
|
| 190 |
const [saveError, setSaveError] = useState<string | null>(null)
|
| 191 |
const [saveSuccess, setSaveSuccess] = useState<string | null>(null)
|
| 192 |
|
| 193 |
-
// Load current model config and providers on mount
|
| 194 |
useEffect(() => {
|
| 195 |
void getCorpusModel(corpusId).then(setCurrentModel)
|
| 196 |
setLoadingProviders(true)
|
|
@@ -201,13 +170,10 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
|
|
| 201 |
const first = ps.find((p) => p.available)
|
| 202 |
if (first) setSelectedProvider(first.provider_type)
|
| 203 |
})
|
| 204 |
-
.catch((err) =>
|
| 205 |
-
setProvidersError(err instanceof Error ? err.message : 'Erreur inconnue')
|
| 206 |
-
})
|
| 207 |
.finally(() => setLoadingProviders(false))
|
| 208 |
}, [corpusId])
|
| 209 |
|
| 210 |
-
// Load models when provider changes
|
| 211 |
useEffect(() => {
|
| 212 |
if (!selectedProvider) return
|
| 213 |
setModels([])
|
|
@@ -215,13 +181,8 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
|
|
| 215 |
setModelsError(null)
|
| 216 |
setLoadingModels(true)
|
| 217 |
fetchProviderModels(selectedProvider)
|
| 218 |
-
.then((ms) => {
|
| 219 |
-
|
| 220 |
-
if (ms.length > 0) setSelectedModelId(ms[0].model_id)
|
| 221 |
-
})
|
| 222 |
-
.catch((err) => {
|
| 223 |
-
setModelsError(err instanceof Error ? err.message : 'Erreur inconnue')
|
| 224 |
-
})
|
| 225 |
.finally(() => setLoadingModels(false))
|
| 226 |
}, [selectedProvider])
|
| 227 |
|
|
@@ -235,124 +196,83 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
|
|
| 235 |
await selectModel(corpusId, selectedModelId, model?.display_name ?? selectedModelId, selectedProvider)
|
| 236 |
const updated = await getCorpusModel(corpusId)
|
| 237 |
setCurrentModel(updated)
|
| 238 |
-
setSaveSuccess(`
|
| 239 |
onSaved()
|
| 240 |
} catch (err) {
|
| 241 |
-
setSaveError(err instanceof Error ? err.message : 'Erreur
|
| 242 |
} finally {
|
| 243 |
setSavingModel(false)
|
| 244 |
}
|
| 245 |
}
|
| 246 |
|
| 247 |
-
const availableProviders = providers.filter((p) => p.available)
|
| 248 |
-
|
| 249 |
return (
|
| 250 |
-
<>
|
| 251 |
{currentModel && (
|
| 252 |
-
<div className="
|
| 253 |
-
|
| 254 |
-
<span className="font-medium text-stone-800">{currentModel.selected_model_display_name}</span>
|
| 255 |
{' '}({currentModel.provider_type})
|
| 256 |
</div>
|
| 257 |
)}
|
| 258 |
|
| 259 |
-
{loadingProviders &&
|
| 260 |
-
<p className="text-sm text-stone-400">DΓ©tection des providers disponiblesβ¦</p>
|
| 261 |
-
)}
|
| 262 |
{!loadingProviders && providersError && <ErrorMsg message={providersError} />}
|
| 263 |
{!loadingProviders && providers.length > 0 && (
|
| 264 |
-
<div className="
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
} ${selectedProvider === p.provider_type ? 'ring-2 ring-stone-500' : ''}`}
|
| 277 |
-
onClick={() => p.available && setSelectedProvider(p.provider_type)}
|
| 278 |
-
>
|
| 279 |
-
<span className={`w-1.5 h-1.5 rounded-full ${p.available ? 'bg-green-500' : 'bg-stone-300'}`} />
|
| 280 |
-
{p.display_name}
|
| 281 |
-
{p.available && <span className="text-green-600">({p.model_count})</span>}
|
| 282 |
-
{!p.available && <span className="text-stone-400">β clΓ© manquante</span>}
|
| 283 |
-
</span>
|
| 284 |
-
))}
|
| 285 |
-
</div>
|
| 286 |
-
{availableProviders.length === 0 && (
|
| 287 |
-
<p className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-2 mt-3">
|
| 288 |
-
Aucun provider disponible. VΓ©rifiez les secrets{' '}
|
| 289 |
-
<code className="font-mono">GOOGLE_AI_STUDIO_API_KEY</code>,{' '}
|
| 290 |
-
<code className="font-mono">VERTEX_API_KEY</code> ou{' '}
|
| 291 |
-
<code className="font-mono">MISTRAL_API_KEY</code>.
|
| 292 |
-
</p>
|
| 293 |
-
)}
|
| 294 |
</div>
|
| 295 |
)}
|
| 296 |
|
| 297 |
{selectedProvider && (
|
| 298 |
-
<form onSubmit={(e) => void handleSelectModel(e)} className="
|
| 299 |
-
{loadingModels && <
|
| 300 |
{!loadingModels && modelsError && <ErrorMsg message={modelsError} />}
|
| 301 |
{!loadingModels && models.length > 0 && (
|
| 302 |
-
<
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
value
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
{models.map((m) => (
|
| 312 |
-
<option key={m.model_id} value={m.model_id}>
|
| 313 |
-
{m.display_name}{m.supports_vision ? ' (vision)' : ''}
|
| 314 |
-
</option>
|
| 315 |
-
))}
|
| 316 |
-
</select>
|
| 317 |
-
</div>
|
| 318 |
)}
|
| 319 |
{saveError && <ErrorMsg message={saveError} />}
|
| 320 |
{saveSuccess && <SuccessMsg message={saveSuccess} />}
|
| 321 |
{!loadingModels && models.length > 0 && (
|
| 322 |
-
<
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
className="bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 326 |
-
>
|
| 327 |
-
{savingModel ? 'Enregistrementβ¦' : 'SΓ©lectionner ce modΓ¨le'}
|
| 328 |
-
</button>
|
| 329 |
)}
|
| 330 |
</form>
|
| 331 |
)}
|
| 332 |
-
</>
|
| 333 |
)
|
| 334 |
}
|
| 335 |
|
| 336 |
// ββ IngestPanel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 337 |
|
| 338 |
-
|
| 339 |
-
corpusId: string
|
| 340 |
-
}
|
| 341 |
-
|
| 342 |
-
function IngestPanel({ corpusId }: IngestPanelProps) {
|
| 343 |
const [subTab, setSubTab] = useState<IngestSubTab>('urls')
|
| 344 |
-
|
| 345 |
const [urlsText, setUrlsText] = useState('')
|
| 346 |
const [folioLabelsText, setFolioLabelsText] = useState('')
|
| 347 |
const [urlsLoading, setUrlsLoading] = useState(false)
|
| 348 |
const [urlsError, setUrlsError] = useState<string | null>(null)
|
| 349 |
const [urlsSuccess, setUrlsSuccess] = useState<string | null>(null)
|
| 350 |
-
|
| 351 |
const [manifestUrl, setManifestUrl] = useState('')
|
| 352 |
const [manifestLoading, setManifestLoading] = useState(false)
|
| 353 |
const [manifestError, setManifestError] = useState<string | null>(null)
|
| 354 |
const [manifestSuccess, setManifestSuccess] = useState<string | null>(null)
|
| 355 |
-
|
| 356 |
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
| 357 |
const [filesLoading, setFilesLoading] = useState(false)
|
| 358 |
const [filesError, setFilesError] = useState<string | null>(null)
|
|
@@ -360,174 +280,98 @@ function IngestPanel({ corpusId }: IngestPanelProps) {
|
|
| 360 |
|
| 361 |
const handleUrlsSubmit = async (e: FormEvent) => {
|
| 362 |
e.preventDefault()
|
| 363 |
-
setUrlsError(null)
|
| 364 |
-
setUrlsSuccess(null)
|
| 365 |
const urls = urlsText.split('\n').map((l) => l.trim()).filter(Boolean)
|
| 366 |
const labels = folioLabelsText.split('\n').map((l) => l.trim()).filter(Boolean)
|
| 367 |
-
if (urls.length === 0) { setUrlsError('Aucune URL
|
| 368 |
-
if (labels.length !== urls.length) {
|
| 369 |
-
setUrlsError(`Le nombre de folio_labels (${labels.length}) doit Γͺtre Γ©gal au nombre d'URLs (${urls.length}).`)
|
| 370 |
-
return
|
| 371 |
-
}
|
| 372 |
setUrlsLoading(true)
|
| 373 |
try {
|
| 374 |
const resp = await ingestImages(corpusId, urls, labels)
|
| 375 |
-
setUrlsSuccess(`${resp.pages_created} page(s)
|
| 376 |
-
setUrlsText('')
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
setUrlsError(err instanceof Error ? err.message : 'Erreur inconnue')
|
| 380 |
-
} finally {
|
| 381 |
-
setUrlsLoading(false)
|
| 382 |
-
}
|
| 383 |
}
|
| 384 |
|
| 385 |
const handleManifestSubmit = async (e: FormEvent) => {
|
| 386 |
e.preventDefault()
|
| 387 |
-
setManifestError(null)
|
| 388 |
-
setManifestSuccess(null)
|
| 389 |
-
setManifestLoading(true)
|
| 390 |
try {
|
| 391 |
const resp = await ingestManifest(corpusId, manifestUrl)
|
| 392 |
-
setManifestSuccess(`${resp.pages_created} page(s)
|
| 393 |
setManifestUrl('')
|
| 394 |
-
} catch (err) {
|
| 395 |
-
|
| 396 |
-
} finally {
|
| 397 |
-
setManifestLoading(false)
|
| 398 |
-
}
|
| 399 |
}
|
| 400 |
|
| 401 |
const handleFilesSubmit = async (e: FormEvent) => {
|
| 402 |
e.preventDefault()
|
| 403 |
-
setFilesError(null)
|
| 404 |
-
|
| 405 |
-
if (selectedFiles.length === 0) { setFilesError('Aucun fichier sΓ©lectionnΓ©.'); return }
|
| 406 |
setFilesLoading(true)
|
| 407 |
try {
|
| 408 |
const resp = await ingestFiles(corpusId, selectedFiles)
|
| 409 |
-
setFilesSuccess(`${resp.pages_created} page(s)
|
| 410 |
setSelectedFiles([])
|
| 411 |
-
} catch (err) {
|
| 412 |
-
|
| 413 |
-
} finally {
|
| 414 |
-
setFilesLoading(false)
|
| 415 |
-
}
|
| 416 |
}
|
| 417 |
|
| 418 |
-
const subTabClass = (tab: IngestSubTab) =>
|
| 419 |
-
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
| 420 |
-
subTab === tab
|
| 421 |
-
? 'border-stone-800 text-stone-900'
|
| 422 |
-
: 'border-transparent text-stone-500 hover:text-stone-700'
|
| 423 |
-
}`
|
| 424 |
-
|
| 425 |
-
const textareaClass =
|
| 426 |
-
'border border-stone-300 rounded px-3 py-2 text-sm w-full font-mono focus:outline-none focus:ring-2 focus:ring-stone-400'
|
| 427 |
-
const submitBtnClass =
|
| 428 |
-
'bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
|
| 429 |
-
|
| 430 |
return (
|
| 431 |
-
<>
|
| 432 |
-
<div className="flex
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
|
|
|
|
|
|
| 436 |
</div>
|
| 437 |
|
| 438 |
{subTab === 'urls' && (
|
| 439 |
-
<form onSubmit={(e) => void handleUrlsSubmit(e)} className="
|
| 440 |
-
<
|
| 441 |
-
|
| 442 |
-
URLs d'images <span className="font-normal normal-case text-stone-400">(1 par ligne)</span>
|
| 443 |
-
</label>
|
| 444 |
-
<textarea
|
| 445 |
-
value={urlsText}
|
| 446 |
-
onChange={(e) => setUrlsText(e.target.value)}
|
| 447 |
-
rows={4}
|
| 448 |
-
placeholder="https://gallica.bnf.fr/iiif/ark:/β¦/f1/full/max/0/native.jpg"
|
| 449 |
-
className={textareaClass}
|
| 450 |
-
/>
|
| 451 |
-
</div>
|
| 452 |
-
<div>
|
| 453 |
-
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 454 |
-
Folio labels <span className="font-normal normal-case text-stone-400">(1 par ligne, mΓͺme ordre)</span>
|
| 455 |
-
</label>
|
| 456 |
-
<textarea
|
| 457 |
-
value={folioLabelsText}
|
| 458 |
-
onChange={(e) => setFolioLabelsText(e.target.value)}
|
| 459 |
-
rows={4}
|
| 460 |
-
placeholder={'001r\n001v\n002r'}
|
| 461 |
-
className={textareaClass}
|
| 462 |
-
/>
|
| 463 |
-
</div>
|
| 464 |
{urlsError && <ErrorMsg message={urlsError} />}
|
| 465 |
{urlsSuccess && <SuccessMsg message={urlsSuccess} />}
|
| 466 |
-
<
|
| 467 |
-
{urlsLoading ? 'Ingestionβ¦' : 'IngΓ©rer les images'}
|
| 468 |
-
</button>
|
| 469 |
</form>
|
| 470 |
)}
|
| 471 |
|
| 472 |
{subTab === 'manifest' && (
|
| 473 |
-
<form onSubmit={(e) => void handleManifestSubmit(e)} className="
|
| 474 |
-
<
|
| 475 |
-
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 476 |
-
URL du manifest IIIF
|
| 477 |
-
</label>
|
| 478 |
-
<input
|
| 479 |
-
type="url"
|
| 480 |
-
value={manifestUrl}
|
| 481 |
-
onChange={(e) => setManifestUrl(e.target.value)}
|
| 482 |
-
required
|
| 483 |
-
placeholder="https://gallica.bnf.fr/iiif/ark:/β¦/manifest.json"
|
| 484 |
-
className="border border-stone-300 rounded px-3 py-2 text-sm w-full font-mono focus:outline-none focus:ring-2 focus:ring-stone-400"
|
| 485 |
-
/>
|
| 486 |
-
</div>
|
| 487 |
{manifestError && <ErrorMsg message={manifestError} />}
|
| 488 |
{manifestSuccess && <SuccessMsg message={manifestSuccess} />}
|
| 489 |
-
<
|
| 490 |
-
{manifestLoading ? 'Ingestionβ¦' : 'Importer le manifest'}
|
| 491 |
-
</button>
|
| 492 |
</form>
|
| 493 |
)}
|
| 494 |
|
| 495 |
{subTab === 'files' && (
|
| 496 |
-
<form onSubmit={(e) => void handleFilesSubmit(e)} className="
|
| 497 |
-
<div>
|
| 498 |
-
<label className="
|
| 499 |
-
Fichiers images
|
| 500 |
-
</label>
|
| 501 |
<input
|
| 502 |
-
type="file"
|
| 503 |
-
multiple
|
| 504 |
-
accept="image/*"
|
| 505 |
onChange={(e) => setSelectedFiles(Array.from(e.target.files ?? []))}
|
| 506 |
-
className="
|
| 507 |
/>
|
| 508 |
{selectedFiles.length > 0 && (
|
| 509 |
-
<
|
| 510 |
)}
|
| 511 |
</div>
|
| 512 |
{filesError && <ErrorMsg message={filesError} />}
|
| 513 |
{filesSuccess && <SuccessMsg message={filesSuccess} />}
|
| 514 |
-
<
|
| 515 |
-
{filesLoading ? 'Envoiβ¦' : 'Envoyer les fichiers'}
|
| 516 |
-
</button>
|
| 517 |
</form>
|
| 518 |
)}
|
| 519 |
-
</>
|
| 520 |
)
|
| 521 |
}
|
| 522 |
|
| 523 |
// ββ RunPanel ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 524 |
|
| 525 |
-
|
| 526 |
-
corpusId: string
|
| 527 |
-
hasModel: boolean
|
| 528 |
-
}
|
| 529 |
-
|
| 530 |
-
function RunPanel({ corpusId, hasModel }: RunPanelProps) {
|
| 531 |
const [pageCount, setPageCount] = useState<number | null>(null)
|
| 532 |
const [launching, setLaunching] = useState(false)
|
| 533 |
const [launchError, setLaunchError] = useState<string | null>(null)
|
|
@@ -535,7 +379,6 @@ function RunPanel({ corpusId, hasModel }: RunPanelProps) {
|
|
| 535 |
const [jobs, setJobs] = useState<Record<string, Job>>({})
|
| 536 |
const [polling, setPolling] = useState(false)
|
| 537 |
|
| 538 |
-
// Fetch page count from manuscripts + pages
|
| 539 |
useEffect(() => {
|
| 540 |
fetchManuscripts(corpusId)
|
| 541 |
.then(async (manuscripts) => {
|
|
@@ -555,28 +398,19 @@ function RunPanel({ corpusId, hasModel }: RunPanelProps) {
|
|
| 555 |
for (const job of results) map[job.id] = job
|
| 556 |
setJobs(map)
|
| 557 |
if (results.every((j) => j.status === 'done' || j.status === 'failed')) setPolling(false)
|
| 558 |
-
} catch {
|
| 559 |
-
// Erreur rΓ©seau transitoire β on continue
|
| 560 |
-
}
|
| 561 |
}
|
| 562 |
const id = setInterval(() => void poll(), 3000)
|
| 563 |
return () => clearInterval(id)
|
| 564 |
}, [polling, jobIds])
|
| 565 |
|
| 566 |
const handleRun = async () => {
|
| 567 |
-
setLaunchError(null)
|
| 568 |
-
setJobIds([])
|
| 569 |
-
setJobs({})
|
| 570 |
-
setLaunching(true)
|
| 571 |
try {
|
| 572 |
const resp = await runCorpus(corpusId)
|
| 573 |
-
setJobIds(resp.job_ids)
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
setLaunchError(err instanceof Error ? err.message : 'Erreur inconnue')
|
| 577 |
-
} finally {
|
| 578 |
-
setLaunching(false)
|
| 579 |
-
}
|
| 580 |
}
|
| 581 |
|
| 582 |
const handleRetryFailed = async () => {
|
|
@@ -591,84 +425,51 @@ function RunPanel({ corpusId, hasModel }: RunPanelProps) {
|
|
| 591 |
const failedCount = jobList.filter((j) => j.status === 'failed').length
|
| 592 |
const totalCount = jobList.length
|
| 593 |
|
| 594 |
-
const
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
failed: 'bg-red-100 text-red-700',
|
| 600 |
-
}
|
| 601 |
-
return (
|
| 602 |
-
<span className={`text-xs px-2 py-0.5 rounded font-medium ${classes[status] ?? 'bg-stone-100 text-stone-500'}`}>
|
| 603 |
-
{status}
|
| 604 |
-
</span>
|
| 605 |
-
)
|
| 606 |
}
|
| 607 |
|
| 608 |
if (!hasModel) {
|
| 609 |
-
return
|
| 610 |
-
<p className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-2">
|
| 611 |
-
Configurez d'abord un modèle IA pour ce corpus.
|
| 612 |
-
</p>
|
| 613 |
-
)
|
| 614 |
}
|
| 615 |
|
| 616 |
return (
|
| 617 |
-
<div className="
|
| 618 |
{pageCount !== null && (
|
| 619 |
-
<
|
| 620 |
-
{pageCount === 0
|
| 621 |
-
? 'Aucune page ingΓ©rΓ©e.'
|
| 622 |
-
: `${pageCount} page(s) dans ce corpus.`}
|
| 623 |
-
</p>
|
| 624 |
)}
|
| 625 |
-
|
| 626 |
{launchError && <ErrorMsg message={launchError} />}
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
disabled={launching || polling || pageCount === 0}
|
| 632 |
-
className="bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 633 |
-
>
|
| 634 |
-
{launching ? 'DΓ©marrageβ¦' : polling ? 'Traitement en coursβ¦' : 'Analyser tout le corpus'}
|
| 635 |
-
</button>
|
| 636 |
-
|
| 637 |
{failedCount > 0 && !polling && (
|
| 638 |
-
<
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
>
|
| 642 |
-
Relancer {failedCount} page(s) en erreur
|
| 643 |
-
</button>
|
| 644 |
)}
|
| 645 |
</div>
|
| 646 |
-
|
| 647 |
{totalCount > 0 && (
|
| 648 |
<div>
|
| 649 |
-
<
|
| 650 |
-
|
| 651 |
-
{failedCount > 0 && <span className="
|
| 652 |
-
{polling && <span className="
|
| 653 |
-
</
|
| 654 |
-
<
|
| 655 |
{jobList.map((job) => (
|
| 656 |
-
<
|
| 657 |
-
|
| 658 |
-
className="flex items-center
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
<div className="flex items-center gap-2 ml-2 shrink-0">
|
| 662 |
-
{statusBadge(job.status)}
|
| 663 |
-
{job.error_message && (
|
| 664 |
-
<span className="text-red-500 truncate max-w-xs" title={job.error_message}>
|
| 665 |
-
{job.error_message}
|
| 666 |
-
</span>
|
| 667 |
-
)}
|
| 668 |
</div>
|
| 669 |
-
</
|
| 670 |
))}
|
| 671 |
-
</
|
| 672 |
</div>
|
| 673 |
)}
|
| 674 |
</div>
|
|
@@ -677,99 +478,68 @@ function RunPanel({ corpusId, hasModel }: RunPanelProps) {
|
|
| 677 |
|
| 678 |
// ββ CorpusDetail ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 679 |
|
| 680 |
-
|
| 681 |
-
corpus: Corpus
|
| 682 |
-
onDeleted: () => void
|
| 683 |
-
}
|
| 684 |
-
|
| 685 |
-
function CorpusDetail({ corpus, onDeleted }: CorpusDetailProps) {
|
| 686 |
const [hasModel, setHasModel] = useState(false)
|
| 687 |
const [deleting, setDeleting] = useState(false)
|
| 688 |
const [deleteError, setDeleteError] = useState<string | null>(null)
|
| 689 |
const [confirmDelete, setConfirmDelete] = useState(false)
|
| 690 |
|
| 691 |
useEffect(() => {
|
| 692 |
-
getCorpusModel(corpus.id)
|
| 693 |
-
.then((m) => setHasModel(m !== null))
|
| 694 |
-
.catch(() => {})
|
| 695 |
}, [corpus.id])
|
| 696 |
|
| 697 |
const handleDelete = async () => {
|
| 698 |
-
setDeleteError(null)
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
await deleteCorpus(corpus.id)
|
| 702 |
-
onDeleted()
|
| 703 |
-
} catch (err) {
|
| 704 |
-
setDeleteError(err instanceof Error ? err.message : 'Erreur inconnue')
|
| 705 |
-
setDeleting(false)
|
| 706 |
-
setConfirmDelete(false)
|
| 707 |
-
}
|
| 708 |
}
|
| 709 |
|
| 710 |
return (
|
| 711 |
-
<div>
|
| 712 |
-
{/*
|
| 713 |
-
<div className="flex items-
|
| 714 |
<div>
|
| 715 |
-
<
|
| 716 |
-
<
|
| 717 |
-
<span className="font-mono">{corpus.slug}</span>
|
| 718 |
-
{' Β· '}
|
| 719 |
-
<span>{corpus.profile_id}</span>
|
| 720 |
-
</p>
|
| 721 |
</div>
|
| 722 |
-
<div className="flex items-center gap-
|
| 723 |
-
{deleteError && <span className="text-
|
| 724 |
{confirmDelete ? (
|
| 725 |
<>
|
| 726 |
-
<span className="text-
|
| 727 |
-
<
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
>
|
| 732 |
-
{deleting ? 'Suppressionβ¦' : 'Supprimer'}
|
| 733 |
-
</button>
|
| 734 |
-
<button
|
| 735 |
-
onClick={() => setConfirmDelete(false)}
|
| 736 |
-
className="px-3 py-1.5 border border-stone-300 text-stone-600 text-xs rounded font-medium hover:bg-stone-50 transition-colors"
|
| 737 |
-
>
|
| 738 |
-
Annuler
|
| 739 |
-
</button>
|
| 740 |
</>
|
| 741 |
) : (
|
| 742 |
-
<
|
| 743 |
-
onClick={() => setConfirmDelete(true)}
|
| 744 |
-
className="px-3 py-1.5 border border-red-200 text-red-600 text-xs rounded font-medium hover:bg-red-50 transition-colors"
|
| 745 |
-
>
|
| 746 |
-
Supprimer
|
| 747 |
-
</button>
|
| 748 |
)}
|
| 749 |
</div>
|
| 750 |
</div>
|
| 751 |
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
onSaved={() => setHasModel(true)}
|
| 758 |
-
/>
|
| 759 |
-
</SectionCard>
|
| 760 |
|
| 761 |
-
<
|
| 762 |
-
<
|
| 763 |
-
|
|
|
|
|
|
|
| 764 |
|
| 765 |
-
<
|
| 766 |
-
<
|
| 767 |
-
|
|
|
|
|
|
|
| 768 |
</div>
|
| 769 |
)
|
| 770 |
}
|
| 771 |
|
| 772 |
-
// ββ Admin (
|
| 773 |
|
| 774 |
export default function Admin({ onHome }: Props) {
|
| 775 |
const [corpora, setCorpora] = useState<Corpus[]>([])
|
|
@@ -781,85 +551,71 @@ export default function Admin({ onHome }: Props) {
|
|
| 781 |
fetchCorpora()
|
| 782 |
.then((cs) => {
|
| 783 |
setCorpora(cs)
|
| 784 |
-
if (selectId) {
|
| 785 |
-
|
| 786 |
-
setShowCreate(false)
|
| 787 |
-
} else if (!didInit.current) {
|
| 788 |
didInit.current = true
|
| 789 |
-
if (cs.length > 0) {
|
| 790 |
-
|
| 791 |
-
setShowCreate(false)
|
| 792 |
-
} else {
|
| 793 |
-
setShowCreate(true)
|
| 794 |
-
}
|
| 795 |
}
|
| 796 |
})
|
| 797 |
.catch(() => {})
|
| 798 |
}
|
| 799 |
|
| 800 |
-
useEffect(() => {
|
| 801 |
-
refreshCorpora()
|
| 802 |
-
}, [])
|
| 803 |
|
| 804 |
const selectedCorpus = corpora.find((c) => c.id === selectedCorpusId) ?? null
|
| 805 |
|
| 806 |
return (
|
| 807 |
-
<div className="h-screen flex flex-col bg-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
<h1 className="text-xl font-semibold tracking-tight">Administration</h1>
|
| 817 |
-
</header>
|
| 818 |
-
|
| 819 |
-
<div className="flex flex-1 overflow-hidden">
|
| 820 |
{/* Sidebar */}
|
| 821 |
-
<
|
| 822 |
-
<div className="
|
| 823 |
<button
|
| 824 |
onClick={() => { setShowCreate(true); setSelectedCorpusId(null) }}
|
| 825 |
-
className={`
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
>
|
| 831 |
+ Nouveau corpus
|
| 832 |
</button>
|
| 833 |
-
</div>
|
| 834 |
-
<nav className="flex-1 p-3 space-y-0.5">
|
| 835 |
{corpora.length === 0 && (
|
| 836 |
-
<
|
| 837 |
)}
|
| 838 |
{corpora.map((c) => (
|
| 839 |
<button
|
| 840 |
key={c.id}
|
| 841 |
onClick={() => { setSelectedCorpusId(c.id); setShowCreate(false) }}
|
| 842 |
-
className={`
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
|
|
|
|
|
|
| 847 |
>
|
| 848 |
-
<
|
| 849 |
-
<
|
|
|
|
|
|
|
| 850 |
</button>
|
| 851 |
))}
|
| 852 |
-
</
|
| 853 |
-
</
|
| 854 |
|
| 855 |
{/* Main panel */}
|
| 856 |
-
<
|
| 857 |
{showCreate && !selectedCorpusId && (
|
| 858 |
-
<CreateCorpusPanel
|
| 859 |
-
onCreated={(corpus) => {
|
| 860 |
-
refreshCorpora(corpus.id)
|
| 861 |
-
}}
|
| 862 |
-
/>
|
| 863 |
)}
|
| 864 |
{!showCreate && selectedCorpus && (
|
| 865 |
<CorpusDetail
|
|
@@ -868,20 +624,15 @@ export default function Admin({ onHome }: Props) {
|
|
| 868 |
onDeleted={() => {
|
| 869 |
const remaining = corpora.filter((c) => c.id !== selectedCorpus.id)
|
| 870 |
setCorpora(remaining)
|
| 871 |
-
if (remaining.length > 0) {
|
| 872 |
-
|
| 873 |
-
setShowCreate(false)
|
| 874 |
-
} else {
|
| 875 |
-
setSelectedCorpusId(null)
|
| 876 |
-
setShowCreate(true)
|
| 877 |
-
}
|
| 878 |
}}
|
| 879 |
/>
|
| 880 |
)}
|
| 881 |
{!showCreate && !selectedCorpus && corpora.length > 0 && (
|
| 882 |
-
<
|
| 883 |
)}
|
| 884 |
-
</
|
| 885 |
</div>
|
| 886 |
</div>
|
| 887 |
)
|
|
|
|
| 24 |
type Job,
|
| 25 |
type CreateCorpusInput,
|
| 26 |
} from '../lib/api.ts'
|
| 27 |
+
import {
|
| 28 |
+
RetroMenuBar,
|
| 29 |
+
RetroWindow,
|
| 30 |
+
RetroButton,
|
| 31 |
+
RetroInput,
|
| 32 |
+
RetroTextarea,
|
| 33 |
+
RetroSelect,
|
| 34 |
+
RetroBadge,
|
| 35 |
+
} from '../components/retro'
|
| 36 |
|
| 37 |
type IngestSubTab = 'urls' | 'manifest' | 'files'
|
| 38 |
|
|
|
|
| 44 |
|
| 45 |
function ErrorMsg({ message }: { message: string }) {
|
| 46 |
return (
|
| 47 |
+
<div className="border border-retro-black bg-retro-white p-2 text-retro-sm">
|
| 48 |
+
<span className="font-bold">Erreur:</span> {message}
|
| 49 |
+
</div>
|
| 50 |
)
|
| 51 |
}
|
| 52 |
|
| 53 |
function SuccessMsg({ message }: { message: string }) {
|
| 54 |
return (
|
| 55 |
+
<div className="border border-retro-black bg-retro-light p-2 text-retro-sm">
|
| 56 |
+
<span className="font-bold">OK:</span> {message}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
</div>
|
| 58 |
)
|
| 59 |
}
|
|
|
|
| 87 |
setLoading(true)
|
| 88 |
try {
|
| 89 |
const corpus = await createCorpus(form)
|
| 90 |
+
setSuccess(`Corpus "${corpus.title}" cree.`)
|
| 91 |
setForm((f) => ({ ...f, slug: '', title: '' }))
|
| 92 |
onCreated(corpus)
|
| 93 |
} catch (err) {
|
|
|
|
| 97 |
}
|
| 98 |
}
|
| 99 |
|
|
|
|
|
|
|
|
|
|
| 100 |
return (
|
| 101 |
+
<RetroWindow title="Creer un corpus" className="max-w-lg">
|
| 102 |
+
<form onSubmit={(e) => void handleSubmit(e)} className="p-3 flex flex-col gap-2">
|
| 103 |
+
<RetroInput
|
| 104 |
+
label="Slug (identifiant unique)"
|
| 105 |
+
value={form.slug}
|
| 106 |
+
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
|
| 107 |
+
required
|
| 108 |
+
placeholder="ex. beatus-lat8878"
|
| 109 |
+
/>
|
| 110 |
+
<RetroInput
|
| 111 |
+
label="Titre"
|
| 112 |
+
value={form.title}
|
| 113 |
+
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
| 114 |
+
required
|
| 115 |
+
placeholder="ex. Beatus de Saint-Sever"
|
| 116 |
+
/>
|
| 117 |
+
{profiles.length === 0 ? (
|
| 118 |
+
<div className="text-retro-sm text-retro-darkgray">Chargement des profils...</div>
|
| 119 |
+
) : (
|
| 120 |
+
<RetroSelect
|
| 121 |
+
label="Profil"
|
| 122 |
+
value={form.profile_id}
|
| 123 |
+
onChange={(e) => setForm((f) => ({ ...f, profile_id: e.target.value }))}
|
| 124 |
+
options={profiles.map((p) => ({ value: p.profile_id, label: `${p.label} (${p.profile_id})` }))}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
/>
|
| 126 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
{error && <ErrorMsg message={error} />}
|
| 128 |
{success && <SuccessMsg message={success} />}
|
| 129 |
+
<div className="mt-1">
|
| 130 |
+
<RetroButton
|
| 131 |
+
type="submit"
|
| 132 |
+
disabled={loading || !form.slug || !form.title || !form.profile_id}
|
| 133 |
+
>
|
| 134 |
+
{loading ? 'Creation...' : 'Creer le corpus'}
|
| 135 |
+
</RetroButton>
|
| 136 |
+
</div>
|
| 137 |
</form>
|
| 138 |
+
</RetroWindow>
|
| 139 |
)
|
| 140 |
}
|
| 141 |
|
|
|
|
| 150 |
const [providers, setProviders] = useState<ProviderInfo[]>([])
|
| 151 |
const [loadingProviders, setLoadingProviders] = useState(true)
|
| 152 |
const [providersError, setProvidersError] = useState<string | null>(null)
|
|
|
|
| 153 |
const [selectedProvider, setSelectedProvider] = useState<string>('')
|
| 154 |
const [models, setModels] = useState<ModelInfo[]>([])
|
| 155 |
const [loadingModels, setLoadingModels] = useState(false)
|
| 156 |
const [modelsError, setModelsError] = useState<string | null>(null)
|
| 157 |
const [selectedModelId, setSelectedModelId] = useState('')
|
|
|
|
| 158 |
const [currentModel, setCurrentModel] = useState<CorpusModelConfig | null>(null)
|
| 159 |
const [savingModel, setSavingModel] = useState(false)
|
| 160 |
const [saveError, setSaveError] = useState<string | null>(null)
|
| 161 |
const [saveSuccess, setSaveSuccess] = useState<string | null>(null)
|
| 162 |
|
|
|
|
| 163 |
useEffect(() => {
|
| 164 |
void getCorpusModel(corpusId).then(setCurrentModel)
|
| 165 |
setLoadingProviders(true)
|
|
|
|
| 170 |
const first = ps.find((p) => p.available)
|
| 171 |
if (first) setSelectedProvider(first.provider_type)
|
| 172 |
})
|
| 173 |
+
.catch((err) => setProvidersError(err instanceof Error ? err.message : 'Erreur'))
|
|
|
|
|
|
|
| 174 |
.finally(() => setLoadingProviders(false))
|
| 175 |
}, [corpusId])
|
| 176 |
|
|
|
|
| 177 |
useEffect(() => {
|
| 178 |
if (!selectedProvider) return
|
| 179 |
setModels([])
|
|
|
|
| 181 |
setModelsError(null)
|
| 182 |
setLoadingModels(true)
|
| 183 |
fetchProviderModels(selectedProvider)
|
| 184 |
+
.then((ms) => { setModels(ms); if (ms.length > 0) setSelectedModelId(ms[0].model_id) })
|
| 185 |
+
.catch((err) => setModelsError(err instanceof Error ? err.message : 'Erreur'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
.finally(() => setLoadingModels(false))
|
| 187 |
}, [selectedProvider])
|
| 188 |
|
|
|
|
| 196 |
await selectModel(corpusId, selectedModelId, model?.display_name ?? selectedModelId, selectedProvider)
|
| 197 |
const updated = await getCorpusModel(corpusId)
|
| 198 |
setCurrentModel(updated)
|
| 199 |
+
setSaveSuccess(`Modele "${model?.display_name ?? selectedModelId}" associe.`)
|
| 200 |
onSaved()
|
| 201 |
} catch (err) {
|
| 202 |
+
setSaveError(err instanceof Error ? err.message : 'Erreur')
|
| 203 |
} finally {
|
| 204 |
setSavingModel(false)
|
| 205 |
}
|
| 206 |
}
|
| 207 |
|
|
|
|
|
|
|
| 208 |
return (
|
| 209 |
+
<div className="flex flex-col gap-2">
|
| 210 |
{currentModel && (
|
| 211 |
+
<div className="text-retro-sm border border-retro-black p-2 bg-retro-light">
|
| 212 |
+
Modele actuel: <span className="font-bold">{currentModel.selected_model_display_name}</span>
|
|
|
|
| 213 |
{' '}({currentModel.provider_type})
|
| 214 |
</div>
|
| 215 |
)}
|
| 216 |
|
| 217 |
+
{loadingProviders && <div className="text-retro-sm text-retro-darkgray">Detection providers...</div>}
|
|
|
|
|
|
|
| 218 |
{!loadingProviders && providersError && <ErrorMsg message={providersError} />}
|
| 219 |
{!loadingProviders && providers.length > 0 && (
|
| 220 |
+
<div className="flex flex-wrap gap-[2px]">
|
| 221 |
+
{providers.map((p) => (
|
| 222 |
+
<RetroButton
|
| 223 |
+
key={p.provider_type}
|
| 224 |
+
size="sm"
|
| 225 |
+
pressed={selectedProvider === p.provider_type}
|
| 226 |
+
disabled={!p.available}
|
| 227 |
+
onClick={() => p.available && setSelectedProvider(p.provider_type)}
|
| 228 |
+
>
|
| 229 |
+
{p.display_name} {p.available ? `(${p.model_count})` : 'β N/A'}
|
| 230 |
+
</RetroButton>
|
| 231 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
</div>
|
| 233 |
)}
|
| 234 |
|
| 235 |
{selectedProvider && (
|
| 236 |
+
<form onSubmit={(e) => void handleSelectModel(e)} className="flex flex-col gap-2 max-w-sm">
|
| 237 |
+
{loadingModels && <div className="text-retro-sm text-retro-darkgray">Chargement modeles...</div>}
|
| 238 |
{!loadingModels && modelsError && <ErrorMsg message={modelsError} />}
|
| 239 |
{!loadingModels && models.length > 0 && (
|
| 240 |
+
<RetroSelect
|
| 241 |
+
label={`Modele β ${providers.find((p) => p.provider_type === selectedProvider)?.display_name}`}
|
| 242 |
+
value={selectedModelId}
|
| 243 |
+
onChange={(e) => setSelectedModelId(e.target.value)}
|
| 244 |
+
options={models.map((m) => ({
|
| 245 |
+
value: m.model_id,
|
| 246 |
+
label: `${m.display_name}${m.supports_vision ? ' (vision)' : ''}`,
|
| 247 |
+
}))}
|
| 248 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
)}
|
| 250 |
{saveError && <ErrorMsg message={saveError} />}
|
| 251 |
{saveSuccess && <SuccessMsg message={saveSuccess} />}
|
| 252 |
{!loadingModels && models.length > 0 && (
|
| 253 |
+
<RetroButton type="submit" disabled={savingModel || !selectedModelId}>
|
| 254 |
+
{savingModel ? 'Enregistrement...' : 'Selectionner'}
|
| 255 |
+
</RetroButton>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
)}
|
| 257 |
</form>
|
| 258 |
)}
|
| 259 |
+
</div>
|
| 260 |
)
|
| 261 |
}
|
| 262 |
|
| 263 |
// ββ IngestPanel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 264 |
|
| 265 |
+
function IngestPanel({ corpusId }: { corpusId: string }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
const [subTab, setSubTab] = useState<IngestSubTab>('urls')
|
|
|
|
| 267 |
const [urlsText, setUrlsText] = useState('')
|
| 268 |
const [folioLabelsText, setFolioLabelsText] = useState('')
|
| 269 |
const [urlsLoading, setUrlsLoading] = useState(false)
|
| 270 |
const [urlsError, setUrlsError] = useState<string | null>(null)
|
| 271 |
const [urlsSuccess, setUrlsSuccess] = useState<string | null>(null)
|
|
|
|
| 272 |
const [manifestUrl, setManifestUrl] = useState('')
|
| 273 |
const [manifestLoading, setManifestLoading] = useState(false)
|
| 274 |
const [manifestError, setManifestError] = useState<string | null>(null)
|
| 275 |
const [manifestSuccess, setManifestSuccess] = useState<string | null>(null)
|
|
|
|
| 276 |
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
| 277 |
const [filesLoading, setFilesLoading] = useState(false)
|
| 278 |
const [filesError, setFilesError] = useState<string | null>(null)
|
|
|
|
| 280 |
|
| 281 |
const handleUrlsSubmit = async (e: FormEvent) => {
|
| 282 |
e.preventDefault()
|
| 283 |
+
setUrlsError(null); setUrlsSuccess(null)
|
|
|
|
| 284 |
const urls = urlsText.split('\n').map((l) => l.trim()).filter(Boolean)
|
| 285 |
const labels = folioLabelsText.split('\n').map((l) => l.trim()).filter(Boolean)
|
| 286 |
+
if (urls.length === 0) { setUrlsError('Aucune URL.'); return }
|
| 287 |
+
if (labels.length !== urls.length) { setUrlsError(`Labels (${labels.length}) != URLs (${urls.length})`); return }
|
|
|
|
|
|
|
|
|
|
| 288 |
setUrlsLoading(true)
|
| 289 |
try {
|
| 290 |
const resp = await ingestImages(corpusId, urls, labels)
|
| 291 |
+
setUrlsSuccess(`${resp.pages_created} page(s) ingeree(s).`)
|
| 292 |
+
setUrlsText(''); setFolioLabelsText('')
|
| 293 |
+
} catch (err) { setUrlsError(err instanceof Error ? err.message : 'Erreur') }
|
| 294 |
+
finally { setUrlsLoading(false) }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
}
|
| 296 |
|
| 297 |
const handleManifestSubmit = async (e: FormEvent) => {
|
| 298 |
e.preventDefault()
|
| 299 |
+
setManifestError(null); setManifestSuccess(null); setManifestLoading(true)
|
|
|
|
|
|
|
| 300 |
try {
|
| 301 |
const resp = await ingestManifest(corpusId, manifestUrl)
|
| 302 |
+
setManifestSuccess(`${resp.pages_created} page(s) ingeree(s).`)
|
| 303 |
setManifestUrl('')
|
| 304 |
+
} catch (err) { setManifestError(err instanceof Error ? err.message : 'Erreur') }
|
| 305 |
+
finally { setManifestLoading(false) }
|
|
|
|
|
|
|
|
|
|
| 306 |
}
|
| 307 |
|
| 308 |
const handleFilesSubmit = async (e: FormEvent) => {
|
| 309 |
e.preventDefault()
|
| 310 |
+
setFilesError(null); setFilesSuccess(null)
|
| 311 |
+
if (selectedFiles.length === 0) { setFilesError('Aucun fichier.'); return }
|
|
|
|
| 312 |
setFilesLoading(true)
|
| 313 |
try {
|
| 314 |
const resp = await ingestFiles(corpusId, selectedFiles)
|
| 315 |
+
setFilesSuccess(`${resp.pages_created} page(s) ingeree(s).`)
|
| 316 |
setSelectedFiles([])
|
| 317 |
+
} catch (err) { setFilesError(err instanceof Error ? err.message : 'Erreur') }
|
| 318 |
+
finally { setFilesLoading(false) }
|
|
|
|
|
|
|
|
|
|
| 319 |
}
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
return (
|
| 322 |
+
<div className="flex flex-col gap-2">
|
| 323 |
+
<div className="flex gap-[2px]">
|
| 324 |
+
{(['urls', 'manifest', 'files'] as IngestSubTab[]).map((t) => (
|
| 325 |
+
<RetroButton key={t} size="sm" pressed={subTab === t} onClick={() => setSubTab(t)}>
|
| 326 |
+
{t === 'urls' ? 'URLs' : t === 'manifest' ? 'Manifest' : 'Fichiers'}
|
| 327 |
+
</RetroButton>
|
| 328 |
+
))}
|
| 329 |
</div>
|
| 330 |
|
| 331 |
{subTab === 'urls' && (
|
| 332 |
+
<form onSubmit={(e) => void handleUrlsSubmit(e)} className="flex flex-col gap-2">
|
| 333 |
+
<RetroTextarea label="URLs d'images (1/ligne)" value={urlsText} onChange={(e) => setUrlsText(e.target.value)} rows={4} placeholder="https://..." />
|
| 334 |
+
<RetroTextarea label="Folio labels (1/ligne)" value={folioLabelsText} onChange={(e) => setFolioLabelsText(e.target.value)} rows={4} placeholder={'001r\n001v'} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
{urlsError && <ErrorMsg message={urlsError} />}
|
| 336 |
{urlsSuccess && <SuccessMsg message={urlsSuccess} />}
|
| 337 |
+
<RetroButton type="submit" disabled={urlsLoading}>{urlsLoading ? 'Ingestion...' : 'Ingerer'}</RetroButton>
|
|
|
|
|
|
|
| 338 |
</form>
|
| 339 |
)}
|
| 340 |
|
| 341 |
{subTab === 'manifest' && (
|
| 342 |
+
<form onSubmit={(e) => void handleManifestSubmit(e)} className="flex flex-col gap-2">
|
| 343 |
+
<RetroInput label="URL manifest IIIF" type="url" value={manifestUrl} onChange={(e) => setManifestUrl(e.target.value)} required placeholder="https://.../manifest.json" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
{manifestError && <ErrorMsg message={manifestError} />}
|
| 345 |
{manifestSuccess && <SuccessMsg message={manifestSuccess} />}
|
| 346 |
+
<RetroButton type="submit" disabled={manifestLoading || !manifestUrl}>{manifestLoading ? 'Ingestion...' : 'Importer'}</RetroButton>
|
|
|
|
|
|
|
| 347 |
</form>
|
| 348 |
)}
|
| 349 |
|
| 350 |
{subTab === 'files' && (
|
| 351 |
+
<form onSubmit={(e) => void handleFilesSubmit(e)} className="flex flex-col gap-2">
|
| 352 |
+
<div className="flex flex-col gap-[2px]">
|
| 353 |
+
<label className="text-retro-xs font-bold">Fichiers images</label>
|
|
|
|
|
|
|
| 354 |
<input
|
| 355 |
+
type="file" multiple accept="image/*"
|
|
|
|
|
|
|
| 356 |
onChange={(e) => setSelectedFiles(Array.from(e.target.files ?? []))}
|
| 357 |
+
className="text-retro-sm font-retro border border-retro-black bg-retro-white p-1"
|
| 358 |
/>
|
| 359 |
{selectedFiles.length > 0 && (
|
| 360 |
+
<span className="text-retro-xs text-retro-darkgray">{selectedFiles.length} fichier(s)</span>
|
| 361 |
)}
|
| 362 |
</div>
|
| 363 |
{filesError && <ErrorMsg message={filesError} />}
|
| 364 |
{filesSuccess && <SuccessMsg message={filesSuccess} />}
|
| 365 |
+
<RetroButton type="submit" disabled={filesLoading || selectedFiles.length === 0}>{filesLoading ? 'Envoi...' : 'Envoyer'}</RetroButton>
|
|
|
|
|
|
|
| 366 |
</form>
|
| 367 |
)}
|
| 368 |
+
</div>
|
| 369 |
)
|
| 370 |
}
|
| 371 |
|
| 372 |
// ββ RunPanel ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 373 |
|
| 374 |
+
function RunPanel({ corpusId, hasModel }: { corpusId: string; hasModel: boolean }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
const [pageCount, setPageCount] = useState<number | null>(null)
|
| 376 |
const [launching, setLaunching] = useState(false)
|
| 377 |
const [launchError, setLaunchError] = useState<string | null>(null)
|
|
|
|
| 379 |
const [jobs, setJobs] = useState<Record<string, Job>>({})
|
| 380 |
const [polling, setPolling] = useState(false)
|
| 381 |
|
|
|
|
| 382 |
useEffect(() => {
|
| 383 |
fetchManuscripts(corpusId)
|
| 384 |
.then(async (manuscripts) => {
|
|
|
|
| 398 |
for (const job of results) map[job.id] = job
|
| 399 |
setJobs(map)
|
| 400 |
if (results.every((j) => j.status === 'done' || j.status === 'failed')) setPolling(false)
|
| 401 |
+
} catch { /* transient */ }
|
|
|
|
|
|
|
| 402 |
}
|
| 403 |
const id = setInterval(() => void poll(), 3000)
|
| 404 |
return () => clearInterval(id)
|
| 405 |
}, [polling, jobIds])
|
| 406 |
|
| 407 |
const handleRun = async () => {
|
| 408 |
+
setLaunchError(null); setJobIds([]); setJobs({}); setLaunching(true)
|
|
|
|
|
|
|
|
|
|
| 409 |
try {
|
| 410 |
const resp = await runCorpus(corpusId)
|
| 411 |
+
setJobIds(resp.job_ids); setPolling(true)
|
| 412 |
+
} catch (err) { setLaunchError(err instanceof Error ? err.message : 'Erreur') }
|
| 413 |
+
finally { setLaunching(false) }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
}
|
| 415 |
|
| 416 |
const handleRetryFailed = async () => {
|
|
|
|
| 425 |
const failedCount = jobList.filter((j) => j.status === 'failed').length
|
| 426 |
const totalCount = jobList.length
|
| 427 |
|
| 428 |
+
const statusVariant = (s: string): 'default' | 'success' | 'warning' | 'error' | 'info' => {
|
| 429 |
+
if (s === 'done') return 'success'
|
| 430 |
+
if (s === 'failed') return 'error'
|
| 431 |
+
if (s === 'running') return 'info'
|
| 432 |
+
return 'default'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
}
|
| 434 |
|
| 435 |
if (!hasModel) {
|
| 436 |
+
return <div className="text-retro-sm border border-retro-black p-2 bg-retro-white">Configurez d'abord un modele IA.</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
}
|
| 438 |
|
| 439 |
return (
|
| 440 |
+
<div className="flex flex-col gap-2">
|
| 441 |
{pageCount !== null && (
|
| 442 |
+
<div className="text-retro-sm">{pageCount === 0 ? 'Aucune page ingeree.' : `${pageCount} page(s).`}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
)}
|
|
|
|
| 444 |
{launchError && <ErrorMsg message={launchError} />}
|
| 445 |
+
<div className="flex flex-wrap gap-[2px]">
|
| 446 |
+
<RetroButton onClick={() => void handleRun()} disabled={launching || polling || pageCount === 0}>
|
| 447 |
+
{launching ? 'Demarrage...' : polling ? 'En cours...' : 'Analyser tout'}
|
| 448 |
+
</RetroButton>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
{failedCount > 0 && !polling && (
|
| 450 |
+
<RetroButton onClick={() => void handleRetryFailed()}>
|
| 451 |
+
Relancer {failedCount} erreur(s)
|
| 452 |
+
</RetroButton>
|
|
|
|
|
|
|
|
|
|
| 453 |
)}
|
| 454 |
</div>
|
|
|
|
| 455 |
{totalCount > 0 && (
|
| 456 |
<div>
|
| 457 |
+
<div className="text-retro-sm mb-1">
|
| 458 |
+
<span className="font-bold">{doneCount}</span>/{totalCount} traitees
|
| 459 |
+
{failedCount > 0 && <span className="ml-2 font-bold">{failedCount} erreur(s)</span>}
|
| 460 |
+
{polling && <span className="ml-2 text-retro-darkgray">(actualisation 3s)</span>}
|
| 461 |
+
</div>
|
| 462 |
+
<div className="border border-retro-black bg-retro-white max-h-48 overflow-y-auto retro-scroll">
|
| 463 |
{jobList.map((job) => (
|
| 464 |
+
<div key={job.id} className="flex items-center justify-between text-retro-xs px-2 py-[2px] border-b border-retro-gray last:border-0">
|
| 465 |
+
<span className="truncate max-w-[200px]">{job.page_id ?? job.id}</span>
|
| 466 |
+
<div className="flex items-center gap-1 shrink-0">
|
| 467 |
+
<RetroBadge variant={statusVariant(job.status)}>{job.status}</RetroBadge>
|
| 468 |
+
{job.error_message && <span className="text-retro-xs truncate max-w-[120px]" title={job.error_message}>{job.error_message}</span>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
</div>
|
| 470 |
+
</div>
|
| 471 |
))}
|
| 472 |
+
</div>
|
| 473 |
</div>
|
| 474 |
)}
|
| 475 |
</div>
|
|
|
|
| 478 |
|
| 479 |
// ββ CorpusDetail ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 480 |
|
| 481 |
+
function CorpusDetail({ corpus, onDeleted }: { corpus: Corpus; onDeleted: () => void }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
const [hasModel, setHasModel] = useState(false)
|
| 483 |
const [deleting, setDeleting] = useState(false)
|
| 484 |
const [deleteError, setDeleteError] = useState<string | null>(null)
|
| 485 |
const [confirmDelete, setConfirmDelete] = useState(false)
|
| 486 |
|
| 487 |
useEffect(() => {
|
| 488 |
+
getCorpusModel(corpus.id).then((m) => setHasModel(m !== null)).catch(() => {})
|
|
|
|
|
|
|
| 489 |
}, [corpus.id])
|
| 490 |
|
| 491 |
const handleDelete = async () => {
|
| 492 |
+
setDeleteError(null); setDeleting(true)
|
| 493 |
+
try { await deleteCorpus(corpus.id); onDeleted() }
|
| 494 |
+
catch (err) { setDeleteError(err instanceof Error ? err.message : 'Erreur'); setDeleting(false); setConfirmDelete(false) }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
}
|
| 496 |
|
| 497 |
return (
|
| 498 |
+
<div className="flex flex-col gap-2">
|
| 499 |
+
{/* Header */}
|
| 500 |
+
<div className="flex items-center justify-between border border-retro-black bg-retro-light p-2">
|
| 501 |
<div>
|
| 502 |
+
<span className="text-retro-lg font-bold">{corpus.title}</span>
|
| 503 |
+
<div className="text-retro-xs text-retro-darkgray">{corpus.slug} β {corpus.profile_id}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
</div>
|
| 505 |
+
<div className="flex items-center gap-1">
|
| 506 |
+
{deleteError && <span className="text-retro-xs">{deleteError}</span>}
|
| 507 |
{confirmDelete ? (
|
| 508 |
<>
|
| 509 |
+
<span className="text-retro-xs">Confirmer?</span>
|
| 510 |
+
<RetroButton size="sm" onClick={() => void handleDelete()} disabled={deleting}>
|
| 511 |
+
{deleting ? '...' : 'Oui'}
|
| 512 |
+
</RetroButton>
|
| 513 |
+
<RetroButton size="sm" onClick={() => setConfirmDelete(false)}>Non</RetroButton>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
</>
|
| 515 |
) : (
|
| 516 |
+
<RetroButton size="sm" onClick={() => setConfirmDelete(true)}>Supprimer</RetroButton>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
)}
|
| 518 |
</div>
|
| 519 |
</div>
|
| 520 |
|
| 521 |
+
<RetroWindow title="Modele IA">
|
| 522 |
+
<div className="p-2">
|
| 523 |
+
<ModelPanel key={corpus.id} corpusId={corpus.id} onSaved={() => setHasModel(true)} />
|
| 524 |
+
</div>
|
| 525 |
+
</RetroWindow>
|
|
|
|
|
|
|
|
|
|
| 526 |
|
| 527 |
+
<RetroWindow title="Ingestion">
|
| 528 |
+
<div className="p-2">
|
| 529 |
+
<IngestPanel key={corpus.id} corpusId={corpus.id} />
|
| 530 |
+
</div>
|
| 531 |
+
</RetroWindow>
|
| 532 |
|
| 533 |
+
<RetroWindow title="Traitement">
|
| 534 |
+
<div className="p-2">
|
| 535 |
+
<RunPanel key={corpus.id} corpusId={corpus.id} hasModel={hasModel} />
|
| 536 |
+
</div>
|
| 537 |
+
</RetroWindow>
|
| 538 |
</div>
|
| 539 |
)
|
| 540 |
}
|
| 541 |
|
| 542 |
+
// ββ Admin (main component) βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 543 |
|
| 544 |
export default function Admin({ onHome }: Props) {
|
| 545 |
const [corpora, setCorpora] = useState<Corpus[]>([])
|
|
|
|
| 551 |
fetchCorpora()
|
| 552 |
.then((cs) => {
|
| 553 |
setCorpora(cs)
|
| 554 |
+
if (selectId) { setSelectedCorpusId(selectId); setShowCreate(false) }
|
| 555 |
+
else if (!didInit.current) {
|
|
|
|
|
|
|
| 556 |
didInit.current = true
|
| 557 |
+
if (cs.length > 0) { setSelectedCorpusId(cs[0].id); setShowCreate(false) }
|
| 558 |
+
else setShowCreate(true)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
}
|
| 560 |
})
|
| 561 |
.catch(() => {})
|
| 562 |
}
|
| 563 |
|
| 564 |
+
useEffect(() => { refreshCorpora() }, [])
|
|
|
|
|
|
|
| 565 |
|
| 566 |
const selectedCorpus = corpora.find((c) => c.id === selectedCorpusId) ?? null
|
| 567 |
|
| 568 |
return (
|
| 569 |
+
<div className="h-screen flex flex-col bg-retro-dither">
|
| 570 |
+
<RetroMenuBar
|
| 571 |
+
items={[
|
| 572 |
+
{ label: 'IIIF Studio', onClick: onHome },
|
| 573 |
+
{ label: 'Administration' },
|
| 574 |
+
]}
|
| 575 |
+
/>
|
| 576 |
+
|
| 577 |
+
<div className="flex flex-1 overflow-hidden p-1 gap-1">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
{/* Sidebar */}
|
| 579 |
+
<RetroWindow title="Corpus" className="w-56 shrink-0" scrollable>
|
| 580 |
+
<div className="flex flex-col">
|
| 581 |
<button
|
| 582 |
onClick={() => { setShowCreate(true); setSelectedCorpusId(null) }}
|
| 583 |
+
className={`
|
| 584 |
+
w-full text-left px-2 py-[4px] text-retro-sm font-bold
|
| 585 |
+
border-b border-retro-gray
|
| 586 |
+
${showCreate && !selectedCorpusId ? 'bg-retro-select text-retro-select-text' : 'hover:bg-retro-select hover:text-retro-select-text'}
|
| 587 |
+
`}
|
| 588 |
>
|
| 589 |
+ Nouveau corpus
|
| 590 |
</button>
|
|
|
|
|
|
|
| 591 |
{corpora.length === 0 && (
|
| 592 |
+
<div className="px-2 py-2 text-retro-xs text-retro-darkgray">Aucun corpus</div>
|
| 593 |
)}
|
| 594 |
{corpora.map((c) => (
|
| 595 |
<button
|
| 596 |
key={c.id}
|
| 597 |
onClick={() => { setSelectedCorpusId(c.id); setShowCreate(false) }}
|
| 598 |
+
className={`
|
| 599 |
+
w-full text-left px-2 py-[4px] text-retro-sm
|
| 600 |
+
border-b border-retro-gray
|
| 601 |
+
${selectedCorpusId === c.id && !showCreate
|
| 602 |
+
? 'bg-retro-select text-retro-select-text'
|
| 603 |
+
: 'hover:bg-retro-select hover:text-retro-select-text'}
|
| 604 |
+
`}
|
| 605 |
>
|
| 606 |
+
<div className="truncate font-bold">{c.title}</div>
|
| 607 |
+
<div className={`truncate text-retro-xs ${selectedCorpusId === c.id && !showCreate ? 'opacity-70' : 'text-retro-darkgray'}`}>
|
| 608 |
+
{c.slug}
|
| 609 |
+
</div>
|
| 610 |
</button>
|
| 611 |
))}
|
| 612 |
+
</div>
|
| 613 |
+
</RetroWindow>
|
| 614 |
|
| 615 |
{/* Main panel */}
|
| 616 |
+
<div className="flex-1 overflow-y-auto retro-scroll p-2">
|
| 617 |
{showCreate && !selectedCorpusId && (
|
| 618 |
+
<CreateCorpusPanel onCreated={(corpus) => refreshCorpora(corpus.id)} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
)}
|
| 620 |
{!showCreate && selectedCorpus && (
|
| 621 |
<CorpusDetail
|
|
|
|
| 624 |
onDeleted={() => {
|
| 625 |
const remaining = corpora.filter((c) => c.id !== selectedCorpus.id)
|
| 626 |
setCorpora(remaining)
|
| 627 |
+
if (remaining.length > 0) { setSelectedCorpusId(remaining[0].id); setShowCreate(false) }
|
| 628 |
+
else { setSelectedCorpusId(null); setShowCreate(true) }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 629 |
}}
|
| 630 |
/>
|
| 631 |
)}
|
| 632 |
{!showCreate && !selectedCorpus && corpora.length > 0 && (
|
| 633 |
+
<div className="text-retro-sm text-retro-darkgray p-2">Selectionnez un corpus.</div>
|
| 634 |
)}
|
| 635 |
+
</div>
|
| 636 |
</div>
|
| 637 |
</div>
|
| 638 |
)
|
frontend/src/pages/Editor.tsx
CHANGED
|
@@ -7,6 +7,14 @@ import {
|
|
| 7 |
type VersionInfo,
|
| 8 |
} from '../lib/api.ts'
|
| 9 |
import Viewer from '../components/Viewer.tsx'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
interface Props {
|
| 12 |
pageId: string
|
|
@@ -15,6 +23,13 @@ interface Props {
|
|
| 15 |
|
| 16 |
type Panel = 'transcription' | 'commentary' | 'regions' | 'history'
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
export default function Editor({ pageId, onBack }: Props) {
|
| 19 |
const [master, setMaster] = useState<PageMaster | null>(null)
|
| 20 |
const [history, setHistory] = useState<VersionInfo[]>([])
|
|
@@ -25,7 +40,6 @@ export default function Editor({ pageId, onBack }: Props) {
|
|
| 25 |
const [saveError, setSaveError] = useState<string | null>(null)
|
| 26 |
const [saveSuccess, setSaveSuccess] = useState(false)
|
| 27 |
|
| 28 |
-
// Editable field values
|
| 29 |
const [ocrText, setOcrText] = useState('')
|
| 30 |
const [commentaryPublic, setCommentaryPublic] = useState('')
|
| 31 |
const [commentaryScholarly, setCommentaryScholarly] = useState('')
|
|
@@ -45,7 +59,6 @@ export default function Editor({ pageId, onBack }: Props) {
|
|
| 45 |
setCommentaryPublic(m.commentary?.public ?? '')
|
| 46 |
setCommentaryScholarly(m.commentary?.scholarly ?? '')
|
| 47 |
setEditorialStatus(m.editorial.status)
|
| 48 |
-
// Restore existing region validations from extensions
|
| 49 |
const ext = (m as unknown as { extensions?: { region_validations?: Record<string, string> } }).extensions
|
| 50 |
setRegionValidations(ext?.region_validations ?? {})
|
| 51 |
} catch (e: unknown) {
|
|
@@ -107,249 +120,239 @@ export default function Editor({ pageId, onBack }: Props) {
|
|
| 107 |
setRegionValidations((prev) => ({ ...prev, [regionId]: val }))
|
| 108 |
}
|
| 109 |
|
|
|
|
| 110 |
if (loading) {
|
| 111 |
return (
|
| 112 |
-
<div className="flex items-center justify-center
|
| 113 |
-
Chargement
|
|
|
|
|
|
|
| 114 |
</div>
|
| 115 |
)
|
| 116 |
}
|
| 117 |
|
| 118 |
if (error) {
|
| 119 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
}
|
| 121 |
|
| 122 |
const imageUrl = master?.image?.derivative_web ?? master?.image?.master ?? ''
|
| 123 |
const regions = master?.layout?.regions ?? []
|
| 124 |
|
| 125 |
return (
|
| 126 |
-
<div className="flex flex-col h-screen bg-
|
| 127 |
-
{/* ββ
|
| 128 |
-
<
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
<span className="text-green-400 text-xs">EnregistrΓ©</span>
|
| 148 |
-
)}
|
| 149 |
-
{saveError && (
|
| 150 |
-
<span className="text-red-400 text-xs">{saveError}</span>
|
| 151 |
-
)}
|
| 152 |
-
<button
|
| 153 |
-
onClick={() => void handleSave()}
|
| 154 |
-
disabled={saving}
|
| 155 |
-
className="px-4 py-1.5 bg-amber-600 hover:bg-amber-500 disabled:opacity-40 text-white text-sm rounded transition-colors"
|
| 156 |
-
>
|
| 157 |
-
{saving ? 'Enregistrementβ¦' : 'Enregistrer'}
|
| 158 |
-
</button>
|
| 159 |
-
</div>
|
| 160 |
-
</header>
|
| 161 |
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
<Viewer imageUrl={imageUrl} onViewerReady={() => {}} />
|
| 167 |
-
{!imageUrl && (
|
| 168 |
-
<div className="absolute inset-0 flex items-center justify-center bg-stone-200 text-stone-400 text-sm">
|
| 169 |
-
AperΓ§u image non disponible
|
| 170 |
-
</div>
|
| 171 |
-
)}
|
| 172 |
-
</div>
|
| 173 |
-
|
| 174 |
-
{/* Panneaux droite */}
|
| 175 |
-
<div
|
| 176 |
-
className="flex flex-col border-l border-stone-200 bg-white"
|
| 177 |
-
style={{ width: '50%' }}
|
| 178 |
>
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
{
|
| 182 |
-
<
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
activePanel === p
|
| 187 |
-
? 'border-b-2 border-amber-500 text-amber-700 bg-amber-50'
|
| 188 |
-
: 'text-stone-500 hover:text-stone-800'
|
| 189 |
-
}`}
|
| 190 |
-
>
|
| 191 |
-
{p === 'transcription' ? 'Transcription' :
|
| 192 |
-
p === 'commentary' ? 'Commentaire' :
|
| 193 |
-
p === 'regions' ? 'RΓ©gions' : 'Historique'}
|
| 194 |
-
</button>
|
| 195 |
-
))}
|
| 196 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
Texte diplomatique (OCR)
|
| 207 |
-
</label>
|
| 208 |
-
<textarea
|
| 209 |
value={ocrText}
|
| 210 |
onChange={(e) => setOcrText(e.target.value)}
|
| 211 |
rows={12}
|
| 212 |
-
className="w-full border border-stone-300 rounded-md px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-400 resize-y"
|
| 213 |
/>
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
<label className="block text-xs font-semibold text-stone-600 mb-1.5 uppercase tracking-wide">
|
| 217 |
-
Statut Γ©ditorial
|
| 218 |
-
</label>
|
| 219 |
-
<select
|
| 220 |
value={editorialStatus}
|
| 221 |
onChange={(e) => setEditorialStatus(e.target.value)}
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
</div>
|
| 231 |
-
|
| 232 |
-
<div className="text-xs text-stone-400 space-y-0.5">
|
| 233 |
-
<div>Langue : {master.ocr.language}</div>
|
| 234 |
-
<div>Confiance : {(master.ocr.confidence * 100).toFixed(0)} %</div>
|
| 235 |
-
</div>
|
| 236 |
-
)}
|
| 237 |
-
</div>
|
| 238 |
-
)}
|
| 239 |
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
Commentaire public
|
| 246 |
-
</label>
|
| 247 |
-
<textarea
|
| 248 |
value={commentaryPublic}
|
| 249 |
onChange={(e) => setCommentaryPublic(e.target.value)}
|
| 250 |
rows={6}
|
| 251 |
-
className="w-full border border-stone-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 resize-y"
|
| 252 |
/>
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
<label className="block text-xs font-semibold text-stone-600 mb-1.5 uppercase tracking-wide">
|
| 256 |
-
Commentaire savant
|
| 257 |
-
</label>
|
| 258 |
-
<textarea
|
| 259 |
value={commentaryScholarly}
|
| 260 |
onChange={(e) => setCommentaryScholarly(e.target.value)}
|
| 261 |
rows={8}
|
| 262 |
-
className="w-full border border-stone-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 resize-y"
|
| 263 |
/>
|
| 264 |
</div>
|
| 265 |
-
|
| 266 |
-
)}
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
<div
|
| 278 |
-
key={
|
| 279 |
-
className="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
>
|
| 281 |
<div>
|
| 282 |
-
<span className="
|
| 283 |
-
|
| 284 |
-
<
|
| 285 |
-
|
| 286 |
-
<div className="text-xs text-stone-400">
|
| 287 |
-
confiance : {(region.confidence * 100).toFixed(0)} %
|
| 288 |
</div>
|
| 289 |
</div>
|
| 290 |
-
<
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
}`}
|
| 298 |
-
>
|
| 299 |
-
Valider
|
| 300 |
-
</button>
|
| 301 |
-
<button
|
| 302 |
-
onClick={() => setRegionValidation(region.id, 'rejected')}
|
| 303 |
-
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
| 304 |
-
validation === 'rejected'
|
| 305 |
-
? 'bg-red-600 text-white'
|
| 306 |
-
: 'bg-stone-100 text-stone-600 hover:bg-red-100'
|
| 307 |
-
}`}
|
| 308 |
-
>
|
| 309 |
-
Rejeter
|
| 310 |
-
</button>
|
| 311 |
-
</div>
|
| 312 |
</div>
|
| 313 |
-
)
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
{/* ββ Historique ββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 320 |
-
{activePanel === 'history' && (
|
| 321 |
-
<div className="space-y-2">
|
| 322 |
-
{history.length === 0 ? (
|
| 323 |
-
<p className="text-sm text-stone-400 italic">
|
| 324 |
-
Aucune version archivΓ©e.
|
| 325 |
-
</p>
|
| 326 |
-
) : (
|
| 327 |
-
history.map((v) => (
|
| 328 |
-
<div
|
| 329 |
-
key={v.version}
|
| 330 |
-
className="flex items-center justify-between border border-stone-200 rounded-lg px-3 py-2.5 text-sm"
|
| 331 |
-
>
|
| 332 |
-
<div>
|
| 333 |
-
<span className="font-medium text-stone-800">v{v.version}</span>
|
| 334 |
-
<span className="ml-2 text-xs text-stone-500">{v.status}</span>
|
| 335 |
-
<div className="text-xs text-stone-400 mt-0.5">
|
| 336 |
-
{new Date(v.saved_at).toLocaleString('fr-FR')}
|
| 337 |
-
</div>
|
| 338 |
-
</div>
|
| 339 |
-
<button
|
| 340 |
-
onClick={() => void handleRestore(v.version)}
|
| 341 |
-
disabled={saving}
|
| 342 |
-
className="ml-4 px-3 py-1 text-xs bg-stone-100 text-stone-600 hover:bg-amber-100 hover:text-amber-700 disabled:opacity-40 rounded-md transition-colors"
|
| 343 |
-
>
|
| 344 |
-
Restaurer
|
| 345 |
-
</button>
|
| 346 |
-
</div>
|
| 347 |
-
))
|
| 348 |
-
)}
|
| 349 |
-
</div>
|
| 350 |
-
)}
|
| 351 |
</div>
|
| 352 |
-
</
|
| 353 |
</div>
|
| 354 |
</div>
|
| 355 |
)
|
|
|
|
| 7 |
type VersionInfo,
|
| 8 |
} from '../lib/api.ts'
|
| 9 |
import Viewer from '../components/Viewer.tsx'
|
| 10 |
+
import {
|
| 11 |
+
RetroMenuBar,
|
| 12 |
+
RetroWindow,
|
| 13 |
+
RetroButton,
|
| 14 |
+
RetroTextarea,
|
| 15 |
+
RetroSelect,
|
| 16 |
+
RetroBadge,
|
| 17 |
+
} from '../components/retro'
|
| 18 |
|
| 19 |
interface Props {
|
| 20 |
pageId: string
|
|
|
|
| 23 |
|
| 24 |
type Panel = 'transcription' | 'commentary' | 'regions' | 'history'
|
| 25 |
|
| 26 |
+
const PANEL_LABELS: Record<Panel, string> = {
|
| 27 |
+
transcription: 'Transcription',
|
| 28 |
+
commentary: 'Commentaire',
|
| 29 |
+
regions: 'Regions',
|
| 30 |
+
history: 'Historique',
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
export default function Editor({ pageId, onBack }: Props) {
|
| 34 |
const [master, setMaster] = useState<PageMaster | null>(null)
|
| 35 |
const [history, setHistory] = useState<VersionInfo[]>([])
|
|
|
|
| 40 |
const [saveError, setSaveError] = useState<string | null>(null)
|
| 41 |
const [saveSuccess, setSaveSuccess] = useState(false)
|
| 42 |
|
|
|
|
| 43 |
const [ocrText, setOcrText] = useState('')
|
| 44 |
const [commentaryPublic, setCommentaryPublic] = useState('')
|
| 45 |
const [commentaryScholarly, setCommentaryScholarly] = useState('')
|
|
|
|
| 59 |
setCommentaryPublic(m.commentary?.public ?? '')
|
| 60 |
setCommentaryScholarly(m.commentary?.scholarly ?? '')
|
| 61 |
setEditorialStatus(m.editorial.status)
|
|
|
|
| 62 |
const ext = (m as unknown as { extensions?: { region_validations?: Record<string, string> } }).extensions
|
| 63 |
setRegionValidations(ext?.region_validations ?? {})
|
| 64 |
} catch (e: unknown) {
|
|
|
|
| 120 |
setRegionValidations((prev) => ({ ...prev, [regionId]: val }))
|
| 121 |
}
|
| 122 |
|
| 123 |
+
// ββ Loading / Error βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 124 |
if (loading) {
|
| 125 |
return (
|
| 126 |
+
<div className="min-h-screen bg-retro-dither flex items-center justify-center">
|
| 127 |
+
<RetroWindow title="Chargement" className="w-64">
|
| 128 |
+
<div className="p-4 text-retro-sm text-center">Chargement...</div>
|
| 129 |
+
</RetroWindow>
|
| 130 |
</div>
|
| 131 |
)
|
| 132 |
}
|
| 133 |
|
| 134 |
if (error) {
|
| 135 |
+
return (
|
| 136 |
+
<div className="min-h-screen bg-retro-dither flex items-center justify-center">
|
| 137 |
+
<RetroWindow title="Erreur" className="w-80">
|
| 138 |
+
<div className="p-4 text-retro-sm">
|
| 139 |
+
{error}
|
| 140 |
+
<div className="mt-2"><RetroButton onClick={onBack}>Retour</RetroButton></div>
|
| 141 |
+
</div>
|
| 142 |
+
</RetroWindow>
|
| 143 |
+
</div>
|
| 144 |
+
)
|
| 145 |
}
|
| 146 |
|
| 147 |
const imageUrl = master?.image?.derivative_web ?? master?.image?.master ?? ''
|
| 148 |
const regions = master?.layout?.regions ?? []
|
| 149 |
|
| 150 |
return (
|
| 151 |
+
<div className="flex flex-col h-screen bg-retro-dither">
|
| 152 |
+
{/* ββ Menu bar βββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 153 |
+
<RetroMenuBar
|
| 154 |
+
items={[
|
| 155 |
+
{ label: 'IIIF Studio', onClick: onBack },
|
| 156 |
+
{ label: `Editeur β ${master?.folio_label ?? pageId}` },
|
| 157 |
+
]}
|
| 158 |
+
right={
|
| 159 |
+
<div className="flex items-center gap-1">
|
| 160 |
+
{master && (
|
| 161 |
+
<span className="text-retro-xs px-2">
|
| 162 |
+
v{master.editorial.version} β {master.editorial.status}
|
| 163 |
+
</span>
|
| 164 |
+
)}
|
| 165 |
+
{saveSuccess && <RetroBadge variant="success">OK</RetroBadge>}
|
| 166 |
+
{saveError && <RetroBadge variant="error">Err</RetroBadge>}
|
| 167 |
+
<RetroButton
|
| 168 |
+
size="sm"
|
| 169 |
+
onClick={() => void handleSave()}
|
| 170 |
+
disabled={saving}
|
| 171 |
+
>
|
| 172 |
+
{saving ? 'Saving...' : 'Sauvegarder'}
|
| 173 |
+
</RetroButton>
|
| 174 |
+
</div>
|
| 175 |
+
}
|
| 176 |
+
/>
|
| 177 |
|
| 178 |
+
{/* ββ Main layout 50/50 ββββββββββββββββββββββββββββββββββββββββ */}
|
| 179 |
+
<div className="flex flex-1 overflow-hidden p-1 gap-1">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
+
{/* ββ Viewer window (left) βββββββββββββββββββββββββββββββββ */}
|
| 182 |
+
<RetroWindow
|
| 183 |
+
title={`Image β ${master?.folio_label ?? pageId}`}
|
| 184 |
+
className="flex-1 min-w-0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
>
|
| 186 |
+
<div className="relative w-full h-full">
|
| 187 |
+
<Viewer imageUrl={imageUrl} onViewerReady={() => {}} />
|
| 188 |
+
{!imageUrl && (
|
| 189 |
+
<div className="absolute inset-0 flex items-center justify-center bg-retro-gray text-retro-darkgray text-retro-sm">
|
| 190 |
+
Apercu non disponible
|
| 191 |
+
</div>
|
| 192 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
</div>
|
| 194 |
+
</RetroWindow>
|
| 195 |
+
|
| 196 |
+
{/* ββ Editor window (right) ββββββββββββββββββββββββββββββββ */}
|
| 197 |
+
<RetroWindow
|
| 198 |
+
title="Editeur"
|
| 199 |
+
className="flex-1 min-w-0"
|
| 200 |
+
scrollable
|
| 201 |
+
>
|
| 202 |
+
<div className="flex flex-col">
|
| 203 |
+
{/* ββ Tab bar ββββββββββββββββββββββββββββββββββββββββ */}
|
| 204 |
+
<div className="flex shrink-0 border-b border-retro-black bg-retro-gray">
|
| 205 |
+
{(['transcription', 'commentary', 'regions', 'history'] as Panel[]).map((p) => (
|
| 206 |
+
<RetroButton
|
| 207 |
+
key={p}
|
| 208 |
+
size="sm"
|
| 209 |
+
pressed={activePanel === p}
|
| 210 |
+
onClick={() => setActivePanel(p)}
|
| 211 |
+
className="flex-1 border-0 border-r border-retro-darkgray last:border-r-0"
|
| 212 |
+
>
|
| 213 |
+
{PANEL_LABELS[p]}
|
| 214 |
+
</RetroButton>
|
| 215 |
+
))}
|
| 216 |
+
</div>
|
| 217 |
|
| 218 |
+
{/* ββ Panel content βββββββββββββββββββββββββββββββββββ */}
|
| 219 |
+
<div className="p-2">
|
| 220 |
|
| 221 |
+
{/* Transcription */}
|
| 222 |
+
{activePanel === 'transcription' && (
|
| 223 |
+
<div className="flex flex-col gap-2">
|
| 224 |
+
<RetroTextarea
|
| 225 |
+
label="Texte diplomatique (OCR)"
|
|
|
|
|
|
|
|
|
|
| 226 |
value={ocrText}
|
| 227 |
onChange={(e) => setOcrText(e.target.value)}
|
| 228 |
rows={12}
|
|
|
|
| 229 |
/>
|
| 230 |
+
<RetroSelect
|
| 231 |
+
label="Statut editorial"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
value={editorialStatus}
|
| 233 |
onChange={(e) => setEditorialStatus(e.target.value)}
|
| 234 |
+
options={[
|
| 235 |
+
{ value: 'machine_draft', label: 'machine_draft' },
|
| 236 |
+
{ value: 'needs_review', label: 'needs_review' },
|
| 237 |
+
{ value: 'reviewed', label: 'reviewed' },
|
| 238 |
+
{ value: 'validated', label: 'validated' },
|
| 239 |
+
{ value: 'published', label: 'published' },
|
| 240 |
+
]}
|
| 241 |
+
/>
|
| 242 |
+
{master?.ocr && (
|
| 243 |
+
<div className="text-retro-xs text-retro-darkgray">
|
| 244 |
+
Langue: {master.ocr.language} β Confiance: {(master.ocr.confidence * 100).toFixed(0)}%
|
| 245 |
+
</div>
|
| 246 |
+
)}
|
| 247 |
</div>
|
| 248 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
+
{/* Commentary */}
|
| 251 |
+
{activePanel === 'commentary' && (
|
| 252 |
+
<div className="flex flex-col gap-2">
|
| 253 |
+
<RetroTextarea
|
| 254 |
+
label="Commentaire public"
|
|
|
|
|
|
|
|
|
|
| 255 |
value={commentaryPublic}
|
| 256 |
onChange={(e) => setCommentaryPublic(e.target.value)}
|
| 257 |
rows={6}
|
|
|
|
| 258 |
/>
|
| 259 |
+
<RetroTextarea
|
| 260 |
+
label="Commentaire savant"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
value={commentaryScholarly}
|
| 262 |
onChange={(e) => setCommentaryScholarly(e.target.value)}
|
| 263 |
rows={8}
|
|
|
|
| 264 |
/>
|
| 265 |
</div>
|
| 266 |
+
)}
|
|
|
|
| 267 |
|
| 268 |
+
{/* Regions */}
|
| 269 |
+
{activePanel === 'regions' && (
|
| 270 |
+
<div className="flex flex-col gap-[2px]">
|
| 271 |
+
{regions.length === 0 ? (
|
| 272 |
+
<p className="text-retro-sm text-retro-darkgray p-2">Aucune region detectee.</p>
|
| 273 |
+
) : (
|
| 274 |
+
regions.map((region) => {
|
| 275 |
+
const validation = regionValidations[region.id]
|
| 276 |
+
return (
|
| 277 |
+
<div
|
| 278 |
+
key={region.id}
|
| 279 |
+
className="
|
| 280 |
+
flex items-center justify-between
|
| 281 |
+
border border-retro-black p-2
|
| 282 |
+
bg-retro-white
|
| 283 |
+
"
|
| 284 |
+
>
|
| 285 |
+
<div>
|
| 286 |
+
<span className="text-retro-sm font-bold capitalize">
|
| 287 |
+
{region.type.replace(/_/g, ' ')}
|
| 288 |
+
</span>
|
| 289 |
+
<span className="ml-2 text-retro-xs text-retro-darkgray">
|
| 290 |
+
{region.id}
|
| 291 |
+
</span>
|
| 292 |
+
<div className="text-retro-xs text-retro-darkgray">
|
| 293 |
+
confiance: {(region.confidence * 100).toFixed(0)}%
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
<div className="flex gap-[2px] ml-2 shrink-0">
|
| 297 |
+
<RetroButton
|
| 298 |
+
size="sm"
|
| 299 |
+
pressed={validation === 'validated'}
|
| 300 |
+
onClick={() => setRegionValidation(region.id, 'validated')}
|
| 301 |
+
>
|
| 302 |
+
OK
|
| 303 |
+
</RetroButton>
|
| 304 |
+
<RetroButton
|
| 305 |
+
size="sm"
|
| 306 |
+
pressed={validation === 'rejected'}
|
| 307 |
+
onClick={() => setRegionValidation(region.id, 'rejected')}
|
| 308 |
+
>
|
| 309 |
+
X
|
| 310 |
+
</RetroButton>
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
)
|
| 314 |
+
})
|
| 315 |
+
)}
|
| 316 |
+
</div>
|
| 317 |
+
)}
|
| 318 |
+
|
| 319 |
+
{/* History */}
|
| 320 |
+
{activePanel === 'history' && (
|
| 321 |
+
<div className="flex flex-col gap-[2px]">
|
| 322 |
+
{history.length === 0 ? (
|
| 323 |
+
<p className="text-retro-sm text-retro-darkgray p-2">Aucune version archivee.</p>
|
| 324 |
+
) : (
|
| 325 |
+
history.map((v) => (
|
| 326 |
<div
|
| 327 |
+
key={v.version}
|
| 328 |
+
className="
|
| 329 |
+
flex items-center justify-between
|
| 330 |
+
border border-retro-black p-2
|
| 331 |
+
bg-retro-white
|
| 332 |
+
"
|
| 333 |
>
|
| 334 |
<div>
|
| 335 |
+
<span className="text-retro-sm font-bold">v{v.version}</span>
|
| 336 |
+
<RetroBadge className="ml-2">{v.status}</RetroBadge>
|
| 337 |
+
<div className="text-retro-xs text-retro-darkgray mt-[2px]">
|
| 338 |
+
{new Date(v.saved_at).toLocaleString('fr-FR')}
|
|
|
|
|
|
|
| 339 |
</div>
|
| 340 |
</div>
|
| 341 |
+
<RetroButton
|
| 342 |
+
size="sm"
|
| 343 |
+
onClick={() => void handleRestore(v.version)}
|
| 344 |
+
disabled={saving}
|
| 345 |
+
>
|
| 346 |
+
Restaurer
|
| 347 |
+
</RetroButton>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
</div>
|
| 349 |
+
))
|
| 350 |
+
)}
|
| 351 |
+
</div>
|
| 352 |
+
)}
|
| 353 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
</div>
|
| 355 |
+
</RetroWindow>
|
| 356 |
</div>
|
| 357 |
</div>
|
| 358 |
)
|
frontend/src/pages/Home.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useEffect, useState } from 'react'
|
| 2 |
import AdminNav from '../components/AdminNav.tsx'
|
| 3 |
import SearchBar from '../components/SearchBar.tsx'
|
|
|
|
| 4 |
import {
|
| 5 |
fetchCorpora,
|
| 6 |
fetchManuscripts,
|
|
@@ -8,6 +9,14 @@ import {
|
|
| 8 |
type Manuscript,
|
| 9 |
} from '../lib/api.ts'
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
interface Props {
|
| 12 |
onOpenManuscript: (manuscriptId: string, profileId: string) => void
|
| 13 |
onOpenPage?: (pageId: string) => void
|
|
@@ -20,6 +29,7 @@ export default function Home({ onOpenManuscript, onOpenPage, onAdmin }: Props) {
|
|
| 20 |
const [error, setError] = useState<string | null>(null)
|
| 21 |
const [manuscripts, setManuscripts] = useState<Record<string, Manuscript[]>>({})
|
| 22 |
const [expanding, setExpanding] = useState<string | null>(null)
|
|
|
|
| 23 |
|
| 24 |
useEffect(() => {
|
| 25 |
fetchCorpora()
|
|
@@ -29,7 +39,8 @@ export default function Home({ onOpenManuscript, onOpenPage, onAdmin }: Props) {
|
|
| 29 |
}, [])
|
| 30 |
|
| 31 |
const handleCorpusClick = async (corpus: Corpus) => {
|
| 32 |
-
|
|
|
|
| 33 |
const cached = manuscripts[corpus.id]
|
| 34 |
if (cached) {
|
| 35 |
if (cached.length === 1) onOpenManuscript(cached[0].id, corpus.profile_id)
|
|
@@ -42,95 +53,171 @@ export default function Home({ onOpenManuscript, onOpenPage, onAdmin }: Props) {
|
|
| 42 |
setManuscripts((prev) => ({ ...prev, [corpus.id]: ms }))
|
| 43 |
if (ms.length === 1) onOpenManuscript(ms[0].id, corpus.profile_id)
|
| 44 |
} catch {
|
| 45 |
-
//
|
| 46 |
} finally {
|
| 47 |
setExpanding(null)
|
| 48 |
}
|
| 49 |
}
|
| 50 |
|
|
|
|
| 51 |
if (loading) {
|
| 52 |
return (
|
| 53 |
-
<div className="flex items-center justify-center
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
</div>
|
| 56 |
)
|
| 57 |
}
|
| 58 |
|
|
|
|
| 59 |
if (error) {
|
| 60 |
return (
|
| 61 |
-
<div className="
|
| 62 |
-
Erreur
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
)
|
| 65 |
}
|
| 66 |
|
|
|
|
|
|
|
| 67 |
return (
|
| 68 |
-
<div className="min-h-screen bg-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
</div>
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
</div>
|
| 80 |
-
</
|
| 81 |
-
|
| 82 |
-
<main className="max-w-3xl mx-auto py-10 px-8">
|
| 83 |
-
<h2 className="text-sm font-semibold text-stone-500 uppercase tracking-wide mb-6">
|
| 84 |
-
Corpus disponibles
|
| 85 |
-
</h2>
|
| 86 |
-
|
| 87 |
-
{corpora.length === 0 ? (
|
| 88 |
-
<p className="text-stone-400 text-sm">
|
| 89 |
-
Aucun corpus enregistrΓ©. CrΓ©ez-en un via{' '}
|
| 90 |
-
<code className="bg-stone-200 px-1 rounded text-xs">POST /api/v1/corpora</code>.
|
| 91 |
-
</p>
|
| 92 |
-
) : (
|
| 93 |
-
<ul className="space-y-3">
|
| 94 |
-
{corpora.map((corpus) => (
|
| 95 |
-
<li key={corpus.id}>
|
| 96 |
-
<button
|
| 97 |
-
onClick={() => void handleCorpusClick(corpus)}
|
| 98 |
-
className="w-full text-left bg-white border border-stone-200 rounded-lg px-6 py-4 hover:border-stone-400 hover:shadow-sm transition-all"
|
| 99 |
-
>
|
| 100 |
-
<div className="font-medium text-stone-900">{corpus.title}</div>
|
| 101 |
-
<div className="text-xs text-stone-400 mt-1">
|
| 102 |
-
Profil : {corpus.profile_id} Β· Slug : {corpus.slug}
|
| 103 |
-
</div>
|
| 104 |
-
</button>
|
| 105 |
-
|
| 106 |
-
{expanding === corpus.id && (
|
| 107 |
-
<div className="mt-2 ml-4 text-xs text-stone-400">Chargementβ¦</div>
|
| 108 |
-
)}
|
| 109 |
-
|
| 110 |
-
{manuscripts[corpus.id] && manuscripts[corpus.id].length > 1 && (
|
| 111 |
-
<ul className="mt-2 ml-4 space-y-1">
|
| 112 |
-
{manuscripts[corpus.id].map((ms) => (
|
| 113 |
-
<li key={ms.id}>
|
| 114 |
-
<button
|
| 115 |
-
onClick={() => onOpenManuscript(ms.id, corpus.profile_id)}
|
| 116 |
-
className="text-sm text-stone-600 hover:text-stone-900 hover:underline text-left"
|
| 117 |
-
>
|
| 118 |
-
{ms.title}
|
| 119 |
-
{ms.total_pages > 0 && (
|
| 120 |
-
<span className="text-stone-400 ml-1">
|
| 121 |
-
({ms.total_pages} pages)
|
| 122 |
-
</span>
|
| 123 |
-
)}
|
| 124 |
-
</button>
|
| 125 |
-
</li>
|
| 126 |
-
))}
|
| 127 |
-
</ul>
|
| 128 |
-
)}
|
| 129 |
-
</li>
|
| 130 |
-
))}
|
| 131 |
-
</ul>
|
| 132 |
-
)}
|
| 133 |
-
</main>
|
| 134 |
</div>
|
| 135 |
)
|
| 136 |
}
|
|
|
|
| 1 |
import { useEffect, useState } from 'react'
|
| 2 |
import AdminNav from '../components/AdminNav.tsx'
|
| 3 |
import SearchBar from '../components/SearchBar.tsx'
|
| 4 |
+
import { RetroMenuBar, RetroWindow, RetroIcon } from '../components/retro'
|
| 5 |
import {
|
| 6 |
fetchCorpora,
|
| 7 |
fetchManuscripts,
|
|
|
|
| 9 |
type Manuscript,
|
| 10 |
} from '../lib/api.ts'
|
| 11 |
|
| 12 |
+
/** Map profile IDs to desktop icon glyphs */
|
| 13 |
+
const PROFILE_GLYPHS: Record<string, string> = {
|
| 14 |
+
'medieval-illuminated': '\u{1F4DC}',
|
| 15 |
+
'medieval-textual': '\u{1F4D6}',
|
| 16 |
+
'early-modern-print': '\u{1F5A8}',
|
| 17 |
+
'modern-handwritten': '\u{270D}',
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
interface Props {
|
| 21 |
onOpenManuscript: (manuscriptId: string, profileId: string) => void
|
| 22 |
onOpenPage?: (pageId: string) => void
|
|
|
|
| 29 |
const [error, setError] = useState<string | null>(null)
|
| 30 |
const [manuscripts, setManuscripts] = useState<Record<string, Manuscript[]>>({})
|
| 31 |
const [expanding, setExpanding] = useState<string | null>(null)
|
| 32 |
+
const [selectedCorpus, setSelectedCorpus] = useState<Corpus | null>(null)
|
| 33 |
|
| 34 |
useEffect(() => {
|
| 35 |
fetchCorpora()
|
|
|
|
| 39 |
}, [])
|
| 40 |
|
| 41 |
const handleCorpusClick = async (corpus: Corpus) => {
|
| 42 |
+
setSelectedCorpus(corpus)
|
| 43 |
+
|
| 44 |
const cached = manuscripts[corpus.id]
|
| 45 |
if (cached) {
|
| 46 |
if (cached.length === 1) onOpenManuscript(cached[0].id, corpus.profile_id)
|
|
|
|
| 53 |
setManuscripts((prev) => ({ ...prev, [corpus.id]: ms }))
|
| 54 |
if (ms.length === 1) onOpenManuscript(ms[0].id, corpus.profile_id)
|
| 55 |
} catch {
|
| 56 |
+
// silent
|
| 57 |
} finally {
|
| 58 |
setExpanding(null)
|
| 59 |
}
|
| 60 |
}
|
| 61 |
|
| 62 |
+
// ββ Loading state βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
if (loading) {
|
| 64 |
return (
|
| 65 |
+
<div className="min-h-screen bg-retro-dither flex items-center justify-center">
|
| 66 |
+
<RetroWindow title="IIIF Studio" className="w-72">
|
| 67 |
+
<div className="p-4 text-retro-sm text-center">
|
| 68 |
+
Chargement...
|
| 69 |
+
</div>
|
| 70 |
+
</RetroWindow>
|
| 71 |
</div>
|
| 72 |
)
|
| 73 |
}
|
| 74 |
|
| 75 |
+
// ββ Error state βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
if (error) {
|
| 77 |
return (
|
| 78 |
+
<div className="min-h-screen bg-retro-dither flex items-center justify-center">
|
| 79 |
+
<RetroWindow title="Erreur" className="w-80">
|
| 80 |
+
<div className="p-4 text-retro-sm">
|
| 81 |
+
<p className="font-bold mb-2">Erreur de connexion</p>
|
| 82 |
+
<p className="text-retro-xs">{error}</p>
|
| 83 |
+
</div>
|
| 84 |
+
</RetroWindow>
|
| 85 |
</div>
|
| 86 |
)
|
| 87 |
}
|
| 88 |
|
| 89 |
+
const selectedMs = selectedCorpus ? manuscripts[selectedCorpus.id] : undefined
|
| 90 |
+
|
| 91 |
return (
|
| 92 |
+
<div className="min-h-screen bg-retro-dither flex flex-col">
|
| 93 |
+
{/* ββ Menu bar (top of screen) βββββββββββββββββββββββββββββββ */}
|
| 94 |
+
<RetroMenuBar
|
| 95 |
+
items={[
|
| 96 |
+
{ label: 'IIIF Studio' },
|
| 97 |
+
{ label: 'Fichier' },
|
| 98 |
+
{ label: 'Corpus' },
|
| 99 |
+
{ label: 'Aide' },
|
| 100 |
+
]}
|
| 101 |
+
right={
|
| 102 |
+
<div className="flex items-center gap-1">
|
| 103 |
+
<SearchBar onSelectResult={onOpenPage ? (r) => onOpenPage(r.page_id) : undefined} />
|
| 104 |
+
<AdminNav onClick={onAdmin} />
|
| 105 |
+
</div>
|
| 106 |
+
}
|
| 107 |
+
/>
|
| 108 |
+
|
| 109 |
+
{/* ββ Desktop area βββββββββββββββββββββββββββββββββββββββββββ */}
|
| 110 |
+
<div className="flex-1 flex flex-col">
|
| 111 |
+
|
| 112 |
+
{/* ββ Corpus list window (center) ββββββββββββββββββββββββ */}
|
| 113 |
+
<div className="flex-1 flex items-start justify-center p-6 gap-4">
|
| 114 |
+
<RetroWindow
|
| 115 |
+
title="Corpus disponibles"
|
| 116 |
+
statusBar={`${corpora.length} corpus enregistre${corpora.length > 1 ? 's' : ''}`}
|
| 117 |
+
className="w-full max-w-2xl"
|
| 118 |
+
scrollable
|
| 119 |
+
>
|
| 120 |
+
{corpora.length === 0 ? (
|
| 121 |
+
<div className="p-4 text-retro-sm text-retro-darkgray">
|
| 122 |
+
Aucun corpus enregistre. Creez-en un via Administration.
|
| 123 |
+
</div>
|
| 124 |
+
) : (
|
| 125 |
+
<div className="divide-y divide-retro-gray">
|
| 126 |
+
{corpora.map((corpus) => (
|
| 127 |
+
<div key={corpus.id}>
|
| 128 |
+
<button
|
| 129 |
+
onClick={() => void handleCorpusClick(corpus)}
|
| 130 |
+
className={`
|
| 131 |
+
w-full text-left px-3 py-[6px] flex items-center gap-3
|
| 132 |
+
text-retro-sm font-retro
|
| 133 |
+
${selectedCorpus?.id === corpus.id
|
| 134 |
+
? 'bg-retro-select text-retro-select-text'
|
| 135 |
+
: 'hover:bg-retro-select hover:text-retro-select-text'
|
| 136 |
+
}
|
| 137 |
+
`}
|
| 138 |
+
>
|
| 139 |
+
<span className="text-[18px] leading-none shrink-0">
|
| 140 |
+
{PROFILE_GLYPHS[corpus.profile_id] || '\u{1F4C1}'}
|
| 141 |
+
</span>
|
| 142 |
+
<div className="flex-1 min-w-0">
|
| 143 |
+
<div className="font-bold truncate">{corpus.title}</div>
|
| 144 |
+
<div className={`text-retro-xs truncate ${
|
| 145 |
+
selectedCorpus?.id === corpus.id ? 'opacity-70' : 'text-retro-darkgray'
|
| 146 |
+
}`}>
|
| 147 |
+
{corpus.profile_id} β {corpus.slug}
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</button>
|
| 151 |
+
|
| 152 |
+
{expanding === corpus.id && (
|
| 153 |
+
<div className="px-3 py-1 text-retro-xs text-retro-darkgray bg-retro-light">
|
| 154 |
+
Chargement...
|
| 155 |
+
</div>
|
| 156 |
+
)}
|
| 157 |
+
</div>
|
| 158 |
+
))}
|
| 159 |
+
</div>
|
| 160 |
+
)}
|
| 161 |
+
</RetroWindow>
|
| 162 |
+
|
| 163 |
+
{/* ββ Manuscripts sub-window (appears when a corpus has multiple) ββ */}
|
| 164 |
+
{selectedMs && selectedMs.length > 1 && selectedCorpus && (
|
| 165 |
+
<RetroWindow
|
| 166 |
+
title={`Manuscrits β ${selectedCorpus.title}`}
|
| 167 |
+
onClose={() => setSelectedCorpus(null)}
|
| 168 |
+
statusBar={`${selectedMs.length} manuscrit${selectedMs.length > 1 ? 's' : ''}`}
|
| 169 |
+
className="w-80"
|
| 170 |
+
scrollable
|
| 171 |
+
>
|
| 172 |
+
<div className="divide-y divide-retro-gray">
|
| 173 |
+
{selectedMs.map((ms) => (
|
| 174 |
+
<button
|
| 175 |
+
key={ms.id}
|
| 176 |
+
onClick={() => onOpenManuscript(ms.id, selectedCorpus.profile_id)}
|
| 177 |
+
className="
|
| 178 |
+
w-full text-left px-3 py-[6px]
|
| 179 |
+
text-retro-sm font-retro
|
| 180 |
+
hover:bg-retro-select hover:text-retro-select-text
|
| 181 |
+
"
|
| 182 |
+
>
|
| 183 |
+
<div className="font-bold truncate">{ms.title}</div>
|
| 184 |
+
{ms.total_pages > 0 && (
|
| 185 |
+
<div className="text-retro-xs text-retro-darkgray">
|
| 186 |
+
{ms.total_pages} pages
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
</button>
|
| 190 |
+
))}
|
| 191 |
+
</div>
|
| 192 |
+
</RetroWindow>
|
| 193 |
+
)}
|
| 194 |
</div>
|
| 195 |
+
|
| 196 |
+
{/* ββ Desktop icons (bottom dock) ββββββββββββββββββββββββββ */}
|
| 197 |
+
<div
|
| 198 |
+
className="
|
| 199 |
+
shrink-0 flex items-end justify-center gap-1 px-4 py-3
|
| 200 |
+
border-t-retro border-retro-black
|
| 201 |
+
bg-retro-gray
|
| 202 |
+
shadow-retro-outset
|
| 203 |
+
"
|
| 204 |
+
>
|
| 205 |
+
{corpora.map((corpus) => (
|
| 206 |
+
<RetroIcon
|
| 207 |
+
key={corpus.id}
|
| 208 |
+
glyph={PROFILE_GLYPHS[corpus.profile_id] || '\u{1F4C1}'}
|
| 209 |
+
label={corpus.slug}
|
| 210 |
+
selected={selectedCorpus?.id === corpus.id}
|
| 211 |
+
onClick={() => void handleCorpusClick(corpus)}
|
| 212 |
+
/>
|
| 213 |
+
))}
|
| 214 |
+
{corpora.length === 0 && (
|
| 215 |
+
<div className="text-retro-xs text-retro-darkgray py-2">
|
| 216 |
+
Aucun corpus
|
| 217 |
+
</div>
|
| 218 |
+
)}
|
| 219 |
</div>
|
| 220 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
</div>
|
| 222 |
)
|
| 223 |
}
|
frontend/src/pages/Reader.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import LayerPanel from '../components/LayerPanel.tsx'
|
|
| 15 |
import TranscriptionPanel from '../components/TranscriptionPanel.tsx'
|
| 16 |
import TranslationPanel from '../components/TranslationPanel.tsx'
|
| 17 |
import CommentaryPanel from '../components/CommentaryPanel.tsx'
|
|
|
|
| 18 |
|
| 19 |
interface Props {
|
| 20 |
manuscriptId: string
|
|
@@ -34,7 +35,6 @@ export default function Reader({ manuscriptId, profileId, onBack, onEdit }: Prop
|
|
| 34 |
const [loading, setLoading] = useState(true)
|
| 35 |
const [error, setError] = useState<string | null>(null)
|
| 36 |
|
| 37 |
-
// Chargement initial : liste des pages + profil
|
| 38 |
useEffect(() => {
|
| 39 |
Promise.all([fetchPages(manuscriptId), fetchProfile(profileId)])
|
| 40 |
.then(([pgs, prof]) => {
|
|
@@ -47,7 +47,6 @@ export default function Reader({ manuscriptId, profileId, onBack, onEdit }: Prop
|
|
| 47 |
.finally(() => setLoading(false))
|
| 48 |
}, [manuscriptId, profileId])
|
| 49 |
|
| 50 |
-
// Chargement du master.json Γ chaque changement de page
|
| 51 |
useEffect(() => {
|
| 52 |
if (pages.length === 0) return
|
| 53 |
setMaster(null)
|
|
@@ -68,25 +67,38 @@ export default function Reader({ manuscriptId, profileId, onBack, onEdit }: Prop
|
|
| 68 |
})
|
| 69 |
}, [])
|
| 70 |
|
|
|
|
| 71 |
if (loading) {
|
| 72 |
return (
|
| 73 |
-
<div className="flex items-center justify-center
|
| 74 |
-
Chargement
|
|
|
|
|
|
|
| 75 |
</div>
|
| 76 |
)
|
| 77 |
}
|
| 78 |
|
| 79 |
if (error) {
|
| 80 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
if (pages.length === 0) {
|
| 84 |
return (
|
| 85 |
-
<div className="
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
</div>
|
| 91 |
)
|
| 92 |
}
|
|
@@ -96,106 +108,119 @@ export default function Reader({ manuscriptId, profileId, onBack, onEdit }: Prop
|
|
| 96 |
const regions: Region[] = master?.layout?.regions ?? []
|
| 97 |
|
| 98 |
return (
|
| 99 |
-
<div className="flex flex-col h-screen bg-
|
| 100 |
-
{/* ββ
|
| 101 |
-
<
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
{currentPage.folio_label} β {currentIndex + 1} / {pages.length}
|
| 116 |
-
</span>
|
| 117 |
-
{onEdit && (
|
| 118 |
-
<button
|
| 119 |
-
onClick={() => onEdit(currentPage.id)}
|
| 120 |
-
className="px-3 py-1 bg-amber-600 hover:bg-amber-500 rounded text-sm text-white transition-colors"
|
| 121 |
>
|
| 122 |
-
|
| 123 |
-
</
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
>
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
<
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
</div>
|
| 172 |
-
|
| 173 |
-
)}
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
| 182 |
|
| 183 |
-
{/*
|
| 184 |
-
<
|
| 185 |
-
|
| 186 |
-
|
|
|
|
| 187 |
>
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
|
|
|
|
|
|
| 195 |
|
| 196 |
-
|
| 197 |
{master ? (
|
| 198 |
-
<>
|
| 199 |
<TranscriptionPanel
|
| 200 |
ocr={master.ocr}
|
| 201 |
editorial={master.editorial}
|
|
@@ -212,18 +237,17 @@ export default function Reader({ manuscriptId, profileId, onBack, onEdit }: Prop
|
|
| 212 |
visiblePublic={visibleLayers.has('public_commentary')}
|
| 213 |
visibleScholarly={visibleLayers.has('scholarly_commentary')}
|
| 214 |
/>
|
| 215 |
-
</>
|
| 216 |
) : (
|
| 217 |
-
<div className="p-
|
| 218 |
-
{imageUrl
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
)}
|
| 223 |
</div>
|
| 224 |
)}
|
| 225 |
</div>
|
| 226 |
-
</
|
| 227 |
</div>
|
| 228 |
</div>
|
| 229 |
)
|
|
|
|
| 15 |
import TranscriptionPanel from '../components/TranscriptionPanel.tsx'
|
| 16 |
import TranslationPanel from '../components/TranslationPanel.tsx'
|
| 17 |
import CommentaryPanel from '../components/CommentaryPanel.tsx'
|
| 18 |
+
import { RetroMenuBar, RetroWindow, RetroButton, RetroBadge } from '../components/retro'
|
| 19 |
|
| 20 |
interface Props {
|
| 21 |
manuscriptId: string
|
|
|
|
| 35 |
const [loading, setLoading] = useState(true)
|
| 36 |
const [error, setError] = useState<string | null>(null)
|
| 37 |
|
|
|
|
| 38 |
useEffect(() => {
|
| 39 |
Promise.all([fetchPages(manuscriptId), fetchProfile(profileId)])
|
| 40 |
.then(([pgs, prof]) => {
|
|
|
|
| 47 |
.finally(() => setLoading(false))
|
| 48 |
}, [manuscriptId, profileId])
|
| 49 |
|
|
|
|
| 50 |
useEffect(() => {
|
| 51 |
if (pages.length === 0) return
|
| 52 |
setMaster(null)
|
|
|
|
| 67 |
})
|
| 68 |
}, [])
|
| 69 |
|
| 70 |
+
// ββ Loading βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
if (loading) {
|
| 72 |
return (
|
| 73 |
+
<div className="min-h-screen bg-retro-dither flex items-center justify-center">
|
| 74 |
+
<RetroWindow title="Chargement" className="w-64">
|
| 75 |
+
<div className="p-4 text-retro-sm text-center">Chargement...</div>
|
| 76 |
+
</RetroWindow>
|
| 77 |
</div>
|
| 78 |
)
|
| 79 |
}
|
| 80 |
|
| 81 |
if (error) {
|
| 82 |
+
return (
|
| 83 |
+
<div className="min-h-screen bg-retro-dither flex items-center justify-center">
|
| 84 |
+
<RetroWindow title="Erreur" className="w-80">
|
| 85 |
+
<div className="p-4 text-retro-sm">{error}</div>
|
| 86 |
+
</RetroWindow>
|
| 87 |
+
</div>
|
| 88 |
+
)
|
| 89 |
}
|
| 90 |
|
| 91 |
if (pages.length === 0) {
|
| 92 |
return (
|
| 93 |
+
<div className="min-h-screen bg-retro-dither flex items-center justify-center">
|
| 94 |
+
<RetroWindow title="Manuscrit vide" className="w-80">
|
| 95 |
+
<div className="p-4 text-retro-sm">
|
| 96 |
+
Aucune page dans ce manuscrit.
|
| 97 |
+
<div className="mt-2">
|
| 98 |
+
<RetroButton onClick={onBack}>Retour</RetroButton>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
</RetroWindow>
|
| 102 |
</div>
|
| 103 |
)
|
| 104 |
}
|
|
|
|
| 108 |
const regions: Region[] = master?.layout?.regions ?? []
|
| 109 |
|
| 110 |
return (
|
| 111 |
+
<div className="flex flex-col h-screen bg-retro-dither">
|
| 112 |
+
{/* ββ Menu bar βββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 113 |
+
<RetroMenuBar
|
| 114 |
+
items={[
|
| 115 |
+
{ label: 'IIIF Studio', onClick: onBack },
|
| 116 |
+
{ label: profile?.label ?? profileId },
|
| 117 |
+
]}
|
| 118 |
+
right={
|
| 119 |
+
<div className="flex items-center gap-1">
|
| 120 |
+
<span className="text-retro-xs px-2">
|
| 121 |
+
{currentPage.folio_label} β {currentIndex + 1}/{pages.length}
|
| 122 |
+
</span>
|
| 123 |
+
<RetroButton
|
| 124 |
+
size="sm"
|
| 125 |
+
disabled={currentIndex === 0}
|
| 126 |
+
onClick={() => setCurrentIndex((i) => i - 1)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
>
|
| 128 |
+
Prev
|
| 129 |
+
</RetroButton>
|
| 130 |
+
<RetroButton
|
| 131 |
+
size="sm"
|
| 132 |
+
disabled={currentIndex === pages.length - 1}
|
| 133 |
+
onClick={() => setCurrentIndex((i) => i + 1)}
|
| 134 |
+
>
|
| 135 |
+
Next
|
| 136 |
+
</RetroButton>
|
| 137 |
+
{onEdit && (
|
| 138 |
+
<RetroButton size="sm" onClick={() => onEdit(currentPage.id)}>
|
| 139 |
+
Editer
|
| 140 |
+
</RetroButton>
|
| 141 |
+
)}
|
| 142 |
+
</div>
|
| 143 |
+
}
|
| 144 |
+
/>
|
| 145 |
+
|
| 146 |
+
{/* ββ Main content βββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 147 |
+
<div className="flex flex-1 overflow-hidden p-1 gap-1">
|
| 148 |
+
|
| 149 |
+
{/* ββ Viewer window (left, 70%) ββββββββββββββββββββββββββββ */}
|
| 150 |
+
<RetroWindow
|
| 151 |
+
title={`Folio ${currentPage.folio_label}`}
|
| 152 |
+
statusBar={
|
| 153 |
+
master
|
| 154 |
+
? `${master.editorial.status} β v${master.editorial.version}`
|
| 155 |
+
: imageUrl ? 'Page non analysee' : 'Aucune image'
|
| 156 |
+
}
|
| 157 |
+
className="flex-[7] min-w-0"
|
| 158 |
+
>
|
| 159 |
+
<div className="relative w-full h-full">
|
| 160 |
+
<Viewer imageUrl={imageUrl} onViewerReady={handleViewerReady} />
|
| 161 |
+
<RegionOverlay
|
| 162 |
+
viewer={osdViewer}
|
| 163 |
+
regions={regions}
|
| 164 |
+
onRegionClick={setSelectedRegion}
|
| 165 |
+
/>
|
| 166 |
+
|
| 167 |
+
{/* Region info popup */}
|
| 168 |
+
{selectedRegion && (
|
| 169 |
+
<div
|
| 170 |
+
className="
|
| 171 |
+
absolute bottom-12 left-2
|
| 172 |
+
border-retro border-retro-black
|
| 173 |
+
bg-retro-white shadow-retro
|
| 174 |
+
text-retro-xs p-2 max-w-[220px]
|
| 175 |
+
"
|
| 176 |
+
>
|
| 177 |
+
<div className="flex items-center justify-between gap-2 mb-1">
|
| 178 |
+
<span className="font-bold capitalize">
|
| 179 |
+
{selectedRegion.type.replace(/_/g, ' ')}
|
| 180 |
+
</span>
|
| 181 |
+
<button
|
| 182 |
+
onClick={() => setSelectedRegion(null)}
|
| 183 |
+
className="text-retro-black font-bold hover:bg-retro-black hover:text-retro-white px-1"
|
| 184 |
+
>
|
| 185 |
+
x
|
| 186 |
+
</button>
|
| 187 |
+
</div>
|
| 188 |
+
<div className="space-y-[1px] text-retro-darkgray">
|
| 189 |
+
<div>id: {selectedRegion.id}</div>
|
| 190 |
+
<div>confiance: {(selectedRegion.confidence * 100).toFixed(0)}%</div>
|
| 191 |
+
<div>bbox: [{selectedRegion.bbox.join(', ')}]</div>
|
| 192 |
+
</div>
|
| 193 |
</div>
|
| 194 |
+
)}
|
|
|
|
| 195 |
|
| 196 |
+
{/* Not analyzed badge */}
|
| 197 |
+
{!master && !loading && imageUrl && (
|
| 198 |
+
<div className="absolute top-2 left-2">
|
| 199 |
+
<RetroBadge variant="warning">Non analysee</RetroBadge>
|
| 200 |
+
</div>
|
| 201 |
+
)}
|
| 202 |
+
</div>
|
| 203 |
+
</RetroWindow>
|
| 204 |
|
| 205 |
+
{/* ββ Right panels (30%) βββββββββββββββββββββββββββββββββββ */}
|
| 206 |
+
<RetroWindow
|
| 207 |
+
title="Analyse"
|
| 208 |
+
className="flex-[3] min-w-0"
|
| 209 |
+
scrollable
|
| 210 |
>
|
| 211 |
+
<div className="flex flex-col">
|
| 212 |
+
{/* Layer toggles */}
|
| 213 |
+
{profile && (
|
| 214 |
+
<LayerPanel
|
| 215 |
+
activeLayers={profile.active_layers}
|
| 216 |
+
visibleLayers={visibleLayers}
|
| 217 |
+
onToggle={toggleLayer}
|
| 218 |
+
/>
|
| 219 |
+
)}
|
| 220 |
|
| 221 |
+
{/* Content panels */}
|
| 222 |
{master ? (
|
| 223 |
+
<div className="divide-y divide-retro-gray">
|
| 224 |
<TranscriptionPanel
|
| 225 |
ocr={master.ocr}
|
| 226 |
editorial={master.editorial}
|
|
|
|
| 237 |
visiblePublic={visibleLayers.has('public_commentary')}
|
| 238 |
visibleScholarly={visibleLayers.has('scholarly_commentary')}
|
| 239 |
/>
|
| 240 |
+
</div>
|
| 241 |
) : (
|
| 242 |
+
<div className="p-3 text-retro-sm text-retro-darkgray">
|
| 243 |
+
{imageUrl
|
| 244 |
+
? 'Page non encore analysee par l\'IA.'
|
| 245 |
+
: 'Aucune image associee a cette page.'
|
| 246 |
+
}
|
|
|
|
| 247 |
</div>
|
| 248 |
)}
|
| 249 |
</div>
|
| 250 |
+
</RetroWindow>
|
| 251 |
</div>
|
| 252 |
</div>
|
| 253 |
)
|
frontend/tailwind.config.js
CHANGED
|
@@ -2,7 +2,53 @@
|
|
| 2 |
export default {
|
| 3 |
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
| 4 |
theme: {
|
| 5 |
-
extend: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
},
|
| 7 |
plugins: [],
|
| 8 |
}
|
|
|
|
| 2 |
export default {
|
| 3 |
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
| 4 |
theme: {
|
| 5 |
+
extend: {
|
| 6 |
+
/* ββ Retro palette βββββββββββββββββββββββββββββββββββββββββββ */
|
| 7 |
+
colors: {
|
| 8 |
+
retro: {
|
| 9 |
+
white: '#ffffff',
|
| 10 |
+
light: '#dfdfdf',
|
| 11 |
+
gray: '#c0c0c0',
|
| 12 |
+
darkgray: '#808080',
|
| 13 |
+
black: '#000000',
|
| 14 |
+
/* Accent for selected / active items */
|
| 15 |
+
select: '#000080',
|
| 16 |
+
'select-text': '#ffffff',
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
|
| 20 |
+
/* ββ Retro fonts βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 21 |
+
fontFamily: {
|
| 22 |
+
retro: ['"IBM Plex Mono"', '"Courier New"', 'Courier', 'monospace'],
|
| 23 |
+
mono: ['"IBM Plex Mono"', '"Courier New"', 'Courier', 'monospace'],
|
| 24 |
+
},
|
| 25 |
+
|
| 26 |
+
/* ββ Hard drop shadows (no blur) βββββββββββββββββββββββββββββ */
|
| 27 |
+
boxShadow: {
|
| 28 |
+
'retro': '2px 2px 0px 0px #000000',
|
| 29 |
+
'retro-lg': '3px 3px 0px 0px #000000',
|
| 30 |
+
/* Outset bevel (button at rest) */
|
| 31 |
+
'retro-outset': 'inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff',
|
| 32 |
+
/* Inset bevel (button pressed) */
|
| 33 |
+
'retro-inset': 'inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff',
|
| 34 |
+
/* Window inner area */
|
| 35 |
+
'retro-well': 'inset 1px 1px 0 #808080, inset -1px -1px 0 #dfdfdf',
|
| 36 |
+
},
|
| 37 |
+
|
| 38 |
+
/* ββ Spacing / sizing tokens βββββββββββββββββββββββββββββββββ */
|
| 39 |
+
borderWidth: {
|
| 40 |
+
'retro': '2px',
|
| 41 |
+
},
|
| 42 |
+
|
| 43 |
+
fontSize: {
|
| 44 |
+
'retro-xs': ['11px', { lineHeight: '16px' }],
|
| 45 |
+
'retro-sm': ['12px', { lineHeight: '18px' }],
|
| 46 |
+
'retro-base': ['13px', { lineHeight: '20px' }],
|
| 47 |
+
'retro-lg': ['15px', { lineHeight: '22px' }],
|
| 48 |
+
'retro-xl': ['18px', { lineHeight: '26px' }],
|
| 49 |
+
'retro-2xl': ['22px', { lineHeight: '30px' }],
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
},
|
| 53 |
plugins: [],
|
| 54 |
}
|