@@ -0,0 +1,249 @@
<!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 : 100 vh }
. header { background : #161b22 ; border-bottom : 1 px solid #30363d ; padding : 16 px 24 px ; display : flex ; justify-content : space-between ; align-items : center }
. header h1 { font-size : 20 px ; color : #f0f6fc }
. header . user { font-size : 13 px ; color : #8b949e }
. container { max-width : 1000 px ; margin : 24 px auto ; padding : 0 24 px }
. section { margin-bottom : 32 px }
. section h2 { font-size : 18 px ; color : #f0f6fc ; margin-bottom : 12 px }
. btn { padding : 8 px 16 px ; border-radius : 6 px ; border : 1 px solid #30363d ; background : #21262d ; color : #c9d1d9 ; cursor : pointer ; font-size : 13 px ; transition : all .15 s }
. 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 : 4 px 10 px ; font-size : 12 px }
. site-card { background : #161b22 ; border : 1 px solid #30363d ; border-radius : 8 px ; padding : 16 px ; margin-bottom : 12 px ; display : flex ; justify-content : space-between ; align-items : center }
. site-card . info { flex : 1 }
. site-card . name { font-size : 16 px ; 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 : 13 px ; color : #8b949e ; margin-top : 4 px }
. site-card . meta { font-size : 11 px ; color : #6e7681 ; margin-top : 4 px }
. site-card . actions { display : flex ; gap : 6 px }
. badge { display : inline-block ; padding : 2 px 8 px ; border-radius : 10 px ; font-size : 11 px ; 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 : 12 px }
. form-group label { display : block ; font-size : 13 px ; color : #8b949e ; margin-bottom : 4 px }
. form-group input { width : 100 % ; padding : 8 px 12 px ; border-radius : 6 px ; border : 1 px solid #30363d ; background : #0d1117 ; color : #c9d1d9 ; font-size : 14 px }
. form-group input : focus { outline : none ; border-color : #1f6feb }
. form-row { display : flex ; gap : 12 px ; align-items : end }
. file-list { list-style : none }
. file-list li { display : flex ; justify-content : space-between ; padding : 6 px 0 ; border-bottom : 1 px solid #21262d ; font-size : 13 px }
. file-list li : last-child { border-bottom : none }
. upload-zone { border : 2 px dashed #30363d ; border-radius : 8 px ; padding : 24 px ; text-align : center ; cursor : pointer ; transition : all .15 s }
. 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 : 1 px solid #30363d ; border-radius : 12 px ; padding : 24 px ; max-width : 560 px ; width : 90 % }
. modal h3 { color : #f0f6fc ; margin-bottom : 16 px }
. toast { position : fixed ; bottom : 24 px ; right : 24 px ; padding : 12 px 20 px ; border-radius : 8 px ; font-size : 14 px ; z-index : 200 ; animation : slideIn .3 s }
. toast-success { background : #1b3a1b ; border : 1 px solid #3fb950 ; color : #3fb950 }
. toast-error { background : #3a1b1b ; border : 1 px solid #f85149 ; color : #f85149 }
@ keyframes slideIn { from { transform : translateY ( 20 px ) ; opacity : 0 } to { transform : translateY ( 0 ) ; opacity : 1 } }
. spinner { display : inline-block ; width : 16 px ; height : 16 px ; border : 2 px solid #30363d ; border-top-color : #58a6ff ; border-radius : 50 % ; animation : spin .6 s linear infinite ; vertical-align : middle ; margin-right : 6 px }
@ keyframes spin { to { transform : rotate ( 360 deg ) } }
. help-text { font-size : 12 px ; color : #6e7681 ; margin-top : 4 px }
< / 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 >