Various HTML improvements
This commit is contained in:
1 parent
fd943696fb
commit
8c1fa0af57
16 files changed
+403
-45
No files matched your search
+9
-4
@@ -5,7 +5,7 @@ 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;general:string[];answers:Answer[]};
|
||||
type Job = {running:boolean;status:string;log:string};
|
||||
type State = {evaluation:string;mode:string;labels:string[];copies:Copy[];draft:string;applied:string;revision:string;dirty:boolean;job:Job};
|
||||
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)});
|
||||
@@ -22,6 +22,8 @@ function App(){
|
||||
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 [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]);
|
||||
@@ -29,7 +31,7 @@ function App(){
|
||||
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});setState({...state,job});setNotice('')}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>;
|
||||
@@ -41,15 +43,18 @@ function App(){
|
||||
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,''));setLimit(20)}}/></label><datalist id="copies">{state.copies.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}</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('');setLabelFilter('');setLimit(20)}}>Réinitialiser</button></div></section>
|
||||
{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} onChange={e=>setApplyDraft(e.target.checked)}/>Appliquer aussi le brouillon de transferts{draftDiff?' (différent du résultat actuel)':''}</label><button className="primary" disabled={disabled||!!pending} onClick={regenerate}>Régénérer les sorties</button></section>
|
||||
<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} 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><p>Les remarques sont composées en LaTeX. Leur ordre est celui de la liste.</p>{matches.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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#23323a;background:#f3f5f4;font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline-offset:3px}button{border:1px solid #c8d3ce;border-radius:7px;background:white;color:#244b40;padding:9px 13px;cursor:pointer;font-weight:550}button:hover:not(:disabled){background:#e9f1ed;border-color:#769b88}button:disabled{opacity:.45;cursor:default}.primary{background:#205c48;color:white;border-color:#205c48}.primary:hover:not(:disabled){background:#174633;color:white}a{color:#21654e;text-decoration:none}a:hover{text-decoration:underline}header{display:flex;align-items:center;gap:32px;padding:20px 36px;border-bottom:1px solid #dbe3de;background:white}.brand{display:flex;align-items:center;gap:10px;font-size:23px;font-weight:800;color:#28684f}.brand span{font-size:16px;font-weight:650}.heading{flex:1;border-left:1px solid #dbe3de;padding-left:30px}h1{font-size:22px;margin:3px 0 0}h2{font-size:20px;margin:0 0 8px}h3{font-size:19px;margin:7px 0}p{line-height:1.55;margin:8px 0;color:#56666b}.eyebrow{font-size:11px;letter-spacing:.09em;color:#5c7470;font-weight:650}main{max-width:1540px;margin:auto;padding:30px 36px 60px}.toolbar{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:24px}.filters{display:flex;gap:12px;align-items:end}label{display:flex;flex-direction:column;gap:6px;color:#405650;font-size:12px;font-weight:600}input,select,textarea{border:1px solid #c5d1cb;border-radius:6px;background:white;padding:9px 10px;color:#263d34;font-size:14px;min-width:0}textarea{resize:vertical;min-height:76px;line-height:1.5}input:focus,textarea:focus,select:focus{outline:2px solid #80b69c;border-color:#4d8269}input[type=checkbox]{width:16px;height:16px;accent-color:#205c48}.check{display:flex;flex-direction:row;align-items:center;max-width:310px;font-size:12px;line-height:1.4}.publish{display:flex;align-items:center;gap:16px;background:#e8f0eb;border:1px solid #d0e0d5;border-radius:10px;padding:17px 20px;margin:20px 0}.publish>div{flex:1}.publish small{display:block;margin-top:5px;color:#5b6e64}.panel{padding:18px 20px;background:white;border:1px solid #d9e2dd;border-radius:9px;margin-bottom:14px}summary{cursor:pointer;font-weight:600}summary span{float:right;font-size:12px;font-weight:400;color:#65766f}.identity{border-top:1px solid #e1e7e3;padding:18px 0;display:grid;grid-template-columns:280px 1fr;gap:28px}.identity-name{display:flex;flex-direction:column;gap:10px;align-items:start}.identity-name label{width:100%}.general button{margin:5px 6px 0 0}.remark-row{display:flex;align-items:center;gap:6px;margin-bottom:8px}.remark-row textarea{flex:1}.instructions{width:100%;font-family:ui-monospace,monospace;min-height:140px;margin:10px 0}.panel details{margin-top:16px}.results{display:flex;justify-content:space-between;align-items:center;margin:30px 0 12px}.results span{color:#677872;font-size:12px}.answer-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px}.answer{border:1px solid #d9e1dd;background:white;border-radius:10px;overflow:hidden}.card-heading{display:flex;justify-content:space-between;align-items:center;padding:18px 20px;gap:12px}.badge{font-size:11px;background:#edf1ee;color:#5a6e62;border-radius:4px;padding:5px 7px;white-space:nowrap}.warning{background:#fff2d8;color:#8a620b}.image-space{height:290px;overflow:hidden;background:#e7ece9;border-top:1px solid #e2e8e3;border-bottom:1px solid #e2e8e3;display:flex;justify-content:center;align-items:start;padding:12px}.image-button{position:relative;border:0;padding:0;background:white;width:100%;height:100%;overflow:hidden;border-radius:0}.image-button img{width:100%;display:block}.image-button span{position:absolute;bottom:8px;right:8px;background:#203d32e8;color:white;border-radius:5px;padding:7px 10px;font-size:12px}.answer-form{display:grid;grid-template-columns:88px 1fr;gap:15px;padding:18px 20px 10px}.card-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:8px 20px 20px}.card-actions a{font-size:12px}.empty{padding:32px;color:#77877e;font-style:italic;text-align:center}.load-more{display:block;margin:24px auto}.alert{background:#fff0eb;color:#8f3827;border:1px solid #e9b6a9;border-radius:7px;padding:12px;display:flex;gap:12px;align-items:center}.alert small{font-size:12px}.notice{color:#216044}.muted{font-size:12px}fieldset{padding:0;margin:0;border:0;min-width:0}.overlay{position:fixed;inset:0;background:#142d23b8;z-index:10;display:flex;align-items:center;justify-content:center;padding:24px}.modal{width:min(1300px,100%);max-height:94vh;overflow:auto;border-radius:12px;background:#f8faf8;padding:24px;box-shadow:0 24px 80px #001c2e55}.modal-head{display:flex;justify-content:space-between;align-items:center;gap:20px}.cut-controls{display:flex;gap:15px;flex-wrap:wrap;margin:20px 0}.compare{display:grid;grid-template-columns:1fr 1fr;gap:24px}.compLine truncated
|
||||
|
||||
.draft-list{margin:16px 0}.draft-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid #e2e8e3}.draft-row code{flex:1;overflow-wrap:anywhere}.draft-row button{padding:6px 9px}.publish>div{min-width:190px}@media(max-width:1000px){.publish>div{flex:1 0 100%}}
|
||||
|
||||
.identity-overview{margin-top:18px;padding:16px;background:#f3f6f4;border-radius:7px}.identity-overview .check{max-width:none;margin-top:14px}.missing-names{display:flex;flex-wrap:wrap;gap:6px 24px;padding-left:20px;margin:10px 0 16px}
|
||||
Reference in new issue
Block a user