mirror of
https://github.com/wahyd4/home-docker.git
synced 2026-08-09 04:15:52 +10:00
- admin-ui.html extracted from git history - Caddy handle_errors serves canvas page for all HTML errors - Error page served directly by Caddy (correct status code preserved) - Republished hermes-stats and my-awesome-demo - Modular file structure: _config.py, _loaders.py, admin-ui.html, error-page.html
250 lines
14 KiB
HTML
250 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Demo Manager — junv.cc</title>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;min-height:100vh}
|
||
.header{background:#161b22;border-bottom:1px solid #30363d;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}
|
||
.header h1{font-size:20px;color:#f0f6fc}
|
||
.header .user{font-size:13px;color:#8b949e}
|
||
.container{max-width:1000px;margin:24px auto;padding:0 24px}
|
||
.section{margin-bottom:32px}
|
||
.section h2{font-size:18px;color:#f0f6fc;margin-bottom:12px}
|
||
.btn{padding:8px 16px;border-radius:6px;border:1px solid #30363d;background:#21262d;color:#c9d1d9;cursor:pointer;font-size:13px;transition:all .15s}
|
||
.btn:hover{background:#30363d}
|
||
.btn-primary{background:#1f6feb;border-color:#1f6feb;color:#fff}
|
||
.btn-primary:hover{background:#388bfd}
|
||
.btn-success{background:#238636;border-color:#238636;color:#fff}
|
||
.btn-success:hover{background:#2ea043}
|
||
.btn-warning{background:#9e6a03;border-color:#9e6a03;color:#fff}
|
||
.btn-warning:hover{background:#bb8009}
|
||
.btn-danger{background:#da3633;border-color:#da3633;color:#fff}
|
||
.btn-danger:hover{background:#f85149}
|
||
.btn-sm{padding:4px 10px;font-size:12px}
|
||
.site-card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center}
|
||
.site-card .info{flex:1}
|
||
.site-card .name{font-size:16px;font-weight:600;color:#58a6ff}
|
||
.site-card .name a{color:inherit;text-decoration:none}
|
||
.site-card .name a:hover{text-decoration:underline}
|
||
.site-card .desc{font-size:13px;color:#8b949e;margin-top:4px}
|
||
.site-card .meta{font-size:11px;color:#6e7681;margin-top:4px}
|
||
.site-card .actions{display:flex;gap:6px}
|
||
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
|
||
.badge-on{background:#1b3a1b;color:#3fb950}
|
||
.badge-off{background:#3a1b1b;color:#f85149}
|
||
.badge-draft{background:#3a2e1b;color:#d29922}
|
||
.badge-published{background:#1b3a1b;color:#3fb950}
|
||
.form-group{margin-bottom:12px}
|
||
.form-group label{display:block;font-size:13px;color:#8b949e;margin-bottom:4px}
|
||
.form-group input{width:100%;padding:8px 12px;border-radius:6px;border:1px solid #30363d;background:#0d1117;color:#c9d1d9;font-size:14px}
|
||
.form-group input:focus{outline:none;border-color:#1f6feb}
|
||
.form-row{display:flex;gap:12px;align-items:end}
|
||
.file-list{list-style:none}
|
||
.file-list li{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid #21262d;font-size:13px}
|
||
.file-list li:last-child{border-bottom:none}
|
||
.upload-zone{border:2px dashed #30363d;border-radius:8px;padding:24px;text-align:center;cursor:pointer;transition:all .15s}
|
||
.upload-zone:hover{border-color:#58a6ff;background:#161b22}
|
||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:100}
|
||
.modal{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:24px;max-width:560px;width:90%}
|
||
.modal h3{color:#f0f6fc;margin-bottom:16px}
|
||
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:8px;font-size:14px;z-index:200;animation:slideIn .3s}
|
||
.toast-success{background:#1b3a1b;border:1px solid #3fb950;color:#3fb950}
|
||
.toast-error{background:#3a1b1b;border:1px solid #f85149;color:#f85149}
|
||
@keyframes slideIn{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||
.spinner{display:inline-block;width:16px;height:16px;border:2px solid #30363d;border-top-color:#58a6ff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
.help-text{font-size:12px;color:#6e7681;margin-top:4px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header"><h1>🧪 Demo Manager</h1><div class="user" id="user-info">Loading...</div></div>
|
||
<div class="container">
|
||
<div class="section">
|
||
<h2>➕ New Demo Site <span style="font-size:12px;font-weight:400;color:#6e7681">— drafts are hidden until published</span></h2>
|
||
<form id="create-form" class="form-row">
|
||
<div class="form-group" style="flex:2"><label>Site Name</label><input type="text" id="site-name" placeholder="my-cool-demo" pattern="[a-z0-9-]+" required></div>
|
||
<div class="form-group" style="flex:3"><label>Description (optional)</label><input type="text" id="site-desc" placeholder="A quick demo of..."></div>
|
||
<div class="form-group" style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="site-publish" style="width:auto"><label for="site-publish" style="margin:0;font-size:13px">Publish now</label></div>
|
||
<button type="submit" class="btn btn-primary" style="height:38px">Create</button>
|
||
</form>
|
||
</div>
|
||
<div class="section"><h2>📂 Sites</h2><div id="sites-list">Loading...</div></div>
|
||
</div>
|
||
<div id="file-modal" class="modal-overlay"><div class="modal" id="file-modal-content"></div></div>
|
||
<div id="preview-modal" class="modal-overlay"><div class="modal" id="preview-modal-content" style="max-width:90%;max-height:90vh;overflow:auto"></div></div>
|
||
<div id="toast-container"></div>
|
||
<script>
|
||
let currentUser='';
|
||
const API='/api/sites';
|
||
|
||
async function api(method,path,body){
|
||
const opts={method,headers:{'Content-Type':'application/json'}};
|
||
if(body)opts.body=JSON.stringify(body);
|
||
const res=await fetch(path,opts);
|
||
if(!res.ok){const err=await res.json().catch(()=>({detail:res.statusText}));throw new Error(err.detail||'Request failed');}
|
||
return res.json();
|
||
}
|
||
|
||
async function uploadFile(siteName,file,subPath){
|
||
const form=new FormData();form.append('file',file);form.append('path',subPath||'');
|
||
const res=await fetch(API+'/'+siteName+'/files',{method:'POST',body:form});
|
||
if(!res.ok){const err=await res.json().catch(()=>({detail:res.statusText}));throw new Error(err.detail||'Upload failed');}
|
||
return res.json();
|
||
}
|
||
|
||
function toast(msg,type){
|
||
const el=document.createElement('div');el.className='toast toast-'+type;el.textContent=msg;
|
||
document.getElementById('toast-container').appendChild(el);setTimeout(()=>el.remove(),3000);
|
||
}
|
||
|
||
function formatSize(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB';}
|
||
function formatDate(iso){if(!iso)return'-';return new Date(iso).toLocaleString();}
|
||
|
||
async function loadSites(){
|
||
const container=document.getElementById('sites-list');
|
||
try{
|
||
const data=await api('GET',API);currentUser=data.user;
|
||
document.getElementById('user-info').textContent='👤 '+currentUser+' (Pocket ID)';
|
||
if(!data.sites.length){container.innerHTML='<p style="color:#8b949e;text-align:center;padding:32px">No sites yet. Create one above!</p>';return;}
|
||
container.innerHTML=data.sites.map(s=>{
|
||
const isPublic=s.public;
|
||
const isEnabled=s.enabled!==false;
|
||
let statusBadge='';
|
||
if(!isEnabled)statusBadge='<span class="badge badge-off">DISABLED</span>';
|
||
else if(s.published)statusBadge='<span class="badge badge-published">🔓 PUBLISHED</span>';
|
||
else statusBadge='<span class="badge badge-draft">🔒 DRAFT</span>';
|
||
|
||
let actionBtns='';
|
||
if(!isEnabled){
|
||
actionBtns+='<button class="btn btn-sm btn-success" onclick="toggleSite(\''+s.name+'\',\'enable\')">▶ Enable</button>';
|
||
}else{
|
||
actionBtns+='<button class="btn btn-sm btn-danger" onclick="toggleSite(\''+s.name+'\',\'disable\')">⏸ Disable</button>';
|
||
if(s.published){
|
||
actionBtns+='<button class="btn btn-sm btn-warning" onclick="publishSite(\''+s.name+'\',\'unpublish\')">🔒 Unpublish</button>';
|
||
}else{
|
||
actionBtns+='<button class="btn btn-sm btn-success" onclick="publishSite(\''+s.name+'\',\'publish\')">🔓 Publish</button>';
|
||
}
|
||
}
|
||
actionBtns+='<button class="btn btn-sm" onclick="manageSite(\''+s.name+'\')">📁 Files</button>';
|
||
if(!isPublic){actionBtns+='<button class="btn btn-sm" onclick="previewSite(\''+s.name+'\')">👁 Preview</button>';}
|
||
actionBtns+='<button class="btn btn-sm btn-danger" onclick="deleteSite(\''+s.name+'\')">🗑</button>';
|
||
|
||
return '<div class="site-card"><div class="info"><div class="name">'+
|
||
(isEnabled
|
||
? '<a href="https://demo.junv.cc/'+s.name+'" target="_blank">'+s.name+'</a>'
|
||
: '<a href="#" onclick="previewSite(\''+s.name+'\');return false" style="color:#58a6ff;text-decoration:none;border-bottom:1px dashed #58a6ff" title="Disabled — click to preview">'+s.name+'</a>')+
|
||
' '+statusBadge+
|
||
'</div>'+(s.description?'<div class="desc">'+s.description+'</div>':'')+
|
||
'<div class="meta">'+s.file_count+' files · created '+formatDate(s.created_at)+'</div></div>'+
|
||
'<div class="actions">'+actionBtns+'</div></div>';
|
||
}).join('');
|
||
}catch(e){container.innerHTML='<p style="color:#f85149">'+e.message+'</p>';}
|
||
}
|
||
|
||
async function createSite(e){
|
||
e.preventDefault();
|
||
const name=document.getElementById('site-name').value.trim();
|
||
const desc=document.getElementById('site-desc').value.trim();
|
||
const publishNow=document.getElementById('site-publish').checked;
|
||
if(!name)return;
|
||
try{
|
||
await api('POST',API,{name,description:desc,published:publishNow});
|
||
document.getElementById('site-name').value='';
|
||
document.getElementById('site-desc').value='';
|
||
document.getElementById('site-publish').checked=false;
|
||
toast(publishNow?'Site "'+name+'" created and published!':'Site "'+name+'" created as draft','success');
|
||
loadSites();
|
||
}catch(e){toast(e.message,'error');}
|
||
}
|
||
|
||
async function publishSite(name,action){
|
||
try{
|
||
await api('POST',API+'/'+name+'/'+action);
|
||
toast(action==='publish'?'🔓 Published! Live at demo.junv.cc/'+name:'🔒 Unpublished — back to draft','success');
|
||
loadSites();
|
||
}catch(e){toast(e.message,'error');}
|
||
}
|
||
|
||
async function toggleSite(name,action){
|
||
try{await api('POST',API+'/'+name+'/'+action);toast('Site '+action+'d','success');loadSites();}
|
||
catch(e){toast(e.message,'error');}
|
||
}
|
||
|
||
async function deleteSite(name){
|
||
if(!confirm('Delete "'+name+'"? This cannot be undone.'))return;
|
||
try{await api('DELETE',API+'/'+name);toast('Deleted','success');loadSites();}
|
||
catch(e){toast(e.message,'error');}
|
||
}
|
||
|
||
async function previewSite(name){
|
||
const modal=document.getElementById('preview-modal');
|
||
const content=document.getElementById('preview-modal-content');
|
||
modal.style.display='flex';
|
||
content.innerHTML='<h3>👁 Preview: '+name+' <span style="font-size:12px;color:#d29922">(Draft — requires auth)</span></h3><div style="text-align:center;padding:24px"><span class="spinner"></span>Loading preview...</div>';
|
||
try{
|
||
const res=await fetch(API+'/'+name+'/preview');
|
||
if(!res.ok)throw new Error((await res.json().catch(()=>({detail:'Failed'}))).detail||'Failed');
|
||
const html=await res.text();
|
||
content.innerHTML='<h3>👁 Preview: '+name+' <span style="font-size:12px;color:#d29922">(Draft)</span></h3><div style="text-align:right;margin-bottom:8px"><button class="btn btn-sm" onclick="document.getElementById(\'preview-modal\').style.display=\'none\'">Close</button></div><iframe srcdoc="'+html.replace(/"/g,'"')+'" style="width:100%;height:70vh;border:1px solid #30363d;border-radius:8px;background:#fff"></iframe>';
|
||
}catch(e){
|
||
content.innerHTML='<h3>Preview Error</h3><p style="color:#f85149">'+e.message+'</p><button class="btn btn-sm" onclick="document.getElementById(\'preview-modal\').style.display=\'none\'">Close</button>';
|
||
}
|
||
}
|
||
|
||
async function manageSite(name){
|
||
const modal=document.getElementById('file-modal');
|
||
const content=document.getElementById('file-modal-content');
|
||
modal.style.display='flex';
|
||
content.innerHTML='<h3>📁 '+name+'</h3><div style="margin-bottom:16px"><div class="upload-zone" id="upload-zone-'+name+'"><div id="upload-label-'+name+'">📤 Drop files or click to upload</div><input type="file" id="file-input-'+name+'" style="display:none" multiple><div id="upload-progress-'+name+'" style="display:none;margin-top:8px"></div></div></div><div style="margin:16px 0"><strong style="font-size:13px;color:#8b949e">Files:</strong></div><div id="files-list-'+name+'" style="max-height:300px;overflow-y:auto">Loading...</div><div style="margin-top:16px;text-align:right"><button class="btn btn-sm" onclick="document.getElementById(\'file-modal\').style.display=\'none\'">Close</button></div>';
|
||
|
||
try{
|
||
const data=await api('GET',API+'/'+name);
|
||
const fd=document.getElementById('files-list-'+name);
|
||
if(!data.files.length){fd.innerHTML='<p style="color:#8b949e">No files. Upload one above.</p>';}
|
||
else{
|
||
fd.innerHTML='<ul class="file-list">'+data.files.map(f=>'<li><span><a href="'+API+'/'+name+'/preview?file='+encodeURIComponent(f.path)+'" target="_blank" style="color:#58a6ff">'+f.path+'</a></span><span style="color:#8b949e">'+formatSize(f.size)+' <button class="btn btn-sm btn-danger" style="margin-left:8px" onclick="deleteFile(\''+name+'\',\''+f.path+'\')">×</button></span></li>').join('')+'</ul>';
|
||
}
|
||
}catch(e){document.getElementById('files-list-'+name).innerHTML='<p style="color:#f85149">'+e.message+'</p>';}
|
||
|
||
const zone=document.getElementById('upload-zone-'+name);
|
||
const input=document.getElementById('file-input-'+name);
|
||
zone.onclick=()=>input.click();
|
||
zone.ondragover=(e)=>{e.preventDefault();zone.style.borderColor='#58a6ff';};
|
||
zone.ondragleave=()=>{zone.style.borderColor='';};
|
||
zone.ondrop=async(e)=>{e.preventDefault();zone.style.borderColor='';await handleUpload(name,e.dataTransfer.files);};
|
||
input.onchange=async()=>{await handleUpload(name,input.files);input.value='';};
|
||
}
|
||
|
||
async function handleUpload(siteName,files){
|
||
const pd=document.getElementById('upload-progress-'+siteName);
|
||
const label=document.getElementById('upload-label-'+siteName);
|
||
pd.style.display='block';
|
||
for(const file of files){
|
||
pd.innerHTML='<span class="spinner"></span> Uploading '+file.name+'...';
|
||
try{await uploadFile(siteName,file,'');pd.innerHTML+=' ✅ '+file.name+'<br>';}
|
||
catch(e){pd.innerHTML+=' ❌ '+file.name+': '+e.message+'<br>';}
|
||
}
|
||
label.textContent='📤 Drop more files or click to upload';
|
||
toast('Upload complete!','success');
|
||
manageSite(siteName);
|
||
}
|
||
|
||
async function deleteFile(siteName,filePath){
|
||
if(!confirm('Delete "'+filePath+'"?'))return;
|
||
try{
|
||
const form=new FormData();form.append('path',filePath);
|
||
const res=await fetch(API+'/'+siteName+'/files',{method:'DELETE',body:form});
|
||
if(!res.ok)throw new Error((await res.json().catch(()=>({detail:'Failed'}))).detail||'Failed');
|
||
toast('Deleted '+filePath,'success');manageSite(siteName);
|
||
}catch(e){toast(e.message,'error');}
|
||
}
|
||
|
||
document.getElementById('create-form').onsubmit=createSite;
|
||
loadSites();
|
||
</script>
|
||
</body>
|
||
</html>
|