diff options
| author | Leonard Kugis <leonard@kug.is> | 2026-09-15 18:05:30 +0200 |
|---|---|---|
| committer | Leonard Kugis <leonard@kug.is> | 2026-09-15 18:05:30 +0200 |
| commit | 97887f681dbe687b37f341be66ee042d45748e7d (patch) | |
| tree | 5de75fc4cabfb4b2ed8c5092df8b2cd626cc5cbf | |
| parent | 0605e67c29ab735615860a6649ebd77a38b534f8 (diff) | |
| download | medman-97887f681dbe687b37f341be66ee042d45748e7d.tar.gz | |
Implemented prefetching
| -rw-r--r-- | templates/index.html | 105 |
1 files changed, 67 insertions, 38 deletions
diff --git a/templates/index.html b/templates/index.html index 3808a3d..24b9658 100644 --- a/templates/index.html +++ b/templates/index.html @@ -12,10 +12,11 @@ --yellow: #f9e2af; --purple: #cba6f7; } * { margin:0; padding:0; box-sizing:border-box; } -body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); min-height:100vh; display:flex; flex-direction:column; } +html, body { height:100%; overflow:hidden; } +body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); display:flex; flex-direction:column; } /* ── header ─────────────────────────────────────────────── */ -header { background:var(--surface); border-bottom:1px solid var(--border); padding:8px 16px; display:flex; align-items:center; gap:8px; flex-wrap:wrap; position:sticky; top:0; z-index:100; } +header { background:var(--surface); border-bottom:1px solid var(--border); padding:8px 16px; display:flex; align-items:center; gap:8px; flex-wrap:wrap; flex-shrink:0; z-index:100; } header h1 { font-size:1.1rem; font-weight:700; white-space:nowrap; color:var(--accent); margin-right:auto; } header button, header .btn { background:var(--surface2); color:var(--text); border:1px solid var(--border); border-radius:5px; padding:4px 10px; cursor:pointer; font-size:0.75rem; white-space:nowrap; } header button:hover { background:var(--border); } @@ -34,24 +35,24 @@ header.collapsed { padding:4px 8px; } header.collapsed #header-actions { display:none; } header.collapsed h1 { font-size:0.9rem; } -#sel-bar { display:none; align-items:center; gap:6px; padding:4px 16px; background:var(--accent); color:#000; font-size:0.75rem; font-weight:600; } +#sel-bar { display:none; align-items:center; gap:6px; padding:4px 16px; background:var(--accent); color:#000; font-size:0.75rem; font-weight:600; flex-shrink:0; } #sel-bar.on { display:flex; } #sel-bar button { background:rgba(0,0,0,0.2); color:#000; border:none; border-radius:4px; padding:3px 8px; cursor:pointer; font-size:0.7rem; font-weight:600; } #sel-bar button:hover { background:rgba(0,0,0,0.35); } -#up-area { display:none; padding:8px 16px; background:var(--surface); border-bottom:1px solid var(--border); } +#up-area { display:none; padding:8px 16px; background:var(--surface); border-bottom:1px solid var(--border); flex-shrink:0; } #up-area.on { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } #up-area input[type=file] { color:var(--text2); font-size:0.75rem; } #up-area .prog { color:var(--accent); font-size:0.75rem; } /* ── main layout ────────────────────────────────────────── */ -main { flex:1; display:flex; overflow:hidden; } +main { flex:1; display:flex; overflow:hidden; min-height:0; } -/* ── sidebar ─ fixed, does not scroll with content ──────── */ +/* ── sidebar ────────────────────────────────────────────── */ #side { width:240px; min-width:240px; background:var(--surface); border-right:1px solid var(--border); padding:10px; overflow-y:auto; flex-shrink:0; display:flex; flex-direction:column; gap:6px; transition:width 0.2s, min-width 0.2s, padding 0.2s; } #side.collapsed { width:0; min-width:0; padding:10px 0; overflow:hidden; border-right:none; } -.section { border:1px solid var(--border); border-radius:7px; overflow:hidden; } +.section { border:1px solid var(--border); border-radius:7px; overflow:hidden; flex-shrink:0; } .sec-h { display:flex; align-items:center; justify-content:space-between; padding:7px 10px; background:var(--surface2); cursor:pointer; user-select:none; } .sec-h:hover { background:var(--border); } .sec-h .t { font-size:0.72rem; font-weight:600; text-transform:uppercase; letter-spacing:0.04em; color:var(--text2); } @@ -89,7 +90,12 @@ main { flex:1; display:flex; overflow:hidden; } #dd-info .dd { margin:1px 0; } /* ── content area ───────────────────────────────────────── */ -#content { flex:1; overflow-y:auto; padding:12px; } +#content { flex:1; overflow-y:scroll; padding:12px; } +#content::-webkit-scrollbar { width:8px; } +#content::-webkit-scrollbar-track { background:var(--bg); } +#content::-webkit-scrollbar-thumb { background:var(--border); border-radius:4px; } +#content::-webkit-scrollbar-thumb:hover { background:var(--text2); } + #grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(170px,1fr)); gap:8px; } .card { background:var(--surface); border:1px solid var(--border); border-radius:7px; overflow:hidden; transition:all 0.15s; cursor:pointer; position:relative; } .card:hover { border-color:var(--accent); transform:translateY(-1px); } @@ -250,22 +256,18 @@ const S = { _obs:null, // IntersectionObserver instance _sentinel:null, // sentinel element _scrollTop:0, // saved scroll position when closing modal - _totalCount:null, // total file count (from first batch header or estimate) - _urlAfter:null, // hash to skip-to on initial load + _prefetch:[], // preloaded adjacent file data (cleared on modal close) }; function st(msg){let e=document.getElementById('status');e.textContent=msg;e.classList.add('on');clearTimeout(e._t);e._t=setTimeout(()=>e.classList.remove('on'),2200);} async function api(u,o={}){let r=await fetch(u,o);if(!r.ok){let e=await r.json().catch(()=>({error:r.statusText}));throw new Error(e.error||r.statusText);}return r.json();} // ── URL helpers ───────────────────────────────────────────────────────────── - -// Write current state to URL: tags, search mode, scroll anchor (last visible hash), selection function _updateURL(){ let p=new URLSearchParams(); if(S.sm && S.stags.length) p.set('tags',S.stags.join(',')); else if(!S.sm && !S.stags.length) p.set('all','1'); - // scroll anchor: use the hash of the first card currently visible at the top let anchor=_visibleAnchor(); if(anchor) p.set('anchor',anchor); @@ -276,7 +278,6 @@ function _updateURL(){ history.replaceState(null,'',url); } -// Find the first card whose top edge is within (or above) the content viewport function _visibleAnchor(){ let content=document.getElementById('content'); let rect=content.getBoundingClientRect(); @@ -284,11 +285,9 @@ function _visibleAnchor(){ let cards=document.querySelectorAll('#grid .card'); for(let c of cards){ let cr=c.getBoundingClientRect(); - // first card whose bottom is below the top of the viewport if(cr.bottom>=topEdge) return c.dataset.hash; } - // fallback: first rendered card - if(S._rendered&&S._rendered.length) return S._rendered[0].hash; + if(S.files.length) return S.files[0].hash; return null; } @@ -417,8 +416,7 @@ async function _fetchBatch(afterHash=null){ params.set('limit',String(S.PAGE)); let endpoint=S.sm && S.stags.length?'/api/search':'/api/files'; - let url=endpoint+'?'+params.toString(); - return await api(url); + return await api(endpoint+'?'+params.toString()); } function _cardHTML(f){ @@ -450,12 +448,11 @@ async function _loadMore(){ S._loading=true; document.getElementById('load-spin').classList.add('on'); try{ - let after=S._rendered&&S._rendered.length?S._rendered[S._rendered.length-1].hash:null; + let after=S.files.length?S.files[S.files.length-1].hash:null; let batch=await _fetchBatch(after); if(!batch.length){S._exhausted=true; return;} S.files.push(...batch); _appendCards(batch); - S._rendered=Array.isArray(S._rendered)?S._rendered.concat(batch):batch.slice(); }catch(e){ st('Laden fehlgeschlagen'); }finally{ @@ -474,7 +471,7 @@ function _appendCards(items){ _bindCardEvents(card); g.appendChild(card); } - // Update sentinel at the bottom of #content + // Move sentinel to end if(S._sentinel) S._sentinel.remove(); let sentinel=document.createElement('div'); sentinel.style.height='1px'; sentinel.id='obs-sentinel'; @@ -489,21 +486,20 @@ function _observeSentinel(){ if(entries[0].isIntersecting&&!S._loading&&!S._exhausted){ _loadMore(); } - },{root:document.getElementById('content'),rootMargin:'400px'}); + },{root:document.getElementById('content'),rootMargin:'600px'}); } async function resetAndReload(){ document.getElementById('grid').innerHTML=''; document.getElementById('empty').style.display='none'; if(S._sentinel){S._sentinel.remove();S._sentinel=null;} - S.files=[]; S._rendered=[]; S._exhausted=false; S._loading=false; + S.files=[]; S._exhausted=false; S._loading=false; document.getElementById('load-spin').classList.remove('on'); S.sel.clear(); if(S._urlSel){for(let h of S._urlSel)S.sel.add(h);} updateSelUI(); _updateURL(); _observeSentinel(); - // If we have an anchor, fast-load until we reach it if(S._urlAnchor){ await _fastForwardToAnchor(S._urlAnchor); S._urlAnchor=null; @@ -516,11 +512,10 @@ async function resetAndReload(){ } } -// Keep loading batches until the anchor hash appears, then scroll it into view async function _fastForwardToAnchor(anchorHash){ - let maxBatches=50; // safety limit + let maxBatches=50; while(maxBatches-->0){ - let after=S._rendered&&S._rendered.length?S._rendered[S._rendered.length-1].hash:null; + let after=S.files.length?S.files[S.files.length-1].hash:null; let batch=await _fetchBatch(after); if(!batch.length){S._exhausted=true; break;} @@ -530,15 +525,13 @@ async function _fastForwardToAnchor(anchorHash){ if(f.hash===anchorHash) found=true; } _appendCards(batch); - S._rendered=Array.isArray(S._rendered)?S._rendered.concat(batch):batch.slice(); if(found){ - // Scroll to the anchor card requestAnimationFrame(()=>{ - let el=document.querySelector(`#grid .card[data-hash="${CSS.escape(anchorHash)}"]`); + let el=document.querySelector('#grid .card[data-hash="'+CSS.escape(anchorHash)+'"]'); if(el) el.scrollIntoView({block:'start',behavior:'instant'}); }); - S._exhausted=false; // there may be more, keep observer active + S._exhausted=false; break; } } @@ -549,7 +542,7 @@ async function reloadFiles(){ let scrollTop=document.getElementById('content').scrollTop; document.getElementById('grid').innerHTML=''; if(S._sentinel){S._sentinel.remove();S._sentinel=null;} - S.files=[]; S._rendered=[]; S._exhausted=false; S._loading=false; + S.files=[]; S._exhausted=false; S._loading=false; document.getElementById('load-spin').classList.remove('on'); _updateURL(); _observeSentinel(); @@ -661,12 +654,49 @@ async function selDel(){ function sc(msg,cb){document.getElementById('conf-msg').textContent=msg;document.getElementById('conf-modal').classList.add('on');document.getElementById('conf-yes').onclick=()=>{document.getElementById('conf-modal').classList.remove('on');cb();};} +// ── Client-side prefetch for detail view ──────────────────────────────────── +function _prefetchAdjacent(hash){ + S._prefetch=[]; + let idx=S.files.findIndex(x=>x.hash===hash); + if(idx<0)return; + // Prefetch up to 2 prev + 2 next file tags and raw data + let targets=[]; + if(idx>0) targets.push(S.files[idx-1].hash); + if(idx>1) targets.push(S.files[idx-2].hash); + if(idx<S.files.length-1) targets.push(S.files[idx+1].hash); + if(idx<S.files.length-2) targets.push(S.files[idx+2].hash); + for(let h of targets){ + let p=Promise.all([ + api(`/api/files/${h}/tags`).catch(()=>[]), + ]).then(([tags])=>({hash:h,tags})); + S._prefetch.push(p); + } +} + +function _clearPrefetch(){S._prefetch=[];} + +async function _getPrefetchedTags(hash){ + for(let p of S._prefetch){ + let d=await p; + if(d.hash===hash) return d.tags; + } + return null; +} + // ── Single file preview ───────────────────────────────────────────────────── async function op(h){ _clearNavKd(); S._scrollTop=document.getElementById('content').scrollTop; - S.ph=h; let f=S.files.find(x=>x.hash===h); if(!f)return; - let ft=await api(`/api/files/${h}/tags`),dd=await api('/api/dat'), + S.ph=h; + _prefetchAdjacent(h); + + let f=S.files.find(x=>x.hash===h); if(!f)return; + + // Try prefetched tags first, fall back to API + let ft=await _getPrefetchedTags(h); + if(!ft) ft=await api(`/api/files/${h}/tags`); + + let dd=await api('/api/dat'), ht=`<h2>${h}</h2>`,mt=f.mt||'other'; if(mt==='image')ht+=`<img src="/raw/${h}" alt="">`; else if(mt==='video')ht+=`<video controls src="/raw/${h}" style="max-width:100%;max-height:48vh;"></video>`; @@ -763,6 +793,7 @@ document.getElementById('b-sel-clr').addEventListener('click',clearSel); // ── Modals ────────────────────────────────────────────────────────────────── function closeModal(){ _clearNavKd(); + _clearPrefetch(); document.getElementById('prev-modal').classList.remove('on'); S.ph=null; requestAnimationFrame(()=>{ @@ -778,7 +809,7 @@ document.addEventListener('keydown',e=>{if(e.key==='Escape'){if(document.getElem let _scrollTimer=null; document.getElementById('content').addEventListener('scroll',()=>{ if(_scrollTimer) clearTimeout(_scrollTimer); - _scrollTimer=setTimeout(()=>_updateURL(),200); + _scrollTimer=setTimeout(()=>_updateURL(),250); }, {passive:true}); // ── Init ──────────────────────────────────────────────────────────────────── @@ -786,7 +817,6 @@ _loadStateFromURL(); lt(); ldd(); _observeSentinel(); -// Start loading — if there's an anchor in the URL, fast-forward to it (async ()=>{ if(S._urlAnchor){ await _fastForwardToAnchor(S._urlAnchor); @@ -795,7 +825,6 @@ _observeSentinel(); await _loadMore(); } if(!S.files.length) document.getElementById('empty').style.display='block'; - // Apply URL selections if(S._urlSel&&S._urlSel.size){ for(let h of S._urlSel) S.sel.add(h); updateSelUI(); |
