fix(webui): lock footer/header visibly using sticky + high z-index and solid background; preserve scroll position on history prepend; robust at-bottom detection

This commit is contained in:
Thomas Cravey 2025-08-17 14:25:04 -05:00
parent e4f58281fb
commit 21a49c18a0
2 changed files with 5 additions and 4 deletions

View file

@ -1,6 +1,6 @@
html, body { height: 100%; } html, body { height: 100%; }
body { padding: 0; display: grid; grid-template-rows: auto 1fr auto; min-height: 100vh; } body { padding: 0; display: grid; grid-template-rows: auto 1fr auto; min-height: 100vh; }
header.nav { position: sticky; top: 0; z-index: 10; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background: var(--background-color); } header.nav { position: sticky; top: 0; z-index: 1000; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background-color: var(--pico-background-color, #fff); }
header.nav a.brand { text-decoration: none; font-weight: 600; } header.nav a.brand { text-decoration: none; font-weight: 600; }
/* Dashboard-only grid layout */ /* Dashboard-only grid layout */
.dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; height: 100%; min-height: 0; } .dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; height: 100%; min-height: 0; }
@ -11,7 +11,7 @@ header.nav a.brand { text-decoration: none; font-weight: 600; }
#tail { flex: 1; overflow: visible; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: auto; } #tail { flex: 1; overflow: visible; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: auto; }
.ts { opacity: .66; } .ts { opacity: .66; }
.msg { margin-bottom: .25rem; } .msg { margin-bottom: .25rem; }
footer { text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; background: var(--background-color); } footer { position: sticky; bottom: 0; z-index: 1000; text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; background-color: var(--pico-background-color, #fff); border-top: 1px solid var(--muted-border-color); }
@media (max-width: 900px) { .dash { grid-template-columns: 1fr; } .dash aside.sidebar { display:none; } } @media (max-width: 900px) { .dash { grid-template-columns: 1fr; } .dash aside.sidebar { display:none; } }
/* Summarizer output wrapping */ /* Summarizer output wrapping */
#out { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; } #out { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; }

View file

@ -17,7 +17,8 @@ async function api(path, params){
} }
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){ window.scrollTo({top: document.body.scrollHeight, 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); if(st.atBottom){ window.scrollTo({top: document.body.scrollHeight, behavior: 'instant'}); } }
function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const prevPageOffset = window.pageYOffset; 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){ oldTop.scrollIntoView(); window.scrollTo({top: oldTop.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'instant'}); } else { window.scrollTo({top: prevPageOffset, 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 processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1'); function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1');
// Fetch and render card // Fetch and render card
@ -55,7 +56,7 @@ async function loadChannels(){ try{ const data = await api('/api/channels'); st.
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); }); } 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); el.scrollTop = el.scrollHeight; 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); el.scrollTop = el.scrollHeight; 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.body.offsetHeight; st.atBottom = nearBottom; if(window.pageYOffset === 0 && 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 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); } } 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); } }