Files
home-docker/home-apps/demo-service/admin-ui.html
T
Junv (via Hermes) a8a09c6675 fix(demo-service): restore admin UI, add canvas error page for 401/403/404
- 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
2026-06-14 11:36:04 +10:00

250 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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,'&quot;')+'" 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>