Spaces:
Build error
feat(frontend): Sprint R5 — retro Admin panel
Browse filesRedesign the Admin page (888 lines) with retro-computing aesthetic:
- RetroMenuBar replaces dark header
- Sidebar as RetroWindow with navy-blue selection highlighting
- CreateCorpusPanel in a RetroWindow with RetroInput/RetroSelect
- ModelPanel with RetroButton provider selector + RetroSelect models
- IngestPanel with RetroButton sub-tabs (URLs/Manifest/Files)
and RetroTextarea/RetroInput forms
- RunPanel with RetroBadge job status indicators, retro scrollable
job list, RetroButton controls
- CorpusDetail with RetroWindow sections for each panel
- ErrorMsg/SuccessMsg restyled with retro borders
- All forms use retro primitives throughout
CSS bundle dropped from 29KB to 15KB (retro theme is more compact).
Build passes (tsc + vite).
https://claude.ai/code/session_01WWohTtw2CxGRawmpH1tyrY
- frontend/src/pages/Admin.tsx +233 -482
|
@@ -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 |
)
|