aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLeonard Kugis <leonard@kug.is>2026-09-15 17:36:18 +0200
committerLeonard Kugis <leonard@kug.is>2026-09-15 17:36:18 +0200
commit0605e67c29ab735615860a6649ebd77a38b534f8 (patch)
tree02df2b18a3f5a3895267f1b370d390508e93f2ca
parent422d2e024cc5927280bfc9e2794797d510005728 (diff)
downloadmedman-0605e67c29ab735615860a6649ebd77a38b534f8.tar.gz
Fixed scroll anchor element
-rw-r--r--templates/index.html113
1 files changed, 87 insertions, 26 deletions
diff --git a/templates/index.html b/templates/index.html
index dfb4c2c..3808a3d 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -47,7 +47,7 @@ header.collapsed h1 { font-size:0.9rem; }
/* ── main layout ────────────────────────────────────────── */
main { flex:1; display:flex; overflow:hidden; }
-/* ── sidebar ────────────────────────────────────────────── */
+/* ── sidebar ─ fixed, does not scroll with content ──────── */
#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; }
@@ -105,7 +105,7 @@ main { flex:1; display:flex; overflow:hidden; }
.card .chk { position:absolute; top:4px; left:4px; width:18px; height:18px; border-radius:4px; border:2px solid rgba(255,255,255,0.4); background:rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center; font-size:0.6rem; color:#fff; cursor:pointer; z-index:5; }
.card.sel .chk { background:var(--accent); border-color:var(--accent); }
-/* ── removed pagination; loading indicator ──────────────── */
+/* ── loading indicator ──────────────────────────────────── */
#load-more { display:flex; justify-content:center; padding:16px; }
#load-more .spinner { width:24px; height:24px; border:3px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; display:none; }
#load-more .spinner.on { display:block; }
@@ -250,27 +250,48 @@ 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
};
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) p.set('all','1');
- // encode scroll anchor: hash of last rendered file
- if(S._rendered && S._rendered.length){
- let last=S._rendered[S._rendered.length-1];
- if(last) p.set('after',last.hash);
- }
+ 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);
+
if(S.sel.size) p.set('sel',[...S.sel].join(','));
+
let q=p.toString();
let url=location.pathname+(q?'?'+q:'');
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();
+ let topEdge=rect.top;
+ 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;
+ return null;
+}
+
function _loadStateFromURL(){
let p=new URLSearchParams(location.search);
let tags=p.get('tags');
@@ -285,8 +306,7 @@ function _loadStateFromURL(){
let sel=p.get('sel');
if(sel) S._urlSel=new Set(sel.split(',').filter(Boolean));
else S._urlSel=new Set();
- // 'after' is used to skip past already-loaded content on initial load
- S._urlAfter=p.get('after')||null;
+ S._urlAnchor=p.get('anchor')||null;
}
// ── Header & sidebar toggle ─────────────────────────────────────────────────
@@ -388,7 +408,6 @@ document.getElementById('ti-btn').addEventListener('click',async()=>{
document.getElementById('ti-in').addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('ti-btn').click();});
// ── Files: continuous scroll ────────────────────────────────────────────────
-// Fetch one batch of files. Returns empty array when exhausted.
async function _fetchBatch(afterHash=null){
let params=new URLSearchParams();
if(S.sm && S.stags.length){
@@ -421,7 +440,6 @@ function _bindCardEvents(c){
if(e.target.classList.contains('chk')||e.target.closest('.chk')){
toggleSel(h,c); e.stopPropagation(); return;
}
- // Save scroll position before opening
S._scrollTop=document.getElementById('content').scrollTop;
op(h);
});
@@ -456,7 +474,7 @@ function _appendCards(items){
_bindCardEvents(card);
g.appendChild(card);
}
- // Update sentinel
+ // Update sentinel at the bottom of #content
if(S._sentinel) S._sentinel.remove();
let sentinel=document.createElement('div');
sentinel.style.height='1px'; sentinel.id='obs-sentinel';
@@ -475,27 +493,59 @@ function _observeSentinel(){
}
async function resetAndReload(){
- // Clear grid
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;
document.getElementById('load-spin').classList.remove('on');
- _updateURL();
- // Restore URL selections
S.sel.clear(); if(S._urlSel){for(let h of S._urlSel)S.sel.add(h);}
updateSelUI();
- // Load initial batch
+ _updateURL();
_observeSentinel();
- await _loadMore();
- // If grid still empty after first batch, show empty message
+
+ // If we have an anchor, fast-load until we reach it
+ if(S._urlAnchor){
+ await _fastForwardToAnchor(S._urlAnchor);
+ S._urlAnchor=null;
+ } else {
+ await _loadMore();
+ }
+
if(!S.files.length){
document.getElementById('empty').style.display='block';
}
}
+// Keep loading batches until the anchor hash appears, then scroll it into view
+async function _fastForwardToAnchor(anchorHash){
+ let maxBatches=50; // safety limit
+ while(maxBatches-->0){
+ let after=S._rendered&&S._rendered.length?S._rendered[S._rendered.length-1].hash:null;
+ let batch=await _fetchBatch(after);
+ if(!batch.length){S._exhausted=true; break;}
+
+ let found=false;
+ for(let f of batch){
+ S.files.push(f);
+ 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)}"]`);
+ if(el) el.scrollIntoView({block:'start',behavior:'instant'});
+ });
+ S._exhausted=false; // there may be more, keep observer active
+ break;
+ }
+ }
+ _updateURL();
+}
+
async function reloadFiles(){
- // Used after mutations (tag, delete, move): full reload preserving scroll
let scrollTop=document.getElementById('content').scrollTop;
document.getElementById('grid').innerHTML='';
if(S._sentinel){S._sentinel.remove();S._sentinel=null;}
@@ -507,7 +557,6 @@ async function reloadFiles(){
if(!S.files.length){
document.getElementById('empty').style.display='block';
}
- // Restore scroll position (approximate)
requestAnimationFrame(()=>{document.getElementById('content').scrollTop=scrollTop;});
}
@@ -527,7 +576,6 @@ function updateSelUI(){
}
function clearSel(){S.sel.clear();updateSelUI();_updateURL(); refreshCardSelections();}
function selAll(){
- // Select all currently rendered cards
let cards=document.querySelectorAll('#grid .card');
for(let c of cards){S.sel.add(c.dataset.hash);}
updateSelUI();_updateURL(); refreshCardSelections();
@@ -545,7 +593,6 @@ function _clearNavKd(){
function opNav(delta){
if(!S.ph)return; let idx=S.files.findIndex(x=>x.hash===S.ph); if(idx<0)return;
let nidx=idx+delta; if(nidx<0||nidx>=S.files.length)return;
- // Ensure next item is loaded if near the end
if(nidx>=S.files.length-S.PAGE/2 && !S._exhausted){_loadMore();}
op(S.files[nidx].hash);
}
@@ -718,7 +765,6 @@ function closeModal(){
_clearNavKd();
document.getElementById('prev-modal').classList.remove('on');
S.ph=null;
- // Restore scroll position
requestAnimationFrame(()=>{
document.getElementById('content').scrollTop=S._scrollTop||0;
});
@@ -728,11 +774,26 @@ document.getElementById('prev-modal').addEventListener('click',e=>{if(e.target==
document.getElementById('conf-no').addEventListener('click',()=>document.getElementById('conf-modal').classList.remove('on'));
document.addEventListener('keydown',e=>{if(e.key==='Escape'){if(document.getElementById('prev-modal').classList.contains('on'))closeModal();}});
+// Debounced URL update on scroll
+let _scrollTimer=null;
+document.getElementById('content').addEventListener('scroll',()=>{
+ if(_scrollTimer) clearTimeout(_scrollTimer);
+ _scrollTimer=setTimeout(()=>_updateURL(),200);
+}, {passive:true});
+
// ── Init ────────────────────────────────────────────────────────────────────
_loadStateFromURL();
lt(); ldd();
_observeSentinel();
-_loadMore().then(()=>{
+
+// Start loading — if there's an anchor in the URL, fast-forward to it
+(async ()=>{
+ if(S._urlAnchor){
+ await _fastForwardToAnchor(S._urlAnchor);
+ S._urlAnchor=null;
+ } else {
+ await _loadMore();
+ }
if(!S.files.length) document.getElementById('empty').style.display='block';
// Apply URL selections
if(S._urlSel&&S._urlSel.size){
@@ -741,7 +802,7 @@ _loadMore().then(()=>{
refreshCardSelections();
}
_updateURL();
-});
+})();
</script>
</body>
</html>