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
Binary file not shown.
+95
View File
@@ -0,0 +1,95 @@
from fastapi import FastAPI, UploadFile, File, HTTPException, Form
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
import os, json, uuid, shutil, re
from datetime import datetime
PDF_DIR = "/files"
META = "/files/.metadata.json"
app = FastAPI()
def load_meta():
if os.path.exists(META):
with open(META) as f:
return json.load(f)
return {}
def save_meta(m):
with open(META, "w") as f:
json.dump(m, f, indent=2)
def safe_name(name):
return re.sub(r'[^\w\-. ]', '_', name)[:200]
@app.get("/api/list")
def list_pdfs():
meta = load_meta()
items = []
for fid, info in meta.items():
path = os.path.join(PDF_DIR, info["filename"])
if os.path.exists(path):
items.append({
"id": fid,
"title": info.get("title", info["filename"]),
"filename": info["filename"],
"tag": info.get("tag", ""),
"size": os.path.getsize(path),
"uploaded": info.get("uploaded", ""),
})
items.sort(key=lambda x: x["uploaded"], reverse=True)
return items
@app.post("/api/upload")
async def upload(file: UploadFile = File(...), title: str = Form(""), tag: str = Form("")):
if not file.filename.lower().endswith(".pdf"):
raise HTTPException(400, "PDFs only")
fid = uuid.uuid4().hex[:12]
fname = safe_name(file.filename)
path = os.path.join(PDF_DIR, f"{fid}_{fname}")
with open(path, "wb") as f:
shutil.copyfileobj(file.file, f)
meta = load_meta()
meta[fid] = {
"filename": f"{fid}_{fname}",
"title": title or fname.rsplit(".", 1)[0],
"tag": tag,
"uploaded": datetime.utcnow().isoformat(),
}
save_meta(meta)
return {"id": fid}
@app.delete("/api/delete/{fid}")
def delete(fid: str):
meta = load_meta()
if fid not in meta:
raise HTTPException(404)
path = os.path.join(PDF_DIR, meta[fid]["filename"])
if os.path.exists(path):
os.remove(path)
del meta[fid]
save_meta(meta)
return {"ok": True}
@app.get("/view/{fid}")
def view(fid: str):
meta = load_meta()
if fid not in meta:
raise HTTPException(404)
path = os.path.join(PDF_DIR, meta[fid]["filename"])
return FileResponse(path, media_type="application/pdf")
@app.patch("/api/update/{fid}")
async def update(fid: str, tag: str = Form(None), title: str = Form(None)):
meta = load_meta()
if fid not in meta:
raise HTTPException(404)
if tag is not None:
meta[fid]["tag"] = tag
if title is not None and title.strip():
meta[fid]["title"] = title.strip()
save_meta(meta)
return {"ok": True}
app.mount("/", StaticFiles(directory="/static", html=True), name="static")
+205
View File
@@ -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>