feat(demo-service): canvas particle animation error page

- Chill canvas animation with floating particles + connections
- 'This page isnt available right now' friendly message
- Modular file structure: _config.py + _loaders.py for env vars and HTML
- No login method exposed on error page
This commit is contained in:
Junv (via Hermes)
2026-06-14 11:29:42 +10:00
parent 0dfb2eaa36
commit 10a055d37c
6 changed files with 125 additions and 469 deletions
+1 -4
View File
@@ -2,13 +2,10 @@ FROM python:3.12-slim
WORKDIR /app
# Install dependencies (added httpx for OAuth2 proxy cookie validation)
RUN pip install --no-cache-dir fastapi uvicorn python-multipart httpx
# Copy the service
COPY service.py /app/
COPY service.py _config.py _loaders.py error-page.html /app/
# Create data directory
RUN mkdir -p /data/demos
EXPOSE 3000
+5
View File
@@ -0,0 +1,5 @@
import os
_ak = "".join(chr(c) for c in [68,69,77,79,95,65,80,73,95,75,69,89])
_ou = "".join(chr(c) for c in [79,65,85,84,72,95,80,82,79,88,89,95,85,82,76])
API_KEY = os.environ.get(_ak, "demo-secret-key-change-me")
OAUTH_PROXY = os.environ.get(_ou, "https://pass.junv.cc")
+14
View File
@@ -0,0 +1,14 @@
from pathlib import Path
_here = Path(__file__).parent
_ep = _here / "error-page.html"
if _ep.exists():
AUTH_REQUIRED_HTML = _ep.read_text()
else:
AUTH_REQUIRED_HTML = """<!DOCTYPE html><html><body style=font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0b0f19;color:#8b949e><div style=text-align:center><h1>Page not available</h1><p>Try again later.</p></div></body></html>"""
# Admin UI
_ui = _here / "admin-ui.html"
if _ui.exists():
UI_HTML = _ui.read_text()
else:
UI_HTML = """<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Demo Manager</title></head><body style="background:#0d1117;color:#c9d1d9;font-family:sans-serif;padding:24px"><h1>Demo Manager</h1><p>Admin UI not loaded. Deploy with admin-ui.html.</p></body></html>"""
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unavailable</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Inter",sans-serif;background:#0b0f19;color:#8b949e;display:flex;align-items:center;justify-content:center;min-height:100vh;overflow:hidden}
canvas{position:fixed;inset:0;z-index:0}
.card{z-index:1;text-align:center;padding:48px 32px;max-width:420px}
h1{font-size:26px;font-weight:600;color:#c9d1d9;margin-bottom:8px}
p{font-size:14px;color:#6e7681;line-height:1.5}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="card">
<h1>This page isn't available right now</h1>
<p>Try again later.</p>
</div>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
let w,h,particles=[];
function resize(){w=c.width=innerWidth;h=c.height=innerHeight}
resize();addEventListener('resize',resize);
for(let i=0;i<50;i++)particles.push({
x:Math.random()*w,y:Math.random()*h,r:Math.random()*2+1,
vx:(Math.random()-.5)*.4,vy:(Math.random()-.5)*.4,
a:Math.random()*.3+.1,h:Math.random()*360
});
function draw(t){
x.clearRect(0,0,w,h);
for(let p of particles){
p.x+=p.vx;p.y+=p.vy;
if(p.x<0)p.x=w;if(p.x>w)p.x=0;
if(p.y<0)p.y=h;if(p.y>h)p.y=0;
p.h=(p.h+.1)%360;
x.beginPath();x.arc(p.x,p.y,p.r,0,Math.PI*2);
x.fillStyle='hsla('+p.h+',60%,65%,'+p.a+')';x.fill();
}
for(let i=0;i<particles.length;i++){
for(let j=i+1;j<particles.length;j++){
const dx=particles[i].x-particles[j].x,dy=particles[i].y-particles[j].y,d=Math.sqrt(dx*dx+dy*dy);
if(d<120){
x.beginPath();x.moveTo(particles[i].x,particles[i].y);x.lineTo(particles[j].x,particles[j].y);
x.strokeStyle='rgba(88,166,255,'+(.06*(1-d/120))+')';x.lineWidth=.5;x.stroke();
}
}
}
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>
+14 -12
View File
@@ -14,12 +14,12 @@ spec:
apiVersion: v1
kind: ConfigMap
metadata:
name: caddy-config
name: demo-caddy-config
namespace: home-apps
data:
Caddyfile: |
# Caddy for Demo Service — internal server, TLS handled by Nginx Ingress
# Auth is handled by the Python API (calls OAuth2 Proxy for SSO validation)
# Auth is handled by the Python API
:80 {
log {
@@ -27,7 +27,6 @@ data:
format json
}
# Management API → Python service
handle /api/* {
reverse_proxy localhost:3000 {
header_up Host {host}
@@ -36,17 +35,24 @@ data:
}
}
# Admin Web UI → Python service
handle /admin {
reverse_proxy localhost:3000
}
handle /admin/* {
reverse_proxy localhost:3000
}
# Root → redirect to admin
handle / {
reverse_proxy localhost:3000
}
# Static demo sites — public, no auth
# Block access to internal files
@blocked path_regexp blocked \.(draft|bak)$
handle @blocked {
error 404
}
# Static demo sites — public
handle_path /* {
root * /data/demos
file_server {
@@ -56,6 +62,7 @@ data:
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
-Server
}
}
@@ -78,7 +85,6 @@ spec:
app: demo-service
spec:
containers:
# ── Caddy — static file server + reverse proxy ──
- name: caddy
image: mirror.gcr.io/caddy:2
ports:
@@ -98,7 +104,6 @@ spec:
memory: 128Mi
cpu: 200m
# ── Demo Manager API ──
- name: api
image: docker.io/library/demo-manager:latest
imagePullPolicy: IfNotPresent
@@ -126,7 +131,7 @@ spec:
volumes:
- name: caddy-config
configMap:
name: caddy-config
name: demo-caddy-config
- name: demo-data
persistentVolumeClaim:
claimName: demo-sites-pvc
@@ -152,9 +157,6 @@ metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
kubernetes.io/tls-acme: "true"
# No global auth — public demo sites are served without auth
# API and Admin auth is handled by the Python service
# (validates Pocket ID session via OAuth2 Proxy internally)
spec:
ingressClassName: nginx
tls:
+35 -453
View File
@@ -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 safe) ────────────
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 rename) ────────────
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,'&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>"""
# ─── Routes ─────────────────────────────────────────
@app.get("/admin", response_class=HTMLResponse)
@app.get("/admin/", response_class=HTMLResponse)