<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Haqor Lexicon Overlay</title>
<style>
:root { color-scheme:dark; font:15px/1.45 system-ui,sans-serif; --panel:#22302a; --field:#101613; --line:#52635b; --muted:#aebdb5; --accent:#8ed0a9; }
* { box-sizing:border-box; }
body { margin:0; background:#18201d; color:#edf4ef; }
header { display:flex; gap:1rem; align-items:center; padding:1rem 1.5rem; background:var(--panel); position:sticky; top:0; z-index:2; box-shadow:0 2px 10px #0005; }
h1 { font-size:1.1rem; margin:0; flex:1; }
button { border:0; border-radius:.45rem; padding:.65rem 1rem; font-weight:700; cursor:pointer; background:var(--accent); color:#102019; }
button:disabled { opacity:.55; cursor:wait; }
button.secondary { background:#35483f; color:#edf4ef; border:1px solid var(--line); }
button.delete { background:transparent; color:#ffb4b4; border:1px solid #825454; padding:.45rem .7rem; }
main { max-width:1100px; margin:auto; padding:1.25rem 1.5rem 3rem; }
nav { display:flex; gap:.5rem; margin-bottom:1rem; }
nav button { background:#293a32; color:#cbd8d0; }
nav button.active { background:var(--accent); color:#102019; }
.toolbar { display:flex; gap:.75rem; align-items:center; margin-bottom:1rem; }
.toolbar input { flex:1; }
input,textarea { width:100%; border:1px solid var(--line); border-radius:.4rem; padding:.6rem .7rem; background:var(--field); color:#f4faf6; font:inherit; }
input[dir="rtl"] { font-size:1.2rem; }
textarea { min-height:4.5rem; resize:vertical; }
label { display:grid; gap:.3rem; color:#bfd0c6; font-size:.86rem; }
.rows { display:grid; gap:.75rem; }
.row { display:grid; grid-template-columns:minmax(10rem,1fr) minmax(9rem,1fr) minmax(15rem,2fr) auto; gap:.75rem; align-items:end; padding:.9rem; border:1px solid #394c43; border-radius:.55rem; background:#1d2924; }
.row.word { grid-template-columns:minmax(10rem,1fr) minmax(15rem,1.5fr) minmax(15rem,2fr) auto; }
.row.imported { grid-template-columns:minmax(7rem,.7fr) minmax(10rem,1fr) minmax(9rem,1fr) minmax(15rem,2fr) auto; }
.ambiguity { padding:1rem; border:1px solid #394c43; border-radius:.55rem; background:#1d2924; }
.ambiguity h2 { margin:0 0 .7rem; font-size:1.3rem; } .ambiguity h2 small { color:var(--muted); font-size:.8rem; font-weight:400; }
.choice { display:flex; gap:.7rem; align-items:center; padding:.55rem; border-top:1px solid #34443d; cursor:pointer; }
.choice input { width:auto; } .choice code { color:#dcebe3; white-space:normal; }
.source { color:var(--muted); align-self:center; }
.row .name-field { display:flex; gap:.5rem; align-items:center; min-height:2.6rem; white-space:nowrap; }
.row .name-field input { width:auto; }
.empty { padding:3rem; text-align:center; color:var(--muted); border:1px dashed var(--line); border-radius:.5rem; }
#status { min-width:13rem; text-align:right; color:#bdd0c5; }
#status.error { color:#ff9d9d; }
.hint { margin:1rem 0 0; color:var(--muted); }
[hidden] { display:none !important; }
@media (max-width:800px) { header { flex-wrap:wrap; } #status { order:3; width:100%; text-align:left; } .row,.row.word { grid-template-columns:1fr; } .toolbar { flex-wrap:wrap; } .toolbar input { flex-basis:100%; } }
</style>
</head>
<body>
<header><h1>Haqor · Lexicon overlay</h1><span id="status">Loading…</span><button id="save">Save changes</button></header>
<main>
<nav aria-label="Overlay sections">
<button class="tab active" data-section="lexicon_entries">Lexicon entries <span id="lexicon-count"></span></button>
<button class="tab" data-section="word_glosses">Word glosses <span id="word-count"></span></button>
<button class="tab" data-section="imported">Imported glosses <span id="imported-count"></span></button>
<button class="tab" data-section="primary_analyses">Ambiguous analyses <span id="ambiguous-count"></span></button>
</nav>
<div class="toolbar"><input id="search" type="search" placeholder="Search surfaces, roots, glosses, or notes…"><button class="secondary" id="add">Add lexicon entry</button></div>
<section id="lexicon_entries" class="rows" aria-label="Lexicon entries"></section>
<section id="word_glosses" class="rows" aria-label="Word glosses" hidden></section>
<section id="imported" class="rows" aria-label="Imported lexicon glosses" hidden></section>
<section id="primary_analyses" class="rows" aria-label="Ambiguous Hebrew analyses" hidden></section>
<p class="hint">Saving validates every field and atomically replaces the overlay. Then run <code>cargo run -- db gen-lexicon</code> and <code>cargo run -- db gen-hebrew -n 0</code>.</p>
</main>
<script>
const $=s=>document.querySelector(s), status=$('#status'), save=$('#save'), search=$('#search'), add=$('#add');
let overlay=null, imported=[], ambiguous=[], active='lexicon_entries', dirty=false;
const show=(text,error=false)=>{status.textContent=text;status.className=error?'error':'';};
const field=(label,key,value='',type='text')=>{const l=document.createElement('label');l.textContent=label;const i=document.createElement(type==='textarea'?'textarea':'input');if(type!=='textarea')i.type=type;i.dataset.key=key;i.value=value??'';if(key==='surface'||key==='root')i.dir='rtl';l.append(i);return l;};
function row(section,item,index){
const card=document.createElement('article');card.className='row '+(section==='word_glosses'?'word':'lexicon');card.dataset.index=index;
card.append(field('Pointed Hebrew surface','surface',item.surface));
if(section==='lexicon_entries') card.append(field('Root (optional)','root',item.root));
card.append(field(section==='lexicon_entries'?'Base gloss':'Learner gloss','gloss',item.gloss));
if(section==='word_glosses'){
card.append(field('Teaching note (optional)','note',item.note,'textarea'));
const controls=document.createElement('div'), name=document.createElement('label'), checkbox=document.createElement('input');controls.className='name-field';checkbox.type='checkbox';checkbox.dataset.key='is_name';checkbox.checked=!!item.is_name;name.append(checkbox,' Proper name');controls.append(name);card.append(controls);
}
const del=document.createElement('button');del.type='button';del.className='delete';del.dataset.action='delete';del.textContent='Delete';card.append(del);return card;
}
function importedRow(item){
const card=document.createElement('article');card.className='row imported';
const source=document.createElement('div');source.className='source';source.textContent=`${item.source} · ${item.id}`;
const surface=field('Pointed Hebrew surface','',item.surface),root=field('Root','',item.root);surface.querySelector('input').readOnly=true;root.querySelector('input').readOnly=true;
card.append(source,surface,root);
const gloss=field('Imported gloss','overlayGloss',item.gloss);card.append(gloss);
const create=document.createElement('button');create.type='button';create.className='secondary';create.dataset.action='create-overlay';create.textContent='Create overlay';card.append(create);
card.dataset.surface=item.surface;card.dataset.root=item.root??'';return card;
}
const analysisKey=a=>{const type=a.analysis_type??'verb',keys=type==='noun'?['stem','kind','label','prefix']:['root','binyan','form','pgn','prefix','vav_consecutive','obj_suffix'];return [type,...keys.map(k=>String(a[k]??''))].join('\u001f');};
function ambiguityRow(item){
const card=document.createElement('article');card.className='ambiguity';
const title=document.createElement('h2');title.dir='rtl';title.append(item.surface);const meta=document.createElement('small');meta.textContent=` · ${item.occurrences} occurrences`;title.append(meta);card.append(title);
const selected=overlay.primary_analyses.find(v=>v.surface===item.surface);
const automatic=document.createElement('label'),autoRadio=document.createElement('input'),autoText=document.createElement('code');automatic.className='choice';autoRadio.type='radio';autoRadio.name=`surface-${item.surface_id}`;autoRadio.checked=!selected;autoRadio.onchange=()=>{const at=overlay.primary_analyses.findIndex(v=>v.surface===item.surface);if(at>=0)overlay.primary_analyses.splice(at,1);markDirty();render();};autoText.textContent='Automatic selection (no override)';automatic.append(autoRadio,autoText);card.append(automatic);
item.analyses.forEach(analysis=>{const label=document.createElement('label');label.className='choice';const radio=document.createElement('input');radio.type='radio';radio.name=`surface-${item.surface_id}`;radio.checked=!!selected&&analysisKey(selected)===analysisKey(analysis);radio.onchange=()=>{const value={surface:item.surface,...analysis};delete value.analysis_id;delete value.attested;if(value.analysis_type==='noun'){delete value.root;delete value.binyan;delete value.form;delete value.pgn;delete value.vav_consecutive;delete value.obj_suffix;}else{delete value.stem;delete value.kind;delete value.label;}const at=overlay.primary_analyses.findIndex(v=>v.surface===item.surface);if(at>=0)overlay.primary_analyses[at]=value;else overlay.primary_analyses.push(value);markDirty();render();};const text=document.createElement('code');text.textContent=analysis.analysis_type==='noun'?`Noun · ${analysis.stem} · ${analysis.kind} · ${analysis.label}${analysis.prefix?' · prefix '+analysis.prefix:''}`:`Verb · ${analysis.root} · ${analysis.binyan} ${analysis.form} ${analysis.pgn||'—'}${analysis.prefix?' · prefix '+analysis.prefix:''}${analysis.obj_suffix?' · suffix '+analysis.obj_suffix:''}${analysis.vav_consecutive?' · vav-consecutive':''}${analysis.attested?' · attested':''}`;label.append(radio,text);card.append(label);});return card;
}
function render(){
if(!overlay)return;const query=search.value.trim().toLocaleLowerCase();
for(const section of ['lexicon_entries','word_glosses']){
const host=$('#'+section);host.replaceChildren();let visible=0;
overlay[section].forEach((item,index)=>{const haystack=Object.values(item).join(' ').toLocaleLowerCase();if(query&&!haystack.includes(query))return;host.append(row(section,item,index));visible++;});
if(!visible){const empty=document.createElement('div');empty.className='empty';empty.textContent=query?'No matching entries.':'No entries yet.';host.append(empty);}
}
const importedHost=$('#imported');importedHost.replaceChildren();let importedVisible=0;
for(const item of imported){const haystack=Object.values(item).join(' ').toLocaleLowerCase();if(query&&!haystack.includes(query))continue;if(importedVisible<250)importedHost.append(importedRow(item));importedVisible++;}
if(!importedVisible){const empty=document.createElement('div');empty.className='empty';empty.textContent='No matching imported glosses.';importedHost.append(empty);}else if(importedVisible>250){const more=document.createElement('div');more.className='empty';more.textContent=`Showing 250 of ${importedVisible} matches. Refine the search to see more.`;importedHost.append(more);}
const ambiguityHost=$('#primary_analyses');ambiguityHost.replaceChildren();let ambiguousVisible=0;
for(const item of ambiguous){const haystack=(item.surface+' '+item.analyses.map(a=>Object.values(a).join(' ')).join(' ')).toLocaleLowerCase();if(query&&!haystack.includes(query))continue;ambiguityHost.append(ambiguityRow(item));ambiguousVisible++;}
if(!ambiguousVisible){const empty=document.createElement('div');empty.className='empty';empty.textContent='No matching ambiguous analyses.';ambiguityHost.append(empty);}
$('#lexicon-count').textContent=`(${overlay.lexicon_entries.length})`;$('#word-count').textContent=`(${overlay.word_glosses.length})`;
$('#imported-count').textContent=`(${imported.length})`;
$('#ambiguous-count').textContent=`(${ambiguous.length})`;
}
function markDirty(){dirty=true;show('Unsaved changes');}
function select(section){active=section;document.querySelectorAll('.tab').forEach(b=>b.classList.toggle('active',b.dataset.section===section));document.querySelectorAll('.rows').forEach(s=>s.hidden=s.id!==section);add.hidden=section==='imported'||section==='primary_analyses';add.textContent=section==='lexicon_entries'?'Add lexicon entry':'Add word gloss';search.value='';render();}
function validate(){
for(const section of ['lexicon_entries','word_glosses']){const seen=new Set();for(let i=0;i<overlay[section].length;i++){const item=overlay[section][i],where=`${section} row ${i+1}`;if(!item.surface?.trim())throw new Error(`${where}: surface is required`);if(!item.gloss?.trim()&&!(section==='word_glosses'&&item.is_name))throw new Error(`${where}: gloss is required unless this is a proper-name marker`);if(seen.has(item.surface))throw new Error(`${where}: duplicate surface ${item.surface}`);seen.add(item.surface);}}
}
document.addEventListener('input',e=>{const card=e.target.closest('.row');if(!card||!e.target.dataset.key||card.parentElement.id==='imported')return;const item=overlay[card.parentElement.id][Number(card.dataset.index)],key=e.target.dataset.key;item[key]=e.target.type==='checkbox'?e.target.checked:e.target.value;if((key==='note'&&!item[key])||(key==='is_name'&&!item[key]))delete item[key];markDirty();});
document.addEventListener('click',e=>{const del=e.target.closest('[data-action="delete"]');if(del){const card=del.closest('.row');overlay[card.parentElement.id].splice(Number(card.dataset.index),1);markDirty();render();return;}const create=e.target.closest('[data-action="create-overlay"]');if(!create)return;const card=create.closest('.row'),surface=card.dataset.surface,root=card.dataset.root,gloss=card.querySelector('[data-key="overlayGloss"]').value.trim();if(!gloss){show('Overlay gloss is required',true);return;}const existing=overlay.lexicon_entries.find(v=>v.surface===surface);if(existing){if(root)existing.root=root;existing.gloss=gloss;}else overlay.lexicon_entries.unshift({surface,root,gloss});markDirty();select('lexicon_entries');search.value=surface;render();});
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>select(b.dataset.section));search.oninput=render;
add.onclick=()=>{const item=active==='lexicon_entries'?{surface:'',root:'',gloss:''}:{surface:'',gloss:''};overlay[active].unshift(item);search.value='';markDirty();render();const input=$('#'+active+' .row input');input?.focus();};
save.onclick=async()=>{try{validate();}catch(e){show(e.message,true);return;}save.disabled=true;show('Saving…');try{const r=await fetch('/api/overlay',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(overlay)}),result=await r.json();if(!r.ok)throw new Error(result.error);dirty=false;show('Saved');}catch(e){show(e.message,true);}finally{save.disabled=false;}};
Promise.all(['/api/overlay','/api/lexicon','/api/ambiguous'].map(url=>fetch(url).then(async r=>{if(!r.ok)throw new Error((await r.json()).error);return r.json();}))).then(([value,catalogue,review])=>{overlay=value;imported=catalogue.entries;ambiguous=review.surfaces;render();show('Ready');}).catch(e=>show(e.message,true));
addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
</script>
</body>
</html>