Files
Copies/web/src/main.tsx
T

103 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, {useEffect, useState} from 'react';
import {createRoot} from 'react-dom/client';
import './style.css';
type Answer = {label:string;score:string;remark:string;image:boolean;editableBody:boolean;info:{present?:boolean;not_empty?:boolean;touched?:boolean}};
type Copy = {id:string;name:string;finalScore?:string|null;general:string[];answers:Answer[]};
type Job = {running:boolean;status:string;log:string};
type State = {evaluation:string;mode:string;labels:string[];copies:Copy[];expectedNames:string[]|null;draft:string;applied:string;revision:string;dirty:boolean;job:Job};
type Edit = {kind:string;copy:string;label:string;value:string|string[]};
async function api(path:string, data?:unknown) {
const response=await fetch('/api/'+path, data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
if(!response.ok){let message;try{const payload=await response.json();message=typeof payload.detail==='string'?payload.detail:JSON.stringify(payload.detail)}catch{message=await response.text().catch(()=>response.statusText)}throw new Error(message||'Le serveur ne répond pas.');}
return response.json();
}
const imageUrl=(copy:string,label:string,body=false,revision='')=>'/api/image?'+new URLSearchParams({copy,label,body:String(body),v:revision});
const documentUrl=(copy:string,kind:string,label='')=>'/api/document?'+new URLSearchParams({copy,kind,label});
const keyOf=(kind:string,copy='',label='')=>JSON.stringify([kind,copy,label]);
const searchName=(text:string)=>text.normalize('NFD').replace(/\p{M}/gu,'').toLocaleLowerCase('fr').trim();
const copyName=(copy:Copy)=>`${copy.name} · copie ${copy.id}`;
function App(){
const [state,setState]=useState<State|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState('');
const [copyFilter,setCopyFilter]=useState(''),[labelFilter,setLabelFilter]=useState(''),[limit,setLimit]=useState(20);
const [edits,setEdits]=useState<Record<string,Edit>>({}),[busy,setBusy]=useState(false),[applyDraft,setApplyDraft]=useState(false);
const [cut,setCut]=useState<{copy:string;label:string}|null>(null),[zoom,setZoom]=useState<{copy:string;label:string}|null>(null);
const [stopped,setStopped]=useState(false);
const [onlyNameIssues,setOnlyNameIssues]=useState(false);
const [nameQuery,setNameQuery]=useState('');
const [incremental,setIncremental]=useState(true);
const refresh=async()=>{try{setState(await api('state'));setError('')}catch(e){setError(String(e))}};
useEffect(()=>{void refresh()},[]);
useEffect(()=>{const protect=(e:BeforeUnloadEvent)=>{if(Object.keys(edits).length){e.preventDefault();e.returnValue=''}};window.addEventListener('beforeunload',protect);return()=>window.removeEventListener('beforeunload',protect)},[edits]);
useEffect(()=>{if(!state?.job.running)return;const timer=setInterval(async()=>{try{const job:Job=await api('job');setState(s=>s?{...s,job}:s);if(!job.running){await refresh();setNotice(job.status==='success'?'Les sorties ont été régénérées.':'La régénération est incomplète. Consultez le journal.')}}catch(e){setError(String(e))}},1000);return()=>clearInterval(timer)},[state?.job.running]);
const value=(kind:string,copy:string,label:string,fallback:string|string[])=>edits[keyOf(kind,copy,label)]?.value??fallback;
const edit=(kind:string,copy:string,label:string,v:string|string[])=>setEdits(old=>({...old,[keyOf(kind,copy,label)]:{kind,copy,label,value:v}}));
const save=async(keys:string[])=>{if(!state)return;setBusy(true);setError('');let latest=state;try{for(const key of keys){const change=edits[key];if(!change)continue;latest=await api('change',{...change,revision:latest.revision});setState(latest);setEdits(old=>{const next={...old};delete next[key];return next})}setNotice('Modifications enregistrées.')}catch(e){setError(String(e))}finally{setBusy(false)}};
const regenerate=async()=>{if(!state)return;setBusy(true);setError('');try{const job=await api('regenerate',{revision:state.revision,apply_draft:applyDraft,incremental});setState({...state,job});setNotice('')}catch(e){setError(String(e))}finally{setBusy(false)}};
const stop=async()=>{try{await api('stop',{});setStopped(true)}catch(e){setError(String(e))}};
if(stopped)return <main className="end"><h1>Session terminée</h1><p>Vous pouvez reprendre le travail dans Copienator.</p></main>;
if(!state)return <main className="end"><h1>Copienator</h1><p>{error||'Chargement de la finalisation…'}</p><button onClick={refresh}>Réessayer</button></main>;
const disabled=busy||state.job.running;
const pending=Object.keys(edits).length;
const draft=value('draft','','',state.draft) as string;
const draftDiff=state.draft.trim()!==state.applied.trim();
const selectedCopy=copyFilter?state.copies.find(c=>Number(c.id)===Number(copyFilter)):undefined;
const nameChoices=[...state.copies].sort((a,b)=>a.name.localeCompare(b.name,'fr')||Number(a.id)-Number(b.id));
const selectName=(text:string)=>{
setLimit(20);
const selection=state.copies.find(c=>copyName(c)===text);
const exact=state.copies.filter(c=>searchName(c.name)===searchName(text));
const copy=selection||(exact.length===1?exact[0]:undefined);
setCopyFilter(copy?.id||'');
setNameQuery(copy?'':text);
};
const matches=state.copies.filter(c=>(!copyFilter||Number(c.id)===Number(copyFilter))
&&(!nameQuery||searchName(c.name).includes(searchName(nameQuery))));
const cards=matches.flatMap(c=>c.answers.filter(a=>!labelFilter||a.label===labelFilter).map(a=>({c,a})));
const names=new Map<string,number>();state.copies.forEach(c=>names.set(c.name.toLowerCase(),(names.get(c.name.toLowerCase())||0)+1));
const issues=state.copies.filter(c=>c.name.toLowerCase()==='unknown'||(names.get(c.name.toLowerCase())||0)>1);
const identityMatches=matches.filter(c=>!onlyNameIssues||issues.includes(c));
const assignedNames=new Set(state.copies.map(c=>c.name.trim().toLocaleLowerCase('fr')));
const missingNames=state.expectedNames?.filter(name=>!assignedNames.has(name.trim().toLocaleLowerCase('fr')));
return <>
<header inert={!!cut||!!zoom}><div className="brand">C<span>Copienator</span></div><div className="heading"><div className="eyebrow">FINALISATION · {state.mode}</div><h1>{state.evaluation}</h1></div><button className="quiet" disabled={disabled||!!pending} onClick={stop}>Fermer et reprendre dans le GUI</button></header>
<main inert={!!cut||!!zoom}>
<section className="toolbar"><div><h2>Vérifier les copies</h2><p>Retrouvez une copie, une question, ou leur intersection.</p></div><div className="filters"><label>Copie<input type="text" inputMode="numeric" list="copies" placeholder="Toutes les copies" value={copyFilter} onChange={e=>{setCopyFilter(e.target.value.replace(/\D/g,''));setNameQuery('');setLimit(20)}}/></label><datalist id="copies">{state.copies.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}</datalist><label>Nom<input type="text" list="copy-names" placeholder="Rechercher un nom…" autoComplete="off" value={selectedCopy?copyName(selectedCopy):nameQuery} onChange={e=>selectName(e.target.value)}/></label><datalist id="copy-names">{nameChoices.filter(c=>!nameQuery||searchName(c.name).includes(searchName(nameQuery))).map(c=><option key={c.id} value={copyName(c)}/>)}</datalist><label>Label<select value={labelFilter} onChange={e=>{setLabelFilter(e.target.value);setLimit(20)}}><option value="">Tous les labels</option>{state.labels.map(l=><option key={l}>{l}</option>)}</select></label><button onClick={()=>{setCopyFilter('');setNameQuery('');setLabelFilter('');setLimit(20)}}>Réinitialiser</button></div></section>
{matches.length===1&&(copyFilter||nameQuery)&&matches[0].finalScore!=null&&<div className="final-score" role="status"><span>Copie {matches[0].id} · {matches[0].name}</span><strong>Score final exporté : {matches[0].finalScore.replace('.', ',')}</strong></div>}
{error&&<div role="alert" className="alert">{error}<button onClick={refresh}>Recharger les données</button><small>Les saisies non enregistrées restent dans la page.</small></div>}
{notice&&<p role="status" className="notice">{notice}</p>}
<section className="publish"><div><strong>{state.job.running?'Régénération en cours…':pending?`${pending} modification(s) non enregistrée(s)`:state.dirty?'Enregistré · sorties à régénérer':'Modifications enregistrées'}</strong><small>Les sorties comprennent les images, PDF et le dossier A Rendre configurés.</small></div><button disabled={disabled||!pending} onClick={()=>save(Object.keys(edits))}>Tout enregistrer</button>{pending>0&&<button disabled={disabled} onClick={()=>setEdits({})}>Annuler les saisies</button>}<label className="check"><input type="checkbox" checked={applyDraft} disabled={disabled||state.mode==='Anot'} onChange={e=>setApplyDraft(e.target.checked)}/>Appliquer aussi le brouillon de transferts{draftDiff?' (différent du résultat actuel)':''}</label><label className="check"><input type="checkbox" checked={incremental} disabled={disabled} onChange={e=>setIncremental(e.target.checked)}/>Ignorer les copies inchangées</label><button className="primary" disabled={disabled||!!pending} onClick={regenerate}>Régénérer les sorties</button></section>
<fieldset disabled={disabled}>
<details className="panel"><summary>Identités et remarques générales <span>{issues.length} nom(s) à vérifier</span></summary><div className="identity-overview"><strong>Noms manquants{missingNames?` (${missingNames.length})`:''}</strong>{missingNames===undefined?<p>Liste des noms attendus introuvable (fichier names).</p>:missingNames.length?<ul className="missing-names">{missingNames.map(name=><li key={name}>{name}</li>)}</ul>:<p>Tous les noms attendus sont attribués.</p>}<label className="check"><input type="checkbox" checked={onlyNameIssues} onChange={e=>setOnlyNameIssues(e.target.checked)}/>Afficher uniquement les copies « Nom à vérifier »</label></div><p>Les remarques sont composées en LaTeX. Leur ordre est celui de la liste.</p>{identityMatches.length===0&&<p>Aucune copie ne correspond aux filtres sélectionnés.</p>}{identityMatches.map(c=>{const general=value('general',c.id,'',c.general) as string[];const changeGeneral=(next:string[])=>edit('general',c.id,'',next);return <section className="identity" key={c.id}><div className="identity-name"><strong>Copie {c.id}</strong><label>Nom<input value={value('name',c.id,'',c.name) as string} onChange={e=>edit('name',c.id,'',e.target.value)}/></label>{issues.includes(c)&&<span className="badge warning">Nom à vérifier</span>}<a target="_blank" rel="noreferrer" href={documentUrl(c.id,'original')}>Voir l’originale ↗</a><button disabled={!edits[keyOf('name',c.id)]} onClick={()=>save([keyOf('name',c.id)])}>Enregistrer le nom</button></div><div className="general">{general.map((text,i)=><div className="remark-row" key={i}><textarea aria-label={`Remarque générale ${i+1}, copie ${c.id}`} value={text} onChange={e=>changeGeneral(general.map((v,j)=>i===j?e.target.value:v))}/><button disabled={i===0} aria-label="Monter" onClick={()=>{const a=[...general];[a[i-1],a[i]]=[a[i],a[i-1]];changeGeneral(a)}}>↑</button><button disabled={i===general.length-1} aria-label="Descendre" onClick={()=>{const a=[...general];[a[i+1],a[i]]=[a[i],a[i+1]];changeGeneral(a)}}>↓</button><button onClick={()=>changeGeneral(general.filter((_,j)=>j!==i))}>Supprimer</button></div>)}<button onClick={()=>changeGeneral([...general,''])}>+ Remarque générale</button><button disabled={!edits[keyOf('general',c.id)]} onClick={()=>save([keyOf('general',c.id)])}>Enregistrer les remarques</button></div></section>})}</details>
{state.mode!=='Anot'&&<details className="panel"><summary>Transferts et coupes <span>{draftDiff?'Brouillon non appliqué':'Brouillon identique aux modifications appliquées'}</span></summary><p>Les en-têtes et scores restent à leur label. Enregistrer un brouillon ne l’applique pas. Pour annuler un transfert, retirez sa ligne puis régénérez en cochant « Appliquer aussi le brouillon ».</p><div className="draft-list">{draft.split('\n').map((line,index,lines)=>line.trim()&&!line.trim().startsWith('###')?<div className="draft-row" key={index}><code>{line}</code><button aria-label={`Monter l’instruction ${index}`} disabled={index===0||lines[index-1].startsWith('###')} onClick={()=>{const next=[...lines];[next[index-1],next[index]]=[next[index],next[index-1]];edit('draft','','',next.join('\n'))}}>↑</button><button aria-label={`Descendre l’instruction ${index}`} disabled={index===lines.length-1||!lines[index+1].trim()} onClick={()=>{const next=[...lines];[next[index+1],next[index]]=[next[index],next[index+1]];edit('draft','','',next.join('\n'))}}>↓</button><button onClick={()=>edit('draft','','',lines.filter((_,i)=>i!==index).join('\n'))}>Retirer</button></div>:null)}</div><details><summary>Éditer les instructions en texte</summary><textarea className="instructions" aria-label="Brouillon des transferts" value={draft} onChange={e=>edit('draft','','',e.target.value)}/></details><button disabled={!edits[keyOf('draft')]} onClick={()=>save([keyOf('draft')])}>Enregistrer le brouillon</button><details><summary>Instructions actuellement appliquées</summary><pre>{state.applied}</pre></details></details>}
<div className="results"><h2>{cards.length} réponse{cards.length!==1?'s':''}</h2><span>{!copyFilter&&!labelFilter?'Vue d’ensemble · sélectionnez une copie ou un label pour cibler la vérification':'Images annotées après lecture manuscrite'}</span></div>
<div className="answer-grid">{cards.slice(0,limit).map(({c,a})=><article className="answer" key={c.id+'|'+a.label}><div className="card-heading"><div><span className="eyebrow">COPIE {c.id} · {c.name}</span><h3>{a.label}</h3></div><span className="badge">{!a.info.present?'Absente':!a.info.not_empty?'Réponse vide':a.info.touched?'Dans le PDF':'Hors PDF filtré'}</span></div><div className="image-space">{a.image?<button className="image-button" aria-label={`Agrandir copie ${c.id} ${a.label}`} onClick={()=>setZoom({copy:c.id,label:a.label})}><img loading="lazy" src={imageUrl(c.id,a.label,false,state.revision)} alt={`Réponse annotée — copie ${c.id}, ${a.label}`}/><span>Agrandir ↗</span></button>:<p className="empty">Aucune image pour ce label.</p>}</div><div className="answer-form"><label>Score<input inputMode="decimal" value={value('score',c.id,a.label,a.score) as string} onChange={e=>edit('score',c.id,a.label,e.target.value)}/></label><label>Remarque personnelle<textarea placeholder="Ajouter une remarque (LaTeX accepté)" value={value('remark',c.id,a.label,a.remark) as string} onChange={e=>edit('remark',c.id,a.label,e.target.value)}/></label></div><div className="card-actions"><button disabled={!edits[keyOf('score',c.id,a.label)]&&!edits[keyOf('remark',c.id,a.label)]} onClick={()=>save([keyOf('score',c.id,a.label),keyOf('remark',c.id,a.label)])}>Enregistrer</button><button disabled={!a.editableBody} onClick={()=>setCut({copy:c.id,label:a.label})}>Couper / transférer</button><a href={documentUrl(c.id,'context',a.label)} target="_blank" rel="noreferrer">Contexte ↗</a><a href={documentUrl(c.id,'question',a.label)} target="_blank" rel="noreferrer">Énoncé ↗</a><a href={documentUrl(c.id,'solution',a.label)} target="_blank" rel="noreferrer">Corrigé ↗</a><a href={documentUrl(c.id,'output')} target="_blank" rel="noreferrer">PDF final ↗</a></div></article>)}</div>
{cards.length===0&&<p className="empty">Aucune réponse ne correspond à ces filtres.</p>}{cards.length>limit&&<button className="load-more" onClick={()=>setLimit(limit+20)}>Afficher les 20 suivantes</button>}
</fieldset>
{(state.job.log||state.job.running)&&<details className="panel" open={state.job.status==='failed'}><summary>Journal de régénération · {state.job.status==='failed'?'Échec / résultat incomplet':state.job.running?'En cours':'Terminée'}</summary><pre className="log">{state.job.log}</pre></details>}
</main>
{cut&&<CutEditor copy={cut.copy} source={cut.label} labels={state.labels} mode={state.mode} revision={state.revision} draft={draft} close={()=>setCut(null)} accept={text=>{edit('draft','','',text);setCut(null);setNotice('Instruction ajoutée au brouillon. Enregistrez-le avant de l’appliquer.')}}/>}
{zoom&&<Viewer copy={zoom.copy} label={zoom.label} revision={state.revision} close={()=>setZoom(null)}/>}
</>;
}
function Viewer({copy,label,revision,close}:{copy:string;label:string;revision:string;close:()=>void}){
const [scale,setScale]=useState(100);
useEffect(()=>{const f=(e:KeyboardEvent)=>{if(e.key==='Escape')close()};window.addEventListener('keydown',f);return()=>window.removeEventListener('keydown',f)},[]);
return <div className="overlay"><section className="modal viewer" role="dialog" aria-modal="true" aria-label={`Copie ${copy} ${label}`}><div className="modal-head"><h2>Copie {copy} · {label}</h2><label>Zoom <input type="range" min="40" max="200" value={scale} onChange={e=>setScale(Number(e.target.value))}/>{scale}%</label><button autoFocus onClick={close}>Fermer</button></div><div className="zoom-scroll"><img src={imageUrl(copy,label,false,revision)} alt={`Copie ${copy}, ${label}`} style={{width:scale+'%',maxWidth:'none'}}/></div></section></div>;
}
function CutEditor({copy,source,labels,mode,revision,draft,close,accept}:{copy:string;source:string;labels:string[];mode:string;revision:string;draft:string;close:()=>void;accept:(text:string)=>void}){
const [target,setTarget]=useState(labels.find(l=>l!==source)||''),[operation,setOperation]=useState('cut'),[percent,setPercent]=useState(50),[keep,setKeep]=useState('1'),[merge,setMerge]=useState('>'),[before,setBefore]=useState(false),[error,setError]=useState(''),[preview,setPreview]=useState<Record<string,string|null>|null>(null),[working,setWorking]=useState(false);
const operator=operation==='cut'?`c{${percent}}${keep}${merge}`:operation;
const line=`Copie${copy} ${source} ${operator} ${before?'|':''}${target}${before?'':'|'}`;
const candidate=draft.trimEnd()+'\n'+line+'\n';
useEffect(()=>setPreview(null),[target,operation,percent,keep,merge,before,draft]);
const check=async()=>{setWorking(true);setError('');try{setPreview(await api('preview',{copy,source,target,text:candidate}))}catch(e){setError(String(e))}finally{setWorking(false)}};
return <div className="overlay"><section className="modal" role="dialog" aria-modal="true" aria-label="Couper ou transférer une réponse"><div className="modal-head"><div><span className="eyebrow">COPIE {copy} · {mode}</span><h2>Couper ou transférer</h2></div><button autoFocus onClick={close}>Fermer</button></div><p>Seul le corps de la réponse est déplacé. Cliquez sur l’image source pour placer la coupe.</p>{error&&<p role="alert" className="alert">{error}</p>}<div className="cut-controls"><label>Destination<select value={target} onChange={e=>setTarget(e.target.value)}>{labels.filter(l=>l!==source).map(l=><option key={l}>{l}</option>)}</select></label><label>Opération<select value={operation} onChange={e=>setOperation(e.target.value)}><option value="cut">Couper la source</option><option value="x>">Déplacer et ajouter</option><option value="->">Copier et ajouter</option><option value="xx">Déplacer et remplacer</option><option value="-x">Copier et remplacer</option><option value="xs">Vider la source</option><option value="sx">Vider la cible</option><option value="ss">Conserver les deux</option></select></label>{operation==='cut'&&<><label>Coupe : {percent}%<input type="range" min="1" max="99" step="0.1" value={percent} onChange={e=>setPercent(Number(e.target.value))}/></label><label>Conserver à la source<select value={keep} onChange={e=>setKeep(e.target.value)}><option value="1">La partie haute</option><option value="2">La partie basse</option></select></label><label>À la destination<select value={merge} onChange={e=>setMerge(e.target.value)}><option value=">">Ajouter le fragment</option><option value="x">Remplacer le corps</option></select></label></>}<label className="check"><input type="checkbox" checked={before} onChange={e=>setBefore(e.target.checked)}/>Placer avant la cible</label></div><div className="compare"><div><h3>Source · {source}</h3><div className="cut-image" onClick={e=>{if(operation==='cut'){const r=e.currentTarget.getBoundingClientRect();setPercent(Math.min(99,Math.max(1,Math.round((e.clientY-r.top)/r.height*1000)/10)))}}}><img src={imageUrl(copy,source,true,revision)} alt="Corps source sans en-tête"/>{operation==='cut'&&<div className="cut-line" style={{top:percent+'%'}}><span>{percent}%</span></div>}</div></div><div><h3>Cible · {target}</h3><BodyImage key={target} copy={copy} label={target} revision={revision}/></div></div><code className="instruction-line">{line}</code><div className="modal-actions"><button disabled={working||!target} onClick={check}>{working?'Calcul de l’aperçu…':'Aperçu du résultat'}</button><button className="primary" disabled={!preview||working} onClick={()=>accept(candidate)}>Ajouter au brouillon</button></div>{preview&&<><h3>Résultat proposé après toutes les instructions du brouillon</h3><div className="compare">{[source,target].map(label=><div key={label}><h4>{label}</h4>{preview[label]?<img src={preview[label]!} alt={'Aperçu '+label}/>:<p className="empty">Corps vide</p>}</div>)}</div></>}<p className="muted">Une instruction par source ; une cible ne peut pas être source. Modifiez ou retirez une instruction existante dans le panneau « Transferts et coupes ».</p></section></div>;
}
function BodyImage({copy,label,revision}:{copy:string;label:string;revision:string}){const [missing,setMissing]=useState(false);return missing?<p className="empty">Pas de corps existant. Le transfert peut remplir cette cible.</p>:<img src={imageUrl(copy,label,true,revision)} alt="Corps cible sans en-tête" onError={()=>setMissing(true)}/>}
createRoot(document.getElementById('root')!).render(<App/>);