Initial commit
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PDF Library - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#060708; --panel:#0f1115; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; --danger:#dc2626; }
|
||||
*{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:1200px;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}
|
||||
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)}
|
||||
.dropzone{border:2px dashed rgba(34,211,238,0.4);border-radius:8px;padding:2rem;text-align:center;background:linear-gradient(135deg,rgba(18,20,24,0.7),rgba(10,11,13,0.85));margin-bottom:2rem;cursor:pointer;transition:all 0.15s}
|
||||
.dropzone:hover,.dropzone.drag{border-color:var(--accent);box-shadow:0 0 20px rgba(34,211,238,0.15)}
|
||||
.dropzone p{margin:0.3rem 0;color:var(--muted);font-size:0.9rem}
|
||||
.dropzone .big{color:var(--accent);font-size:1.1rem;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace}
|
||||
.dropzone input[type=file]{display:none}
|
||||
.fields{display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;margin-top:1rem}
|
||||
.fields input{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.5rem 0.8rem;border-radius:4px;font-size:0.85rem}
|
||||
.fields input: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-bottom:1rem;display:flex;justify-content:space-between;align-items:center}
|
||||
.search{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.4rem 0.7rem;border-radius:4px;font-size:0.8rem;font-family:ui-monospace,monospace}
|
||||
.search:focus{outline:none;border-color:var(--accent)}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,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;position:relative;overflow:hidden}
|
||||
.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 .title{font-size:0.95rem;color:var(--text);margin-bottom:0.3rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-weight:500}
|
||||
.card .meta{font-size:0.7rem;color:var(--muted);font-family:ui-monospace,monospace;letter-spacing:0.05em}
|
||||
.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.65rem;padding:0.15rem 0.5rem;border-radius:3px;margin-top:0.5rem;font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.1em}
|
||||
.card .actions{display:flex;gap:0.4rem;margin-top:0.7rem}
|
||||
.card .actions a,.card .actions button{flex:1;background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.35rem;border-radius:3px;font-size:0.7rem;cursor:pointer;text-align:center;text-decoration:none;letter-spacing:0.1em;text-transform:uppercase}
|
||||
.card .actions a:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.card .actions button:hover{border-color:var(--danger);color:var(--danger)}
|
||||
.empty{color:var(--muted);text-align:center;padding:2rem;font-style:italic}
|
||||
.toast{position:fixed;bottom:2rem;right:2rem;background:rgba(18,20,24,0.95);border:1px solid var(--accent);color:var(--accent);padding:0.8rem 1.2rem;border-radius:4px;font-family:ui-monospace,monospace;font-size:0.85rem;z-index:100;display:none}
|
||||
|
||||
.tag-row{margin-top:0.5rem;min-height:1.4rem}
|
||||
.tag-edit{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.65rem;padding:0.15rem 0.5rem;border-radius:3px;font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.1em;cursor:pointer;transition:all 0.15s}
|
||||
.tag-edit:empty::before,.tag-edit:hover{border-color:rgba(34,211,238,0.7);color:var(--accent)}
|
||||
.tag-edit.empty{background:transparent;border-style:dashed;color:var(--muted)}
|
||||
.tag-edit input{background:rgba(10,11,13,0.9);border:1px solid var(--accent);color:var(--accent);font:inherit;padding:0;width:8rem;outline:none;text-transform:uppercase}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div><h1>PDF Library</h1></div>
|
||||
<a href="/">← Hub</a>
|
||||
</header>
|
||||
|
||||
<div class="dropzone" id="dz">
|
||||
<p class="big">Drop PDFs here or click to upload</p>
|
||||
<p>Supports drag-drop, multiple files</p>
|
||||
<input type="file" id="fileInput" accept=".pdf" multiple>
|
||||
<div class="fields">
|
||||
<input type="text" id="titleInput" placeholder="Title (optional - uses filename)">
|
||||
<input type="text" id="tagInput" placeholder="Tag (medical, repair, etc.)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">
|
||||
Library
|
||||
<div style="display:flex;gap:0.5rem;align-items:center">
|
||||
<select class="search" id="sortInput" style="font-family:ui-monospace,monospace">
|
||||
<option value="date-desc">Newest First</option>
|
||||
<option value="date-asc">Oldest First</option>
|
||||
<option value="title-asc">Title A-Z</option>
|
||||
<option value="title-desc">Title Z-A</option>
|
||||
<option value="size-desc">Size (Largest)</option>
|
||||
<option value="size-asc">Size (Smallest)</option>
|
||||
<option value="tag-asc">Tag A-Z</option>
|
||||
</select>
|
||||
<input type="text" class="search" id="searchInput" placeholder="Filter...">
|
||||
</div>
|
||||
</div>
|
||||
<div id="grid" class="grid"><div class="empty">Loading...</div></div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script>
|
||||
let allItems = [];
|
||||
|
||||
function fmtSize(b){
|
||||
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(2)+" GB";
|
||||
}
|
||||
function fmtDate(s){
|
||||
if(!s) return "";
|
||||
return new Date(s).toLocaleDateString();
|
||||
}
|
||||
|
||||
function toast(msg){
|
||||
const t=document.getElementById("toast");
|
||||
t.textContent=msg;
|
||||
t.style.display="block";
|
||||
setTimeout(()=>t.style.display="none",2500);
|
||||
}
|
||||
|
||||
async function loadList(){
|
||||
const r=await fetch("api/list");
|
||||
allItems=await r.json();
|
||||
render();
|
||||
}
|
||||
|
||||
function render(){
|
||||
const q=document.getElementById("searchInput").value.toLowerCase();
|
||||
const sort=document.getElementById("sortInput").value;
|
||||
let items=allItems.filter(i=>!q || i.title.toLowerCase().includes(q) || (i.tag||"").toLowerCase().includes(q));
|
||||
items.sort((a,b)=>{
|
||||
switch(sort){
|
||||
case "date-desc": return (b.uploaded||"").localeCompare(a.uploaded||"");
|
||||
case "date-asc": return (a.uploaded||"").localeCompare(b.uploaded||"");
|
||||
case "title-asc": return a.title.localeCompare(b.title);
|
||||
case "title-desc":return b.title.localeCompare(a.title);
|
||||
case "size-desc": return b.size-a.size;
|
||||
case "size-asc": return a.size-b.size;
|
||||
case "tag-asc": return (a.tag||"").localeCompare(b.tag||"") || a.title.localeCompare(b.title);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const grid=document.getElementById("grid");
|
||||
if(!items.length){
|
||||
grid.innerHTML='<div class="empty">No PDFs yet. Upload some above.</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML=items.map(i=>`
|
||||
<div class="card">
|
||||
<div class="title">${i.title}</div>
|
||||
<div class="meta">${fmtSize(i.size)} • ${fmtDate(i.uploaded)}</div>
|
||||
<div class="tag-row"><span class="tag-edit ${i.tag?'':'empty'}" onclick="editTag('${i.id}', this)">${i.tag||"+ add tag"}</span></div>
|
||||
<div class="actions">
|
||||
<a href="view/${i.id}" target="_blank">View</a>
|
||||
<button onclick="del('${i.id}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
async function del(fid){
|
||||
if(!confirm("Delete this PDF?")) return;
|
||||
await fetch("api/delete/"+fid,{method:"DELETE"});
|
||||
toast("Deleted");
|
||||
loadList();
|
||||
}
|
||||
|
||||
async function upload(files){
|
||||
for(const file of files){
|
||||
if(!file.name.toLowerCase().endsWith(".pdf")){ toast("Skipped: "+file.name); continue; }
|
||||
const fd=new FormData();
|
||||
fd.append("file",file);
|
||||
fd.append("title",document.getElementById("titleInput").value);
|
||||
fd.append("tag",document.getElementById("tagInput").value);
|
||||
toast("Uploading "+file.name+"...");
|
||||
const r=await fetch("api/upload",{method:"POST",body:fd});
|
||||
if(r.ok) toast("Uploaded "+file.name);
|
||||
else toast("Failed: "+file.name);
|
||||
}
|
||||
document.getElementById("titleInput").value="";
|
||||
document.getElementById("tagInput").value="";
|
||||
loadList();
|
||||
}
|
||||
|
||||
const dz=document.getElementById("dz");
|
||||
const fi=document.getElementById("fileInput");
|
||||
dz.addEventListener("click",e=>{ if(e.target.tagName!=="INPUT") fi.click(); });
|
||||
fi.addEventListener("change",e=>upload(e.target.files));
|
||||
dz.addEventListener("dragover",e=>{ e.preventDefault(); dz.classList.add("drag"); });
|
||||
dz.addEventListener("dragleave",()=>dz.classList.remove("drag"));
|
||||
dz.addEventListener("drop",e=>{ e.preventDefault(); dz.classList.remove("drag"); upload(e.dataTransfer.files); });
|
||||
document.getElementById("searchInput").addEventListener("input",render);
|
||||
document.getElementById("sortInput").addEventListener("change",render);
|
||||
|
||||
|
||||
async function editTag(fid, el){
|
||||
if(el.querySelector("input")) return;
|
||||
const current = el.textContent === "+ add tag" ? "" : el.textContent;
|
||||
el.innerHTML = '<input type="text" value="'+current+'" />';
|
||||
const input = el.querySelector("input");
|
||||
input.focus();
|
||||
input.select();
|
||||
const save = async () => {
|
||||
const val = input.value.trim();
|
||||
const fd = new FormData();
|
||||
fd.append("tag", val);
|
||||
await fetch("api/update/"+fid, {method:"PATCH", body: fd});
|
||||
toast(val ? "Tag set: "+val : "Tag cleared");
|
||||
loadList();
|
||||
};
|
||||
input.addEventListener("blur", save);
|
||||
input.addEventListener("keydown", e => {
|
||||
if(e.key === "Enter") input.blur();
|
||||
if(e.key === "Escape"){ input.value = current; input.blur(); }
|
||||
});
|
||||
}
|
||||
|
||||
loadList();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user