Initial commit

This commit is contained in:
2026-06-30 18:30:24 +00:00
commit 67e0a90b15
54 changed files with 6184 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Library - The Dark Elite</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root { --bg:#060708; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min-height:100vh}
.container{max-width:1400px;margin:0 auto;padding:2rem}
header{display:flex;justify-content:space-between;align-items:center;padding-bottom:1.5rem;border-bottom:1px solid var(--border);margin-bottom:2rem;flex-wrap:wrap;gap:1rem}
header h1{margin:0;font-size:1.5rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.4)}
header a{color:var(--muted);text-decoration:none;font-size:0.85rem}
header a:hover{color:var(--accent)}
.controls{display:flex;gap:0.5rem;flex-wrap:wrap}
.controls input,.controls select{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.45rem 0.7rem;border-radius:4px;font-size:0.85rem;font-family:ui-monospace,monospace}
.controls input:focus,.controls select:focus{outline:none;border-color:var(--accent)}
.section-title{font-size:0.75rem;letter-spacing:0.25em;text-transform:uppercase;color:var(--accent);border-left:3px solid var(--accent);padding-left:0.75rem;margin:2rem 0 1rem 0}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem}
.card{background:linear-gradient(135deg,rgba(18,20,24,0.9),rgba(10,11,13,0.95));border:1px solid var(--border);border-radius:6px;padding:1rem;transition:all 0.15s;display:flex;gap:0.85rem;text-decoration:none;color:inherit;position:relative;overflow:hidden}
.card::before{content:"";position:absolute;top:0;left:0;width:3px;height:100%;background:var(--accent);opacity:0;transition:opacity 0.2s}
.card:hover{border-color:rgba(34,211,238,0.5);transform:translateY(-2px);box-shadow:0 0 25px rgba(34,211,238,0.18)}
.card:hover::before{opacity:1}
.card .icon{width:42px;height:42px;flex-shrink:0;border-radius:6px;background:rgba(34,211,238,0.08);display:flex;align-items:center;justify-content:center;border:1px solid rgba(34,211,238,0.2);overflow:hidden}
.card .icon img{width:100%;height:100%;object-fit:contain}
.card .info{min-width:0;flex:1}
.card .title{font-size:0.95rem;color:var(--text);margin-bottom:0.2rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.card .desc{font-size:0.78rem;color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.35}
.card .meta{font-size:0.65rem;color:var(--muted);font-family:ui-monospace,monospace;letter-spacing:0.05em;margin-top:0.4rem;display:flex;gap:0.6rem;flex-wrap:wrap}
.card .tag{display:inline-block;background:rgba(34,211,238,0.12);border:1px solid rgba(34,211,238,0.3);color:rgb(165,235,247);font-size:0.6rem;padding:0.1rem 0.4rem;border-radius:3px;font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.1em}
.empty{color:var(--muted);text-align:center;padding:3rem;font-style:italic;grid-column:1/-1}
.count{font-size:0.75rem;color:var(--muted);font-family:ui-monospace,monospace}
</style>
</head>
<body>
<div class="container">
<header>
<div><h1>Library</h1><div class="count" id="count">Loading...</div></div>
<div class="controls">
<select id="catFilter"><option value="">All Categories</option></select>
<select id="sortBy">
<option value="title">Sort: Title A-Z</option>
<option value="size-desc">Sort: Size (Largest)</option>
<option value="size-asc">Sort: Size (Smallest)</option>
</select>
<input type="text" id="search" placeholder="Filter...">
<a href="/">← Hub</a>
</div>
</header>
<div class="section-title" style="display:flex;justify-content:space-between;align-items:center;margin-top:0">
<span>Admin</span>
<button id="reloadBtn" style="background:rgba(18,20,24,0.9);border:1px solid rgba(80,90,100,0.3);color:#e5e7eb;padding:0.4rem 0.9rem;border-radius:4px;font-size:0.7rem;cursor:pointer;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace">Restart Kiwix</button>
</div>
<div id="dz" style="border:2px dashed rgba(34,211,238,0.4);border-radius:8px;padding:1.5rem;text-align:center;background:linear-gradient(135deg,rgba(18,20,24,0.7),rgba(10,11,13,0.85));margin-bottom:0.75rem;cursor:pointer;transition:all 0.15s">
<div style="color:rgb(34,211,238);font-size:0.95rem;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace">Drop ZIM files here or click to upload</div>
<div style="color:#9ca3af;font-size:0.8rem;margin-top:0.25rem">Multi-GB supported. After upload click Restart Kiwix.</div>
<input type="file" id="fileInput" accept=".zim" multiple style="display:none">
</div>
<div id="upProgress" style="margin-bottom:1.5rem;font-family:ui-monospace,monospace;font-size:0.8rem"></div>
<div class="section-title">Library</div>
<div id="grid" class="grid"><div class="empty">Loading catalog...</div></div>
</div>
<script>
let books = [];
const CATEGORIES = {
'medical|wiki.*med|hesperian|dentist': 'Medical',
'wikipedia|wikibooks|wikivoyage|wiktionary': 'Reference',
'stackexchange|stack_exchange|stack.overflow': 'Q&A',
'gutenberg|book': 'Books',
'khan|crashcourse|appropedia|saylor|teded|ted|education': 'Education',
'ifixit|repair': 'Repair',
'gardening|farm|agriculture': 'Agriculture',
'cooking|food|recipe': 'Food',
'ham|amateur|radio|comms': 'Comms',
'history|war': 'History',
'survival|prepper|shtf': 'Survival',
'outdoor|hunt|fish': 'Outdoors',
'diy|home.improvement': 'DIY'
};
function categoryFor(name) {
const n = (name || '').toLowerCase();
for (const [pattern, cat] of Object.entries(CATEGORIES)) {
if (new RegExp(pattern).test(n)) return cat;
}
return 'Other';
}
function fmtSize(b) {
if (!b) return '';
if (b < 1048576) return (b/1024).toFixed(0)+' KB';
if (b < 1073741824) return (b/1048576).toFixed(0)+' MB';
return (b/1073741824).toFixed(1)+' GB';
}
async function load() {
try {
const r = await fetch('/library/catalog/v2/entries?count=-1');
const text = await r.text();
const xml = new DOMParser().parseFromString(text, 'application/xml');
const entries = Array.from(xml.querySelectorAll('entry'));
books = entries.map(e => {
const name = e.querySelector('name')?.textContent || '';
const title = e.querySelector('title')?.textContent || name;
const summary = e.querySelector('summary')?.textContent || '';
const sizeEl = Array.from(e.querySelectorAll('link')).find(l => l.getAttribute('type') === 'application/x-zim');
const size = sizeEl ? parseInt(sizeEl.getAttribute('length') || '0') : 0;
const iconLink = Array.from(e.querySelectorAll('link')).find(l => l.getAttribute('rel') === 'http://opds-spec.org/image/thumbnail');
const icon = iconLink ? iconLink.getAttribute('href') : '';
const contentLink = Array.from(e.querySelectorAll('link')).find(l => (l.getAttribute('href')||'').startsWith('/library/content/'));
const url = contentLink ? contentLink.getAttribute('href') : ('/library/content/' + name);
const dlLink = Array.from(e.querySelectorAll('link')).find(l => (l.getAttribute('type')||'') === 'application/x-zim');
const filename = dlLink ? (dlLink.getAttribute('href')||'').split('/').pop() : (name + '.zim');
return { name, title, summary, size, icon, url, filename, category: categoryFor(name + ' ' + title) };
});
render();
populateCats();
} catch (e) {
document.getElementById('grid').innerHTML = '<div class="empty">Failed to load catalog: ' + e.message + '</div>';
}
}
function populateCats() {
const cats = [...new Set(books.map(b => b.category))].sort();
const sel = document.getElementById('catFilter');
cats.forEach(c => {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
sel.appendChild(opt);
});
}
function render() {
const q = document.getElementById('search').value.toLowerCase();
const cat = document.getElementById('catFilter').value;
const sort = document.getElementById('sortBy').value;
let filtered = books.filter(b => {
if (cat && b.category !== cat) return false;
if (q && !(b.title.toLowerCase().includes(q) || b.summary.toLowerCase().includes(q) || b.name.toLowerCase().includes(q))) return false;
return true;
});
filtered.sort((a, b) => {
if (sort === 'size-desc') return b.size - a.size;
if (sort === 'size-asc') return a.size - b.size;
return a.title.localeCompare(b.title);
});
document.getElementById('count').textContent = filtered.length + ' of ' + books.length + ' books';
const grid = document.getElementById('grid');
if (!filtered.length) {
grid.innerHTML = '<div class="empty">No matches.</div>';
return;
}
grid.innerHTML = filtered.map(function(b){
var iconHtml = b.icon ? '<img src="'+b.icon+'" alt="">' : '\ud83d\udcd6';
var sizeHtml = b.size ? '<span>'+fmtSize(b.size)+'</span>' : '';
return '<a class="card" href="'+b.url+'" target="_blank">' +
'<div class="icon">'+iconHtml+'</div>' +
'<div class="info">' +
'<div class="title">'+b.title+'</div>' +
'<div class="desc">'+(b.summary||'&nbsp;')+'</div>' +
'<div class="meta">' +
'<span class="tag">'+b.category+'</span>' +
sizeHtml +
'<span class="del-zim" data-fn="'+b.filename+'" data-title="'+b.title.replace(/"/g,"&quot;")+'" style="margin-left:auto;color:#9ca3af;cursor:pointer;font-size:0.6rem;letter-spacing:0.1em;text-transform:uppercase">Delete</span>' +
'</div>' +
'</div>' +
'</a>';
}).join('');
}
document.getElementById('search').addEventListener('input', render);
document.getElementById('catFilter').addEventListener('change', render);
document.getElementById('sortBy').addEventListener('change', render);
function showUpProgress(name){
const wrap=document.createElement("div");
wrap.style.cssText="margin:0.4rem 0;color:#22d3ee";
wrap.innerHTML=name+' <span class="pct">0%</span><div style="height:4px;background:rgba(80,90,100,0.2);border-radius:2px;overflow:hidden;margin-top:0.2rem"><div class="bar" style="height:100%;background:#22d3ee;width:0%;transition:width 0.2s"></div></div>';
document.getElementById("upProgress").appendChild(wrap);
return wrap;
}
function uploadOne(file){
return new Promise(resolve=>{
const wrap=showUpProgress(file.name);
const bar=wrap.querySelector(".bar");
const pct=wrap.querySelector(".pct");
const xhr=new XMLHttpRequest();
xhr.open("POST","/library-admin/api/upload");
xhr.upload.onprogress=e=>{if(e.lengthComputable){const p=Math.round(e.loaded/e.total*100);bar.style.width=p+"%";pct.textContent=p+"%";}};
xhr.onload=()=>{if(xhr.status===200){pct.textContent="DONE";bar.style.background="#22c55e";}else{pct.textContent="FAIL";bar.style.background="#dc2626";}resolve();};
const fd=new FormData(); fd.append("file",file); xhr.send(fd);
});
}
async function uploadFiles(files){
for(const f of files){if(!f.name.toLowerCase().endsWith(".zim"))continue; await uploadOne(f);}
}
document.getElementById("dz").addEventListener("click",e=>{if(e.target.tagName!=="INPUT")document.getElementById("fileInput").click();});
document.getElementById("fileInput").addEventListener("change",e=>uploadFiles(e.target.files));
document.getElementById("dz").addEventListener("dragover",e=>{e.preventDefault();e.currentTarget.style.borderColor="#22d3ee";});
document.getElementById("dz").addEventListener("dragleave",e=>{e.currentTarget.style.borderColor="rgba(34,211,238,0.4)";});
document.getElementById("dz").addEventListener("drop",e=>{e.preventDefault();e.currentTarget.style.borderColor="rgba(34,211,238,0.4)";uploadFiles(e.dataTransfer.files);});
document.getElementById("reloadBtn").addEventListener("click",async ()=>{
const btn=document.getElementById("reloadBtn"); btn.textContent="Restarting..."; btn.disabled=true;
try{const r=await fetch("/library-admin/api/reload",{method:"POST"}); btn.textContent=r.ok?"Restarted - reloading catalog...":"Failed";
if(r.ok){setTimeout(()=>{btn.textContent="Restart Kiwix";btn.disabled=false;load();},3000);}
else{setTimeout(()=>{btn.textContent="Restart Kiwix";btn.disabled=false;},2000);}
}catch(e){btn.textContent="Error";setTimeout(()=>{btn.textContent="Restart Kiwix";btn.disabled=false;},2000);}
});
document.getElementById("grid").addEventListener("click", async function(e){
const t = e.target.closest(".del-zim");
if(!t) return;
e.preventDefault(); e.stopPropagation();
const fn = t.dataset.fn;
if(!confirm("Delete "+t.dataset.title+"?\nFile: "+fn+"\n\nKiwix will need restart after.")) return;
t.textContent = "Deleting...";
try {
const r = await fetch("/library-admin/api/delete/"+encodeURIComponent(fn), {method:"DELETE"});
if(r.ok){ t.textContent = "Deleted - restart Kiwix"; t.style.color = "#22c55e"; }
else { t.textContent = "Failed"; t.style.color = "#dc2626"; }
} catch(err) { t.textContent = "Error"; t.style.color = "#dc2626"; }
});
// Make the delete look like a hover state
const css = document.createElement("style");
css.textContent = ".del-zim:hover{color:#dc2626 !important}";
document.head.appendChild(css);
load();
</script>
</body>
</html>