Initial commit
This commit is contained in:
@@ -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||' ')+'</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,""")+'" 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>
|
||||
Reference in New Issue
Block a user