@@ -18,16 +18,17 @@ from pydantic import BaseModel
DEMO_ROOT = Path ( os . environ . get ( " DEMO_ROOT " , " /data/demos " ) )
META_FILE = DEMO_ROOT / " .meta " / " sites.json "
API_KEY = os . environ . get ( " DEMO_API_KEY " , " " )
OAUTH_PROXY = os . environ . get ( " OAUTH_PROXY_URL " , " https://pass.junv.cc " )
# Config from separate file (avoids secret redaction issues)
from _config import API_KEY , OAUTH_PROXY
from _loaders import AUTH_REQUIRED_HTML , UI_HTML
app = FastAPI ( title = " Demo Manager " , version = " 1.1.0 " )
app = FastAPI ( title = " Demo Manager " , version = " 1.2.0 " )
# ─── Models ─────────────────────────────────────────
class SiteInfo ( BaseModel ) :
name : str
enabled : bool = True
published : bool = False # NEW: draft by default
published : bool = False
description : str = " "
created_at : str = " "
updated_at : str = " "
@@ -35,7 +36,7 @@ class SiteInfo(BaseModel):
class SiteCreate ( BaseModel ) :
name : str
description : str = " "
published : bool = False # Can opt to publish on create
published : bool = False
# ─── Helpers ────────────────────────────────────────
def load_meta ( ) - > dict :
@@ -52,7 +53,7 @@ def check_auth(request: Request) -> Optional[str]:
auth_header = request . headers . get ( " Authorization " , " " )
if auth_header . startswith ( " Bearer " ) :
token = auth_header [ 7 : ]
if token == API_KEY :
if API_KEY and token == API_KEY :
return " api-agent "
email = request . headers . get ( " X-Auth-Request-Email " ) or request . headers . get ( " X-Forwarded-Email " )
if email :
@@ -87,36 +88,15 @@ def site_exists(name: str) -> bool:
try :
return site_dir . is_dir ( )
except OSError :
return True # NFS stale handle, trust meta
return True
def is_site_visible ( name : str ) - > bool :
""" Is the site publicly visible? (enabled AND published). """
meta = load_meta ( )
info = meta . get ( " sites " , { } ) . get ( name , { } )
return info . get ( " enabled " , True ) and info . get ( " published " , False )
# ─── File Helpers (no rename — NFS saf e) ────────────
def hide_index ( name : str , suffix : str = " .bak " ) :
""" Move index.html out of Caddy ' s reach. """
site_dir = DEMO_ROOT / name
real_index = site_dir / " index.html "
backup_index = site_dir / f " .index.html { suffix } "
if real_index . exists ( ) :
backup_index . write_bytes ( real_index . read_bytes ( ) )
real_index . unlink ( )
def restore_index ( name : str , suffix : str ) :
""" Restore index.html from backup. """
site_dir = DEMO_ROOT / name
backup_index = site_dir / f " .index.html { suffix } "
real_index = site_dir / " index.html "
if backup_index . exists ( ) :
real_index . write_bytes ( backup_index . read_bytes ( ) )
backup_index . unlink ( )
# ─── File Helpers (NFS safe — no renam e) ─ ────────────
def sync_visibility ( name : str ) :
""" Ensure filesystem state matches enabled+published state.
Only one file should exist: index.html (visible) or .index.html.bak (hidden). """
meta = load_meta ( )
info = meta . get ( " sites " , { } ) . get ( name , { } )
visible = info . get ( " enabled " , True ) and info . get ( " published " , False )
@@ -126,25 +106,20 @@ def sync_visibility(name: str):
bak_index = site_dir / " .index.html.bak "
if visible :
# Site should be public. Restore from any backup.
for src in [ draft_index , bak_index ] :
if src . exists ( ) and not real_index . exists ( ) :
real_index . write_bytes ( src . read_bytes ( ) )
src . unlink ( )
break
else :
# Site should be hidden.
if real_index . exists ( ) :
if not info . get ( " enabled " , True ) :
# Disabled — use .bak
bak_index . write_bytes ( real_index . read_bytes ( ) )
real_index . unlink ( )
elif not info . get ( " published " , False ) :
# Unpublished draft — use .draft
draft_index . write_bytes ( real_index . read_bytes ( ) )
real_index . unlink ( )
# Clean up leftover backup files
for leftover in [ draft_index , bak_index ] :
if leftover . exists ( ) and real_index . exists ( ) :
leftover . unlink ( )
@@ -168,12 +143,11 @@ def list_sites(request: Request):
file_count = sum ( 1 for _ in site_dir . rglob ( " * " ) if _ . is_file ( ) and not _ . name . startswith ( " . " ) )
except OSError :
pass
# Backward compat: treat missing 'published' as True
if " published " not in info :
info [ " published " ] = True
sites . append ( {
* * info , " name " : name ,
" exists " : site_dir . is_dir ( ) if not isinstance ( site_dir . is_dir ( ) , OSError ) else info . get ( " published " , False ) ,
" exists " : True ,
" file_count " : file_count ,
" url " : f " https://demo.junv.cc/ { name } " if is_site_visible ( name ) else None ,
" public " : is_site_visible ( name ) ,
@@ -188,39 +162,26 @@ def create_site(body: SiteCreate, request: Request):
raise HTTPException ( status_code = 400 , detail = " Name is required " )
if site_exists ( name ) :
raise HTTPException ( status_code = 409 , detail = f " Site ' { name } ' already exists " )
now = datetime . now ( timezone . utc ) . isoformat ( )
meta = load_meta ( )
if " sites " not in meta :
meta [ " sites " ] = { }
meta . setdefault ( " sites " , { } )
site_dir = DEMO_ROOT / name
site_dir . mkdir ( parents = True , exist_ok = True )
# Create a default index.html (always starts as a real file)
default_html = f """ <!DOCTYPE html>
<html lang= " en " >
<head><meta charset= " UTF-8 " ><title> { name } </title>
<style>body {{ font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5 }} h1 {{ color:#333 }} </style>
</head>
<body><h1>🚀 { name } </h1></body>
</html> """ " "
</html> """
( site_dir / " index.html " ) . write_text ( default_html )
site_info = {
" name " : name ,
" enabled " : True ,
" published " : body . published , # NEW
" description " : body . description ,
" created_at " : now ,
" updated_at " : now ,
" name " : name , " enabled " : True , " published " : body . published ,
" description " : body . description , " created_at " : now , " updated_at " : now ,
}
meta [ " sites " ] [ name ] = site_info
save_meta ( meta )
# Sync visibility
sync_visibility ( name )
return site_info
@app.delete ( " /api/sites/ {name} " )
@@ -232,17 +193,14 @@ def delete_site(name: str, request: Request):
meta [ " sites " ] . pop ( name , None )
save_meta ( meta )
site_dir = DEMO_ROOT / name
try :
shutil . rmtree ( site_dir )
except OSError :
pass
try : shutil . rmtree ( site_dir )
except OSError : pass
return { " status " : " deleted " , " name " : name }
@app.post ( " /api/sites/ {name} /enable " )
def enable_site ( name : str , request : Request ) :
user = require_auth ( request )
if not site_exists ( name ) :
raise HTTPException ( status_code = 404 , detail = f " Site ' { name } ' not found " )
if not site_exists ( name ) : raise HTTPException ( status_code = 404 )
meta = load_meta ( )
if name in meta . get ( " sites " , { } ) :
meta [ " sites " ] [ name ] [ " enabled " ] = True
@@ -254,8 +212,7 @@ def enable_site(name: str, request: Request):
@app.post ( " /api/sites/ {name} /disable " )
def disable_site ( name : str , request : Request ) :
user = require_auth ( request )
if not site_exists ( name ) :
raise HTTPException ( status_code = 404 , detail = f " Site ' { name } ' not found " )
if not site_exists ( name ) : raise HTTPException ( status_code = 404 )
meta = load_meta ( )
if name in meta . get ( " sites " , { } ) :
meta [ " sites " ] [ name ] [ " enabled " ] = False
@@ -266,58 +223,45 @@ def disable_site(name: str, request: Request):
@app.post ( " /api/sites/ {name} /publish " )
def publish_site ( name : str , request : Request ) :
""" Make a draft site public. """
user = require_auth ( request )
if not site_exists ( name ) :
raise HTTPException ( status_code = 404 , detail = f " Site ' { name } ' not found " )
if not site_exists ( name ) : raise HTTPException ( status_code = 404 )
meta = load_meta ( )
if name in meta . get ( " sites " , { } ) :
meta [ " sites " ] [ name ] [ " published " ] = True
meta [ " sites " ] [ name ] [ " updated_at " ] = datetime . now ( timezone . utc ) . isoformat ( )
save_meta ( meta )
sync_visibility ( name )
return {
" status " : " published " ,
" name " : name ,
" url " : f " https://demo.junv.cc/ { name } " ,
}
return { " status " : " published " , " name " : name , " url " : f " https://demo.junv.cc/ { name } " }
@app.post ( " /api/sites/ {name} /unpublish " )
def unpublish_site ( name : str , request : Request ) :
""" Make a public site draft (hidden from public). """
user = require_auth ( request )
if not site_exists ( name ) :
raise HTTPException ( status_code = 404 , detail = f " Site ' { name } ' not found " )
if not site_exists ( name ) : raise HTTPException ( status_code = 404 )
meta = load_meta ( )
if name in meta . get ( " sites " , { } ) :
meta [ " sites " ] [ name ] [ " published " ] = False
meta [ " sites " ] [ name ] [ " updated_at " ] = datetime . now ( timezone . utc ) . isoformat ( )
save_meta ( meta )
sync_visibility ( name )
return { " status " : " unpublished " , " name " : name , " note " : " Site is now a draft, accessible only via preview API " }
return { " status " : " unpublished " , " name " : name }
@app.get ( " /api/sites/ {name} /preview " )
def preview_site ( name : str , request : Request ) :
""" Preview a draft site (requires auth). Returns the HTML content. """
user = require_auth ( request )
if not site_exists ( name ) :
raise HTTPException ( status_code = 404 , detail = f " Site ' { name } ' not found " )
if not site_exists ( name ) : raise HTTPException ( status_code = 404 )
site_dir = DEMO_ROOT / name
# Check for index.html (published), then .index.html.draft, then .index.html.bak
for candidate in [ " index.html " , " .index.html.draft " , " .index.html.bak " ] :
fpath = site_dir / candidate
if fpath . exists ( ) :
return HTMLResponse ( fpath . read_text ( ) )
raise HTTPException ( status_code = 404 , detail = " No content found for this site " )
raise HTTPException ( status_code = 404 , detail = " No content " )
@app.get ( " /api/sites/ {name} " )
def get_site ( name : str , request : Request ) :
require_auth ( request )
if not site_exists ( name ) :
raise HTTPException ( status_code = 404 , detail = f " Site ' { name } ' not found " )
if not site_exists ( name ) : raise HTTPException ( status_code = 404 )
meta = load_meta ( )
info = meta . get ( " sites " , { } ) . get ( name , { " name " : name , " enabled " : True , " published " : False } )
# Backward compat
if " published " not in info :
info [ " published " ] = True
site_dir = DEMO_ROOT / name
@@ -328,21 +272,17 @@ def get_site(name: str, request: Request):
if f . is_file ( ) and not f . name . startswith ( " . " ) :
rel = str ( f . relative_to ( site_dir ) )
files . append ( { " path " : rel , " size " : f . stat ( ) . st_size } )
except OSError :
pass
except OSError : pass
return {
" site " : info ,
" files " : files ,
" site " : info , " files " : files ,
" public " : is_site_visible ( name ) ,
" url " : f " https://demo.junv.cc/ { name } " if is_site_visible ( name ) else None ,
" preview_url " : f " /api/sites/ { name } /preview " ,
}
@app.post ( " /api/sites/ {name} /files " )
async def upload_file ( name : str , request : Request , file : UploadFile = File ( . . . ) , path : str = Form ( " " ) ) :
user = require_auth ( request )
if not site_exists ( name ) :
raise HTTPException ( status_code = 404 , detail = f " Site ' { name } ' not found. Create it first. " )
if not site_exists ( name ) : raise HTTPException ( status_code = 404 , detail = f " Site ' { name } ' not found " )
site_dir = DEMO_ROOT / name
safe_path = path . strip ( " / " ) . replace ( " .. " , " " )
dest_dir = site_dir / safe_path
@@ -354,379 +294,21 @@ async def upload_file(name: str, request: Request, file: UploadFile = File(...),
if name in meta . get ( " sites " , { } ) :
meta [ " sites " ] [ name ] [ " updated_at " ] = datetime . now ( timezone . utc ) . isoformat ( )
save_meta ( meta )
return {
" status " : " uploaded " ,
" name " : name ,
" file " : str ( file_path . relative_to ( site_dir ) ) ,
" size " : len ( content ) ,
" url " : f " https://demo.junv.cc/ { name } / { str ( file_path . relative_to ( site_dir ) ) } " if is_site_visible ( name ) else None ,
}
return { " status " : " uploaded " , " name " : name , " file " : str ( file_path . relative_to ( site_dir ) ) , " size " : len ( content ) }
@app.delete ( " /api/sites/ {name} /files " )
def delete_file ( name : str , request : Request , path : str = Form ( " " ) ) :
user = require_auth ( request )
if not site_exists ( name ) :
raise HTTPException ( status_code = 404 , detail = f " Site ' { name } ' not found " )
if not site_exists ( name ) : raise HTTPException ( status_code = 404 )
safe_path = path . strip ( " / " ) . replace ( " .. " , " " )
if not safe_path :
raise HTTPException ( status_code = 400 , detail = " path is required " )
if not safe_path : raise HTTPException ( status_code = 400 , detail = " path required " )
file_path = DEMO_ROOT / name / safe_path
if not file_path . exists ( ) :
raise HTTPException ( status_code = 404 , detail = f " File not found: { safe_path } " )
if file_path . is_dir ( ) :
shutil . rmtree ( file_path )
else :
file_path . unlink ( )
if not file_path . exists ( ) : raise HTTPException ( status_code = 404 )
if file_path . is_dir ( ) : shutil . rmtree ( file_path )
else : file_path . unlink ( )
return { " status " : " deleted " , " name " : name , " file " : safe_path }
# ─── Auth Required Page ─────────────────────────────
AUTH_REQUIRED_HTML = """ <!DOCTYPE html>
<html lang= " en " >
<head>
<meta charset= " UTF-8 " >
<meta name= " viewport " content= " width=device-width, initial-scale=1.0 " >
<title>Access Restricted — Demo Manager</title>
<style>
* { margin:0;padding:0;box-sizing:border-box}
body { font-family:-apple-system,BlinkMacSystemFont, " Segoe UI " , " Inter " ,sans-serif;background:#0d1117;color:#c9d1d9;display:flex;align-items:center;justify-content:center;min-height:100vh;overflow:hidden}
.bg { position:fixed;inset:0;pointer-events:none}
.bg svg { width:100 % ;height:100 % }
.card { z-index:1;text-align:center;max-width:380px;padding:56px 40px 48px}
.lock-wrap { width:80px;height:80px;margin:0 auto 28px;position:relative}
.lock-wrap svg { width:100 % ;height:100 % }
h1 { font-size:22px;font-weight:700;color:#f0f6fc;margin-bottom:6px;letter-spacing:-0.3px}
p { font-size:13px;color:#6e7681;line-height:1.5}
@keyframes pulse { 0 % ,100 % {opacity:.15} 50 % {opacity:.35} }
@keyframes float { 0 % ,100 % { transform:translateY(0) rotate(0deg)}33 % { transform:translateY(-8px) rotate(1deg)}66 % { transform:translateY(4px) rotate(-1deg)}}
@keyframes orbit { from { transform:rotate(0deg) translateX(60px) rotate(0deg)}to { transform:rotate(360deg) translateX(60px) rotate(-360deg)}}
@keyframes blink { 0 % ,100 % {opacity:0} 50 % {opacity:1} }
.dot { position:absolute;border-radius:50 % ;background:#1f6feb;animation:pulse 3s ease-in-out infinite}
.d1 { width:3px;height:3px;top:20 % ;left:15 % ;animation-delay:0s}
.d2 { width:4px;height:4px;top:60 % ;right:10 % ;animation-delay:.5s}
.d3 { width:2px;height:2px;top:30 % ;right:25 % ;animation-delay:1s}
.d4 { width:5px;height:5px;bottom:15 % ;left:20 % ;animation-delay:1.5s}
.d5 { width:2px;height:2px;top:10 % ;right:40 % ;animation-delay:2s}
.d6 { width:3px;height:3px;bottom:30 % ;right:25 % ;animation-delay:.8s}
.orbit-ring { position:absolute;width:140px;height:140px;top:50 % ;left:50 % ;transform:translate(-50 % ,-50 % );border:1px solid rgba(31,111,235,.08);border-radius:50 % }
.orbit-dot { position:absolute;width:5px;height:5px;background:#58a6ff;border-radius:50 % ;top:50 % ;left:50 % ;animation:orbit 8s linear infinite;box-shadow:0 0 12px rgba(88,166,255,.5)}
.orbit-dot::after { content: " " ;position:absolute;width:3px;height:3px;background:#1f6feb;border-radius:50 % ;top:50 % ;left:50 % ;transform:translate(-50 % ,-50 % );animation:blink 2s ease-in-out infinite}
</style>
</head>
<body>
<div class= " bg " >
<svg viewBox= " 0 0 800 600 " preserveAspectRatio= " xMidYMid slice " >
<defs>
<radialGradient id= " g1 " cx= " 50 % " cy= " 40 % " ><stop offset= " 0 % " stop-color= " #1f6feb " stop-opacity= " 0.06 " /><stop offset= " 100 % " stop-color= " transparent " /></radialGradient>
<radialGradient id= " g2 " cx= " 30 % " cy= " 60 % " ><stop offset= " 0 % " stop-color= " #58a6ff " stop-opacity= " 0.04 " /><stop offset= " 100 % " stop-color= " transparent " /></radialGradient>
</defs>
<rect fill= " url(#g1) " width= " 800 " height= " 600 " />
<rect fill= " url(#g2) " width= " 800 " height= " 600 " />
<line x1= " 100 " y1= " 60 " x2= " 120 " y2= " 80 " stroke= " #1f6feb " stroke-opacity= " 0.06 " stroke-width= " 0.5 " />
<line x1= " 680 " y1= " 120 " x2= " 700 " y2= " 100 " stroke= " #58a6ff " stroke-opacity= " 0.06 " stroke-width= " 0.5 " />
<line x1= " 200 " y1= " 500 " x2= " 220 " y2= " 520 " stroke= " #1f6feb " stroke-opacity= " 0.06 " stroke-width= " 0.5 " />
<line x1= " 600 " y1= " 450 " x2= " 580 " y2= " 470 " stroke= " #58a6ff " stroke-opacity= " 0.06 " stroke-width= " 0.5 " />
<circle cx= " 400 " cy= " 280 " r= " 100 " fill= " none " stroke= " #1f6feb " stroke-opacity= " 0.04 " stroke-width= " 0.5 " >
<animate attributeName= " r " values= " 100;120;100 " dur= " 6s " repeatCount= " indefinite " />
<animate attributeName= " stroke-opacity " values= " 0.04;0.02;0.04 " dur= " 6s " repeatCount= " indefinite " />
</circle>
<circle cx= " 400 " cy= " 280 " r= " 60 " fill= " none " stroke= " #58a6ff " stroke-opacity= " 0.06 " stroke-width= " 0.3 " >
<animate attributeName= " r " values= " 60;48;60 " dur= " 4s " repeatCount= " indefinite " />
</circle>
<rect x= " 380 " y= " 260 " width= " 40 " height= " 40 " rx= " 6 " fill= " none " stroke= " #1f6feb " stroke-opacity= " 0.08 " stroke-width= " 0.5 " transform= " rotate(45 400 280) " >
<animate attributeName= " stroke-opacity " values= " 0.08;0.03;0.08 " dur= " 5s " repeatCount= " indefinite " />
</rect>
<rect x= " 385 " y= " 265 " width= " 30 " height= " 30 " rx= " 4 " fill= " none " stroke= " #58a6ff " stroke-opacity= " 0.08 " stroke-width= " 0.5 " transform= " rotate(45 400 280) " >
<animate attributeName= " stroke-opacity " values= " 0.08;0.03;0.08 " dur= " 5s " repeatCount= " indefinite " begin= " 0.5s " />
</rect>
<rect x= " 416 " y= " 296 " width= " 8 " height= " 8 " rx= " 2 " fill= " #58a6ff " fill-opacity= " 0.3 " >
<animate attributeName= " fill-opacity " values= " 0.3;0.1;0.3 " dur= " 3s " repeatCount= " indefinite " />
</rect>
<rect x= " 376 " y= " 296 " width= " 8 " height= " 8 " rx= " 2 " fill= " #1f6feb " fill-opacity= " 0.3 " >
<animate attributeName= " fill-opacity " values= " 0.3;0.1;0.3 " dur= " 3s " repeatCount= " indefinite " begin= " 1.5s " />
</rect>
</svg>
</div>
<div class= " card " >
<div class= " lock-wrap " >
<svg viewBox= " 0 0 80 80 " fill= " none " >
<rect x= " 26 " y= " 38 " width= " 28 " height= " 24 " rx= " 4 " fill= " none " stroke= " #58a6ff " stroke-width= " 2 " >
<animate attributeName= " stroke-opacity " values= " 1;0.4;1 " dur= " 3s " repeatCount= " indefinite " />
</rect>
<path d= " M30 38V28a10 10 0 0120 0v10 " fill= " none " stroke= " #1f6feb " stroke-width= " 2 " stroke-linecap= " round " >
<animate attributeName= " stroke-opacity " values= " 1;0.5;1 " dur= " 2.5s " repeatCount= " indefinite " begin= " 0.5s " />
</path>
<circle cx= " 40 " cy= " 50 " r= " 3 " fill= " #58a6ff " >
<animate attributeName= " opacity " values= " 1;0.3;1 " dur= " 2s " repeatCount= " indefinite " />
</circle>
<line x1= " 40 " y1= " 53 " x2= " 40 " y2= " 57 " stroke= " #58a6ff " stroke-width= " 2 " stroke-linecap= " round " >
<animate attributeName= " opacity " values= " 1;0.3;1 " dur= " 2s " repeatCount= " indefinite " />
</line>
</svg>
</div>
<h1>Access Restricted</h1>
<p>This area requires authentication.</p>
</div>
<div class= " dot d1 " ></div>
<div class= " dot d2 " ></div>
<div class= " dot d3 " ></div>
<div class= " dot d4 " ></div>
<div class= " dot d5 " ></div>
<div class= " dot d6 " ></div>
<div class= " orbit-ring " ><div class= " orbit-dot " ></div></div>
</body>
</html> """
# ─── Web UI ─────────────────────────────────────────
UI_HTML = r """ <!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> """
# ─── Routes ─────────────────────────────────────────
@app.get ( " /admin " , response_class = HTMLResponse )
@app.get ( " /admin/ " , response_class = HTMLResponse )