aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLeonard Kugis <leonard@kug.is>2026-09-15 18:05:30 +0200
committerLeonard Kugis <leonard@kug.is>2026-09-15 18:05:30 +0200
commit97887f681dbe687b37f341be66ee042d45748e7d (patch)
tree5de75fc4cabfb4b2ed8c5092df8b2cd626cc5cbf
parent0605e67c29ab735615860a6649ebd77a38b534f8 (diff)
downloadmedman-97887f681dbe687b37f341be66ee042d45748e7d.tar.gz
Implemented prefetching
-rw-r--r--templates/index.html105
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();