diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js index b9735d3..99e7a32 100644 --- a/internal/httpapi/static/app.js +++ b/internal/httpapi/static/app.js @@ -1,11 +1,21 @@ // Shared state const st = { tailLoading: false, atBottom: true, current: '#', earliest: null, sse: null, channels: [] }; +function measureBars(){ + const hdr = document.querySelector('header.nav'); + const ftr = document.querySelector('footer'); + if(hdr){ document.documentElement.style.setProperty('--headerH', hdr.getBoundingClientRect().height+'px'); } + if(ftr){ document.documentElement.style.setProperty('--footerH', ftr.getBoundingClientRect().height+'px'); } +} + function colorFor(nick){ let h=0; for(let i=0;i>>0 } return 'hsl('+(h%360)+',60%,'+(window.matchMedia('(prefers-color-scheme: dark)').matches? '70%':'35%')+')'; } function escapeHtml(s){ return s.replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } function linkify(t){ return t.replace(/https?:\/\/\S+/g, function(u){ return '' + u + ''; }); } function lineHTML(m){ const ts = '[' + m.time + ']'; const nick = '' + m.author + ''; const body = escapeHtml(m.body); return ts + ' ' + nick + ': ' + linkify(body); } +function snapBottom(){ window.scrollTo(0, document.documentElement.scrollHeight); } +function pinBottomMulti(){ if(!st.atBottom) return; [0,16,64,200].forEach(d=> setTimeout(()=>requestAnimationFrame(snapBottom), d)); } + async function api(path, params){ const url = new URL(path, window.location.origin); if(params && params.query){ Object.entries(params.query).forEach(([k,v])=>url.searchParams.set(k,v)); } @@ -16,9 +26,8 @@ async function api(path, params){ return res.text(); } -function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ requestAnimationFrame(()=>{ window.scrollTo({top: document.documentElement.scrollHeight, behavior: 'instant'}); }); } } -function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const beforeTop = oldTop ? oldTop.getBoundingClientRect().top : 0; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); // preserve visual position - if(oldTop){ const afterTop = oldTop.getBoundingClientRect().top; const delta = afterTop - beforeTop; window.scrollBy({top: delta, left: 0, behavior: 'instant'}); } } +function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); pinBottomMulti(); } +function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const beforeTop = oldTop ? oldTop.getBoundingClientRect().top : 0; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); if(oldTop){ const afterTop = oldTop.getBoundingClientRect().top; const delta = afterTop - beforeTop; window.scrollBy(0, delta); } } function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1'); // Fetch and render card @@ -33,6 +42,7 @@ function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not( html += ''; const row = document.createElement('div'); row.style.display='flex'; row.style.alignItems='flex-start'; row.style.gap='.5rem'; row.innerHTML = html; c.appendChild(row); + c.querySelectorAll('img').forEach(img=> img.addEventListener('load', ()=> pinBottomMulti())); // Summary control row const ctrl = document.createElement('div'); ctrl.style.marginTop='.25rem'; const btn = document.createElement('button'); btn.type='button'; btn.title='Summarize this link'; btn.textContent='\u25B6'; btn.style.padding='0 .4rem'; btn.style.fontSize='.9rem'; @@ -55,9 +65,7 @@ function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not( async function loadChannels(){ try{ const data = await api('/api/channels'); st.channels = data; renderChannels(); if(data.length>0){ selectChannel(data[0]); } } catch(e){} } function renderChannels(){ const list=document.getElementById('chanlist'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); } -async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); // snap to bottom after initial load - requestAnimationFrame(()=>{ window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }); st.atBottom=true; }); - st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); } +async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); requestAnimationFrame(()=>{ snapBottom(); st.atBottom=true; }); st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); } function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; const onScroll = async ()=>{ const nearBottom = (window.innerHeight + window.pageYOffset + 16) >= document.documentElement.scrollHeight; st.atBottom = nearBottom; const nearTop = window.pageYOffset <= 4; if(nearTop && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ prependBatch(older); st.earliest = older[0].time; } } catch(e){} } }; window.removeEventListener('scroll', st._scrollHandler || (()=>{})); st._scrollHandler = onScroll; window.addEventListener('scroll', onScroll, {passive:true}); } function startStream(){ const el=document.getElementById('tail'); if(!el) return; const url=new URL('/api/stream', window.location.origin); url.searchParams.set('channel', st.current); const es=new EventSource(url); st.sse=es; es.onmessage=(ev)=>{ try{ const m=JSON.parse(ev.data); appendBatch([m]); }catch(e){} }; es.onerror=()=>{ es.close(); st.sse=null; setTimeout(startStream, 3000); } } @@ -65,13 +73,12 @@ function startStream(){ const el=document.getElementById('tail'); if(!el) return async function summarize(){ const ch=document.getElementById('channel'); const win=document.getElementById('window'); const push=document.getElementById('push'); const btn=document.getElementById('btn'); const out=document.getElementById('out'); if(!ch||!win||!btn||!out) return; btn.disabled=true; out.textContent=''; try{ const data = await api('/api/trigger',{query:{channel:ch.value,window:win.value||'6h',push:push && push.checked? '1':'0'}}); if(typeof data === 'string'){ out.textContent = data; } else { out.textContent = (data.summary||''); } } catch(e){ out.textContent = 'error: '+e; } btn.disabled=false; } window.addEventListener('DOMContentLoaded', ()=>{ - // measure header/footer and set margins so last lines are visible - const hdr = document.querySelector('header.nav'); - const ftr = document.querySelector('footer'); - if(hdr){ document.documentElement.style.setProperty('--headerH', hdr.getBoundingClientRect().height+'px'); } - if(ftr){ document.documentElement.style.setProperty('--footerH', ftr.getBoundingClientRect().height+'px'); } + if('scrollRestoration' in history){ history.scrollRestoration = 'manual'; } + measureBars(); if(document.getElementById('chanlist')){ loadChannels(); } if(document.getElementById('channel')){ fetch('/api/channels').then(r=>r.json()).then(arr=>{ const sel=document.getElementById('channel'); arr.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); }); }).catch(()=>{}); } }); +window.addEventListener('load', measureBars); +window.addEventListener('resize', ()=>{ measureBars(); if(st.atBottom) pinBottomMulti(); });