aboutsummaryrefslogtreecommitdiffstats
path: root/templates/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'templates/index.html')
-rw-r--r--templates/index.html534
1 files changed, 534 insertions, 0 deletions
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..9f48eb7
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,534 @@
+<!DOCTYPE html>
+<html lang="de">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>MedMan</title>
+<style>
+:root {
+ --bg: #0f0f1a; --surface: #181825; --surface2: #1e1e2e;
+ --border: #313244; --text: #cdd6f4; --text2: #a6adc8;
+ --accent: #89b4fa; --red: #f38ba8; --green: #a6e3a1;
+ --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; }
+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 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); }
+.btn-a { background:var(--accent)!important; color:#000!important; border-color:var(--accent)!important; font-weight:600; }
+.btn-a:hover { filter:brightness(1.1); }
+.btn-g { background:var(--green)!important; color:#000!important; border-color:var(--green)!important; font-weight:600; }
+.btn-g:hover { filter:brightness(1.1); }
+#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.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.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 { flex:1; display:flex; overflow:hidden; }
+#side { 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; }
+
+.section { border:1px solid var(--border); border-radius:7px; overflow:hidden; }
+.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); }
+.sec-h .arr { font-size:0.65rem; transition:transform 0.2s; color:var(--text2); }
+.sec-h.open .arr { transform:rotate(90deg); }
+.sec-b { display:none; padding:8px; }
+.sec-b.on { display:block; }
+
+#tag-list { display:flex; flex-wrap:wrap; gap:3px; }
+.tc { display:inline-flex; align-items:center; gap:3px; padding:2px 7px; border-radius:999px; font-size:0.72rem; cursor:pointer; transition:all 0.15s; border:1px solid var(--border); user-select:none; background:var(--surface2); color:var(--text); }
+.tc:hover { border-color:var(--accent); }
+.tc.sel { border-color:#fff; background:var(--accent); color:#000; }
+.tc .rm { font-size:0.6rem; opacity:0.4; cursor:pointer; margin-left:1px; }
+.tc .rm:hover { opacity:1; }
+.ti-row { display:flex; gap:3px; margin-top:6px; }
+.ti-row input { flex:1; background:var(--bg); border:1px solid var(--border); border-radius:4px; padding:4px 6px; color:var(--text); font-size:0.72rem; }
+.ti-row button { background:var(--accent); color:#000; border:none; border-radius:4px; padding:4px 8px; cursor:pointer; font-weight:600; font-size:0.72rem; }
+
+#si-row { margin-bottom:6px; }
+#si-row input { width:100%; background:var(--bg); border:1px solid var(--border); border-radius:4px; padding:4px 6px; color:var(--text); font-size:0.72rem; }
+#suggestions { display:flex; flex-wrap:wrap; gap:3px; margin-bottom:4px; }
+#suggestions .tc { cursor:pointer; opacity:0.7; }
+#suggestions .tc:hover { opacity:1; border-color:var(--accent); }
+
+.srch-acts { display:flex; flex-direction:column; gap:3px; margin-top:4px; }
+.srch-acts button { border:none; border-radius:4px; padding:3px 8px; cursor:pointer; font-weight:600; font-size:0.68rem; color:#000; }
+.srch-acts .s { background:var(--green); }
+.srch-acts .c { background:var(--red); }
+
+.sel-acts { display:flex; flex-direction:column; gap:3px; }
+.sel-acts button { background:var(--surface2); color:var(--text2); border:1px solid var(--border); border-radius:4px; padding:3px 8px; cursor:pointer; font-size:0.68rem; text-align:left; }
+.sel-acts button:hover { color:var(--text); border-color:var(--accent); }
+
+#dd-info { font-size:0.62rem; color:var(--text2); }
+#dd-info .dd { margin:1px 0; }
+
+#content { flex:1; overflow-y:auto; padding:12px; }
+#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); }
+.card.sel { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent); }
+.card .th { width:100%; aspect-ratio:4/3; object-fit:cover; background:var(--surface2); display:flex; align-items:center; justify-content:center; font-size:1.6rem; }
+.card .th img { width:100%; height:100%; object-fit:cover; }
+.card .info { padding:6px 7px; }
+.card .hash { font-size:0.65rem; font-weight:500; word-break:break-all; line-height:1.2; font-family:monospace; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; color:var(--accent); }
+.card .meta { font-size:0.58rem; color:var(--text2); margin-top:1px; }
+.card .badge { position:absolute; top:4px; right:4px; background:rgba(0,0,0,0.75); color:var(--text); padding:1px 5px; border-radius:3px; font-size:0.5rem; text-transform:uppercase; letter-spacing:0.03em; }
+.card .ct { display:flex; flex-wrap:wrap; gap:2px; padding:0 7px 4px; }
+.card .ct .mt { font-size:0.5rem; padding:1px 4px; border-radius:999px; background:var(--surface2); color:var(--text2); }
+.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); }
+
+#pg { display:flex; justify-content:center; align-items:center; gap:6px; margin-top:12px; }
+#pg button { background:var(--surface); color:var(--text); border:1px solid var(--border); padding:3px 10px; border-radius:4px; cursor:pointer; font-size:0.7rem; }
+#pg button:hover { border-color:var(--accent); }
+#pg span { font-size:0.7rem; color:var(--text2); }
+
+.status { position:fixed; bottom:16px; right:16px; background:var(--surface2); border:1px solid var(--border); padding:8px 14px; border-radius:6px; font-size:0.75rem; z-index:300; opacity:0; transition:opacity 0.2s; pointer-events:none; }
+.status.on { opacity:1; }
+
+.modal { display:none; position:fixed; inset:0; z-index:200; background:rgba(0,0,0,0.82); align-items:center; justify-content:center; }
+.modal.on { display:flex; }
+.mc { background:var(--surface); border:1px solid var(--border); border-radius:10px; max-width:90vw; max-height:90vh; overflow-y:auto; padding:18px; min-width:340px; position:relative; }
+.mc .cb { position:absolute; top:5px; right:9px; background:none; border:none; color:var(--text); font-size:1.2rem; cursor:pointer; }
+.mc img, .mc video { max-width:100%; max-height:48vh; border-radius:6px; display:block; margin:0 auto; }
+.mc h2 { margin-bottom:6px; font-size:0.8rem; word-break:break-all; font-family:monospace; color:var(--accent); }
+.mc .ft { display:flex; flex-wrap:wrap; gap:3px; margin-top:6px; }
+.mc .mrow { margin-top:5px; font-size:0.68rem; color:var(--text2); }
+.mc .ar { margin-top:8px; display:flex; gap:4px; flex-wrap:wrap; }
+.mc select, .mc input { background:var(--surface2); color:var(--text); border:1px solid var(--border); border-radius:3px; padding:3px 6px; font-size:0.72rem; }
+.mc button.act { background:var(--accent); color:#000; border:none; border-radius:3px; padding:3px 8px; cursor:pointer; font-weight:600; font-size:0.7rem; }
+.mc button.act:hover { filter:brightness(1.1); }
+.mc button.dng { background:var(--red); }
+
+.mc .nav-row { display:flex; align-items:center; gap:6px; margin-top:12px; padding-top:8px; border-top:1px solid var(--border); }
+.mc .nav-row button { flex:1; background:var(--surface2); color:var(--text); border:1px solid var(--border); border-radius:4px; padding:5px 10px; cursor:pointer; font-size:0.7rem; }
+.mc .nav-row button:hover { border-color:var(--accent); }
+.mc .nav-row button:disabled { opacity:0.3; cursor:default; }
+.mc .nav-row span { font-size:0.62rem; color:var(--text2); white-space:nowrap; min-width:60px; text-align:center; }
+</style>
+</head>
+<body>
+<header>
+ <h1>🧩 MedMan</h1>
+ <button id="b-up" class="btn-a">📤 Upload</button>
+ <button id="b-scan" class="btn-g">🔄 Quellen scannen</button>
+ <button id="b-exp">📦 Export</button>
+ <label class="btn" style="cursor:pointer;">📥 Import <input type="file" id="f-imp" accept=".json" style="display:none;"></label>
+</header>
+
+<div id="sel-bar">
+ <span id="sel-n">0 ausgewählt</span>
+ <button id="sel-tag-btn">🏷 Tags</button>
+ <button id="sel-mv-btn">📁 Verschieben</button>
+ <button id="sel-del-btn" style="background:rgba(180,0,0,0.4)">🗑 Löschen</button>
+ <button id="sel-clr-btn">✕</button>
+</div>
+
+<div id="up-area">
+ <input type="file" id="up-in" multiple>
+ <button id="up-go" class="btn-a">Hochladen</button>
+ <span class="prog" id="up-prog"></span>
+ <button id="up-cancel">Abbrechen</button>
+</div>
+
+<main>
+ <aside id="side">
+
+ <!-- Tags section -->
+ <div class="section" id="sec-tags">
+ <div class="sec-h open" data-sec="tags"><span class="t">🏷 Tags</span><span class="arr">▶</span></div>
+ <div class="sec-b on" id="secb-tags">
+ <div id="tag-list"></div>
+ <div class="ti-row">
+ <input id="ti-in" type="text" placeholder="Neuer Tag..." maxlength="50">
+ <button id="ti-btn">+</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Search section -->
+ <div class="section" id="sec-search">
+ <div class="sec-h" data-sec="search"><span class="t">🔍 Suche</span><span class="arr">▶</span></div>
+ <div class="sec-b" id="secb-search">
+ <div id="si-row">
+ <input id="si-in" type="text" placeholder="Tag-Suche (Substring)..." autocomplete="off">
+ </div>
+ <div id="suggestions"></div>
+ <div style="font-size:0.62rem;color:var(--text2);margin:2px 0;">Ausgewählte Tags:</div>
+ <div id="search-tags"></div>
+ <div class="srch-acts">
+ <button class="s" id="b-search">🔍 Dateien finden</button>
+ <button class="c" id="b-clear">✕ Suche löschen</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Selection section -->
+ <div class="section" id="sec-sel">
+ <div class="sec-h" data-sec="sel"><span class="t">☑ Auswahl</span><span class="arr">▶</span></div>
+ <div class="sec-b" id="secb-sel">
+ <div style="font-size:0.65rem;color:var(--text2);margin-bottom:4px;" id="sel-info">Keine Auswahl</div>
+ <div class="sel-acts">
+ <button id="b-sel-all">✅ Alle auswählen</button>
+ <button id="b-sel-tag">🏷 Tags zuweisen</button>
+ <button id="b-sel-untag">🚫 Tags entfernen</button>
+ <button id="b-sel-mv">📁 Verschieben</button>
+ <button id="b-sel-del" style="color:var(--red)">🗑 Löschen</button>
+ <button id="b-sel-clr">✕ Auswahl aufheben</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Data dirs section -->
+ <div class="section">
+ <div class="sec-h open" data-sec="dd"><span class="t">📊 Daten</span><span class="arr">▶</span></div>
+ <div class="sec-b on" id="secb-dd">
+ <div id="dd-info">Lade...</div>
+ <div style="font-size:0.58rem;color:var(--text2);margin-top:6px;">
+ <a href="/api/tags/export" target="_blank" style="color:var(--accent)">Roh-Export (JSON)</a>
+ </div>
+ </div>
+ </div>
+ </aside>
+
+ <section id="content">
+ <div id="grid"></div>
+ <div id="empty" style="color:var(--text2);text-align:center;padding:40px;display:none;">Keine Dateien gefunden.</div>
+ <div id="pg"></div>
+ </section>
+</main>
+
+<div class="status" id="status"></div>
+<div class="modal" id="prev-modal"><div class="mc"><button class="cb" id="m-close">&times;</button><div id="m-body"></div></div></div>
+<div class="modal" id="conf-modal"><div class="mc" style="min-width:260px;text-align:center;"><p id="conf-msg"></p><button id="conf-yes" style="background:var(--red);color:#fff;border:none;padding:6px 16px;border-radius:4px;margin:6px;cursor:pointer;">Ja</button><button id="conf-no" style="background:var(--border);color:var(--text);border:none;padding:6px 16px;border-radius:4px;margin:6px;cursor:pointer;">Nein</button></div></div>
+
+<script>
+const S = {files:[],disp:[],page:0,ps:50,sel:new Set(),tags:[],stags:[],sm:false,ph:null,_navKd:null};
+
+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();}
+
+// --- Section toggling ---
+document.querySelectorAll('.sec-h').forEach(h=>{
+ h.addEventListener('click',()=>{
+ let sec=h.dataset.sec, b=document.getElementById('secb-'+sec);
+ h.classList.toggle('open'); b.classList.toggle('on');
+ });
+});
+
+// --- Tags ---
+async function lt(){S.tags=await api('/api/tags');rTagList();rSearchSuggestions('');}
+function rTagList(){
+ let l=document.getElementById('tag-list');
+ l.innerHTML=S.tags.map(t=>{
+ let sel=S.stags.includes(t.name)?'sel':'';
+ return `<span class="tc ${sel}" data-tag="${t.name}">${t.name}<span class="rm" data-tid="${t.id}">&times;</span></span>`;
+ }).join('');
+ l.querySelectorAll('.tc').forEach(c=>{c.addEventListener('click',e=>{
+ if(e.target.classList.contains('rm'))return;
+ toggleSearchTag(c.dataset.tag);
+ });});
+ l.querySelectorAll('.rm').forEach(b=>{b.addEventListener('click',async e=>{
+ e.stopPropagation(); await api(`/api/tags/${b.dataset.tid}`,{method:'DELETE'}); await lt(); if(S.files.length) await lf(); st('Tag gelöscht');
+ });});
+}
+
+function toggleSearchTag(name){
+ let i=S.stags.indexOf(name);
+ if(i>=0) S.stags.splice(i,1); else S.stags.push(name);
+ rTagList(); rSearchTags();
+}
+
+// --- Search ---
+function rSearchTags(){
+ document.getElementById('search-tags').innerHTML=S.stags.map(n=>
+ `<span class="tc sel">${n}<span class="rm" data-rm="${n}">&times;</span></span>`
+ ).join('');
+ document.querySelectorAll('#search-tags .rm').forEach(b=>{b.addEventListener('click',e=>{
+ e.stopPropagation(); S.stags=S.stags.filter(x=>x!==b.dataset.rm); rTagList(); rSearchTags();
+ });});
+}
+
+function rSearchSuggestions(q){
+ let el=document.getElementById('suggestions');
+ if(!q){el.innerHTML='';return;}
+ let re=new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'i');
+ let matches=S.tags.filter(t=>re.test(t.name)&&!S.stags.includes(t.name)).slice(0,12);
+ el.innerHTML=matches.map(t=>`<span class="tc" data-tag="${t.name}">${t.name}</span>`).join('');
+ el.querySelectorAll('.tc').forEach(c=>{c.addEventListener('click',()=>{
+ S.stags.push(c.dataset.tag); rTagList(); rSearchTags(); rSearchSuggestions('');
+ document.getElementById('si-in').value='';
+ });});
+}
+
+document.getElementById('si-in').addEventListener('input',function(){rSearchSuggestions(this.value.trim());});
+
+document.getElementById('b-search').addEventListener('click',()=>{
+ if(!S.stags.length){st('Keine Tags ausgewählt');return;}
+ S.sm=true; let t=S.stags.join(',');
+ api(`/api/search?tags=${encodeURIComponent(t)}`).then(d=>{S.files=d;S.page=0;rGrid();}).catch(e=>{st('Suche fehlgeschlagen');S.files=[];rGrid();});
+});
+document.getElementById('b-clear').addEventListener('click',()=>{S.sm=false;S.stags=[];S.page=0;rTagList();rSearchTags();lf();});
+
+// --- Tag create ---
+document.getElementById('ti-btn').addEventListener('click',async()=>{
+ let i=document.getElementById('ti-in'),n=i.value.trim(); if(!n)return;
+ await api('/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})});
+ i.value=''; await lt(); st('Tag erstellt');
+});
+document.getElementById('ti-in').addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('ti-btn').click();});
+
+// --- Files ---
+async function lf(tf=null){let u='/api/files'+(tf?`?tag=${encodeURIComponent(tf)}`:'');S.files=await api(u);S.page=0;rGrid();}
+
+function rGrid(){
+ let s=S.page*S.ps; S.disp=S.files.slice(s,s+S.ps);
+ let g=document.getElementById('grid'),e=document.getElementById('empty');
+ if(!S.files.length){g.innerHTML='';e.style.display='block';document.getElementById('pg').innerHTML='';updateSelUI();return;}
+ e.style.display='none';
+ g.innerHTML=S.disp.map(f=>{
+ let sel=S.sel.has(f.hash)?'sel':'',mt=f.mt||'other',
+ tg=(f.tags||[]).map(t=>`<span class="mt">${t.name}</span>`).join(''),
+ sz=fs(f.size), ico={video:'🎬',image:'🖼️',audio:'🎵',doc:'📄',other:'📁'}[mt]||'📁',
+ ts=mt==='audio'?ico:`<img src="/thumb/${f.hash}" loading="lazy" alt="">`;
+ return `<div class="card ${sel}" data-hash="${f.hash}">
+ <div class="chk">✓</div>
+ <div class="th">${ts}</div><div class="badge">${mt}</div>
+ <div class="info"><div class="hash" title="${f.hash}">${f.hash}</div><div class="meta">${sz} · DD${f.didx}</div></div>
+ <div class="ct">${tg}</div></div>`;
+ }).join('');
+ g.querySelectorAll('.card').forEach(c=>{
+ c.addEventListener('click',e=>{
+ let h=c.dataset.hash;
+ if(e.ctrlKey||e.metaKey){toggleSel(h,c);return;}
+ // If the click was on the checkbox itself: toggle selection, don't open
+ if(e.target.classList.contains('chk')||e.target.closest('.chk')){
+ toggleSel(h,c); e.stopPropagation(); return;
+ }
+ op(h);
+ });
+ });
+ let tp=Math.ceil(S.files.length/S.ps);
+ document.getElementById('pg').innerHTML=tp<=1?'':`<button onclick="S.page=Math.max(0,S.page-1);rGrid();" ${S.page===0?'disabled':''}>←</button><span>${S.page+1}/${tp}</span><button onclick="S.page=Math.min(${tp-1},S.page+1);rGrid();" ${S.page>=tp-1?'disabled':''}>→</button>`;
+ updateSelUI();
+}
+
+function fs(b){if(!b)return'';if(b<1024)return b+' B';if(b<1048576)return (b/1024).toFixed(1)+' KB';if(b<1073741824)return (b/1048576).toFixed(1)+' MB';return (b/1073741824).toFixed(1)+' GB';}
+
+// --- Selection ---
+function toggleSel(h,cardEl){
+ if(S.sel.has(h)){S.sel.delete(h);if(cardEl)cardEl.classList.remove('sel');}
+ else{S.sel.add(h);if(cardEl)cardEl.classList.add('sel');}
+ updateSelUI();
+}
+function updateSelUI(){
+ let n=S.sel.size;
+ document.getElementById('sel-n').textContent=n+' ausgewählt';
+ document.getElementById('sel-bar').classList.toggle('on',n>0);
+ document.getElementById('sel-info').textContent=n?n+' Dateien ausgewählt':'Keine Auswahl';
+}
+function clearSel(){S.sel.clear();updateSelUI();rGrid();}
+function selAll(){let pageStart=S.page*S.ps;for(let f of S.disp){S.sel.add(f.hash);}updateSelUI();rGrid();}
+
+// --- Navigation helpers ---
+function _clearNavKd(){
+ if(S._navKd){document.removeEventListener('keydown',S._navKd);S._navKd=null;}
+}
+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;
+ let page=Math.floor(nidx/S.ps);
+ if(page!==S.page){S.page=page;rGrid();}
+ op(S.files[nidx].hash);
+}
+
+// --- Selection actions ---
+async function selTag(){
+ if(!S.sel.size){st('Keine Dateien ausgewählt');return;}
+ let opts=S.tags.map(t=>`<option value="${t.id}">${t.name}</option>`).join('');
+ document.getElementById('m-body').innerHTML=
+ `<h3>${S.sel.size} Dateien: Tag zuweisen</h3>
+ <select id="stag-sel" style="width:100%;margin-top:6px;"><option value="">-- Tag wählen --</option>${opts}</select>
+ <div style="margin-top:6px;"><button class="act" id="stag-go">Zuweisen</button></div>`;
+ document.getElementById('prev-modal').classList.add('on');
+ document.getElementById('stag-go').onclick=async()=>{
+ let s=document.getElementById('stag-sel'); if(!s.value)return;
+ let tid=parseInt(s.value),c=0;
+ for(let h of[...S.sel]){try{await api(`/api/files/${h}/tags`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tid})});c++;}catch(e){}}
+ document.getElementById('prev-modal').classList.remove('on'); st(`${c} getaggt`); await lf(S.sm?S.stags[0]:null);
+ };
+}
+
+async function selUntag(){
+ if(!S.sel.size){st('Keine Dateien ausgewählt');return;}
+ let allTids=new Map();
+ let promises=[...S.sel].map(h=>api(`/api/files/${h}/tags`));
+ let results=await Promise.allSettled(promises);
+ for(let r of results){if(r.status==='fulfilled'){for(let t of r.value){allTids.set(t.id,t.name);}}}
+ if(!allTids.size){st('Keine Tags auf der Auswahl');return;}
+ let opts=[...allTids.entries()].map(([id,name])=>`<option value="${id}">${name}</option>`).join('');
+ document.getElementById('m-body').innerHTML=
+ `<h3>${S.sel.size} Dateien: Tag entfernen</h3>
+ <select id="suntag-sel" style="width:100%;margin-top:6px;"><option value="">-- Tag wählen --</option>${opts}</select>
+ <div style="margin-top:6px;"><button class="act dng" id="suntag-go">Entfernen</button></div>`;
+ document.getElementById('prev-modal').classList.add('on');
+ document.getElementById('suntag-go').onclick=async()=>{
+ let s=document.getElementById('suntag-sel'); if(!s.value)return;
+ let tid=parseInt(s.value),c=0;
+ for(let h of[...S.sel]){try{await api(`/api/files/${h}/tags/${tid}`,{method:'DELETE'});c++;}catch(e){}}
+ document.getElementById('prev-modal').classList.remove('on'); st(`${c} Tags entfernt`); await lf(S.sm?S.stags[0]:null);
+ };
+}
+
+async function selMove(){
+ if(!S.sel.size){st('Keine Dateien ausgewählt');return;}
+ let dd=await api('/api/dat'),opts=dd.dat.map(d=>`<option value="${d.i}">DD${d.i} (${d.path})</option>`).join('');
+ document.getElementById('m-body').innerHTML=
+ `<h3>${S.sel.size} Dateien verschieben</h3>
+ <select id="smv-sel" style="width:100%;margin-top:6px;"><option value="">-- Ziel --</option>${opts}</select>
+ <div style="margin-top:6px;"><button class="act" id="smv-go">Verschieben</button></div>`;
+ document.getElementById('prev-modal').classList.add('on');
+ document.getElementById('smv-go').onclick=async()=>{
+ let s=document.getElementById('smv-sel'); if(!s.value)return;
+ let ti=parseInt(s.value),c=0;
+ for(let h of[...S.sel]){try{await api(`/api/files/${h}/move`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({didx:ti})});c++;}catch(e){}}
+ document.getElementById('prev-modal').classList.remove('on'); clearSel(); st(`${c} verschoben`); await lf(S.sm?S.stags[0]:null);
+ };
+}
+
+async function selDel(){
+ if(!S.sel.size){st('Keine Dateien ausgewählt');return;}
+ sc(`${S.sel.size} Dateien wirklich löschen?`,async()=>{
+ let c=0; for(let h of[...S.sel]){try{await api(`/api/files/${h}`,{method:'DELETE'});c++;}catch(e){}}
+ clearSel(); st(`${c} gelöscht`); await lf(S.sm?S.stags[0]:null);
+ });
+}
+
+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();};}
+
+// --- Single file preview ---
+async function op(h){
+ _clearNavKd();
+ 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'),
+ 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>`;
+ else if(mt==='audio')ht+=`<audio controls src="/raw/${h}" style="width:100%;"></audio>`;
+ else ht+=`<p style="color:var(--text2)">Keine Vorschau</p>`;
+ ht+=`<div class="mrow">Typ: ${mt} · Größe: ${fs(f.size)} · DD${f.didx}<br>${f.w&&f.h?`${f.w}×${f.h} · `:''}${f.dur?parseFloat(f.dur).toFixed(1)+'s · ':''}${f.ts||''}</div>`;
+ ht+=`<div class="ft">`;
+ for(let t of ft)ht+=`<span class="tc" style="background:var(--surface2)">${t.name}<span class="rm" data-tid="${t.id}" data-hash="${h}">&times;</span></span>`;
+ ht+=`</div>`;
+ ht+=`<div class="ar"><select id="ats"><option value="">-- Tag hinzufügen --</option>`;
+ for(let t of S.tags)if(!ft.some(x=>x.id===t.id))ht+=`<option value="${t.id}">${t.name}</option>`;
+ ht+=`</select><button class="act" id="atb">+</button></div>`;
+ ht+=`<div class="ar"><select id="mds"><option value="">-- Verschieben nach --</option>`;
+ for(let d of dd.dat)if(d.i!==f.didx)ht+=`<option value="${d.i}">DD${d.i}</option>`;
+ ht+=`</select><button class="act" id="mvb">Verschieben</button></div>`;
+ ht+=`<div class="ar"><button class="act dng" id="delb">🗑 Löschen</button></div>`;
+ ht+=`<p style="margin-top:6px;font-size:0.6rem;"><a href="/raw/${h}" target="_blank" style="color:var(--accent)">Direktlink</a></p>`;
+
+ // ── Prev / Next navigation row ────────────────────────────────────
+ let idx=S.files.findIndex(x=>x.hash===h);
+ let hasPrev=idx>0, hasNext=idx>=0 && idx<S.files.length-1;
+ let prevLabel=hasPrev?`← ${S.files[idx-1].hash.slice(0,8)}…`:'← ──';
+ let nextLabel=hasNext?`${S.files[idx+1].hash.slice(0,8)}… →`:'── →';
+ ht+=`<div class="nav-row">
+ <button id="nav-prev" ${hasPrev?'':'disabled'}>${prevLabel}</button>
+ <span>${idx+1} / ${S.files.length}</span>
+ <button id="nav-next" ${hasNext?'':'disabled'}>${nextLabel}</button>
+ </div>`;
+
+ document.getElementById('m-body').innerHTML=ht; document.getElementById('prev-modal').classList.add('on');
+
+ // Prev / Next button handlers
+ let np=document.getElementById('nav-prev'), nn=document.getElementById('nav-next');
+ if(np)np.onclick=e=>{e.stopPropagation();opNav(-1);};
+ if(nn)nn.onclick=e=>{e.stopPropagation();opNav(1);};
+
+ // Keyboard left/right navigation while modal is open
+ S._navKd=(e)=>{
+ if(e.key==='ArrowLeft'){e.preventDefault();opNav(-1);}
+ else if(e.key==='ArrowRight'){e.preventDefault();opNav(1);}
+ };
+ document.addEventListener('keydown',S._navKd);
+
+ let ab=document.getElementById('atb'); if(ab)ab.onclick=async()=>{
+ let s=document.getElementById('ats'); if(!s.value)return;
+ await api(`/api/files/${h}/tags`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tid:parseInt(s.value)})});
+ st('Tag hinzugefügt'); await lf(S.sm?S.stags[0]:null); op(h);};
+ let mb=document.getElementById('mvb'); if(mb)mb.onclick=async()=>{
+ let s=document.getElementById('mds'); if(!s.value)return;
+ await api(`/api/files/${h}/move`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({didx:parseInt(s.value)})});
+ st('Verschoben'); await lf(S.sm?S.stags[0]:null); op(h);};
+ let db=document.getElementById('delb'); if(db)db.onclick=()=>{
+ sc(`Diese Datei wirklich löschen?`,async()=>{await api(`/api/files/${h}`,{method:'DELETE'}); st('Gelöscht'); S.sel.delete(h); document.getElementById('prev-modal').classList.remove('on'); await lf(S.sm?S.stags[0]:null);});};
+ document.querySelectorAll('#m-body .rm').forEach(b=>{b.onclick=async e=>{e.stopPropagation();
+ await api(`/api/files/${h}/tags/${b.dataset.tid}`,{method:'DELETE'}); st('Tag entfernt'); await lf(S.sm?S.stags[0]:null); op(h);};});
+}
+
+// --- Data dirs ---
+async function ldd(){let d=await api('/api/dat');document.getElementById('dd-info').innerHTML=d.dat.map(x=>`<div class="dd" style="margin-bottom:4px;"><strong>DD${x.i}</strong> (${(x.w*100).toFixed(0)}%)<br>${x.n} Dateien, ${(x.bytes/(1024*1024*1024)).toFixed(1)} GB<br><span style="opacity:0.5;word-break:break-all;font-size:0.55rem;">${x.path}</span></div>`).join('')+(d.src.length?`<div style="margin-top:6px;"><strong>Quellen:</strong><br>${d.src.map(s=>'<span style="opacity:0.5;font-size:0.55rem;">'+s+'</span>').join('<br>')}</div>`:'');}
+
+// --- Header buttons ---
+document.getElementById('b-up').addEventListener('click',()=>document.getElementById('up-area').classList.toggle('on'));
+document.getElementById('up-cancel').addEventListener('click',()=>document.getElementById('up-area').classList.remove('on'));
+document.getElementById('up-go').addEventListener('click',async()=>{
+ let i=document.getElementById('up-in'),p=document.getElementById('up-prog'); if(!i.files.length)return;
+ let fd=new FormData(); for(let f of i.files)fd.append('file',f);
+ p.textContent=`Lade ${i.files.length} Datei(en)...`;
+ try{let r=await fetch('/api/upload',{method:'POST',body:fd}),d=await r.json(),ok=d.results.filter(x=>x.status==='ok').length;
+ p.textContent=`${ok} von ${d.results.length} hochgeladen.`; i.value=''; await lf(); await ldd();
+ setTimeout(()=>p.textContent='',3000);}catch(e){p.textContent='Fehler: '+e.message;}
+});
+document.getElementById('b-scan').addEventListener('click',async()=>{st('Scanne...');let r=await api('/api/scan',{method:'POST'});st(`Scan: ${r.ingested} neu`);await lf();await ldd();});
+document.getElementById('b-exp').addEventListener('click',async()=>{
+ let d=await api('/api/tags/export'),bl=new Blob([JSON.stringify(d,null,2)],{type:'application/json'}),u=URL.createObjectURL(bl),a=document.createElement('a');
+ a.href=u;a.download='medman_tags_'+new Date().toISOString().slice(0,10)+'.json';a.click();URL.revokeObjectURL(u);st('Exportiert');
+});
+document.getElementById('f-imp').addEventListener('change',async e=>{
+ let f=e.target.files[0];if(!f)return;let t=await f.text(),d=JSON.parse(t),r=await api('/api/tags/import',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)});
+ await lt();if(S.files.length)await lf();st(`${r.imported||0} importiert`);
+});
+
+// --- Selection bar ---
+document.getElementById('sel-tag-btn').addEventListener('click',selTag);
+document.getElementById('sel-mv-btn').addEventListener('click',selMove);
+document.getElementById('sel-del-btn').addEventListener('click',selDel);
+document.getElementById('sel-clr-btn').addEventListener('click',clearSel);
+
+// --- Selection section ---
+document.getElementById('b-sel-all').addEventListener('click',selAll);
+document.getElementById('b-sel-tag').addEventListener('click',selTag);
+document.getElementById('b-sel-untag').addEventListener('click',selUntag);
+document.getElementById('b-sel-mv').addEventListener('click',selMove);
+document.getElementById('b-sel-del').addEventListener('click',selDel);
+document.getElementById('b-sel-clr').addEventListener('click',clearSel);
+
+// --- Modals ---
+function closeModal(){
+ _clearNavKd();
+ document.getElementById('prev-modal').classList.remove('on');
+ S.ph=null;
+ lf(S.sm?S.stags[0]:null);
+}
+document.getElementById('m-close').addEventListener('click',closeModal);
+document.getElementById('prev-modal').addEventListener('click',e=>{if(e.target===e.currentTarget)closeModal();});
+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();}});
+
+// --- Init ---
+lt(); lf(); ldd();
+</script>
+</body>
+</html>