mirror of
https://github.com/wahyd4/home-docker.git
synced 2026-08-09 04:15:52 +10:00
feat(demo-service): add publish/unpublish draft system with preview
- Sites now start as drafts (published: false) — hidden from public - Publish endpoint makes site live at demo.junv.cc/<name> - Unpublish hides it back to draft - Preview endpoint serves draft HTML to authenticated users - Caddy blocks .draft/.bak files (defense-in-depth) - Backward compatible: existing sites default to published - sync_visibility() handles all state transitions
This commit is contained in:
@@ -29,16 +29,25 @@
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
# ── Security: block access to internal/hidden files ──
|
||||
# Prevent leaking .draft, .bak, .meta, .git files
|
||||
@blocked path_regexp \.(draft|bak)$|^\.meta|^\.git
|
||||
handle @blocked {
|
||||
error 404
|
||||
}
|
||||
|
||||
# Static demo sites — public, no auth
|
||||
handle_path /* {
|
||||
root * /data/demos
|
||||
file_server {
|
||||
index index.html
|
||||
hide .*
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options SAMEORIGIN
|
||||
-Server
|
||||
}
|
||||
}
|
||||
|
||||
+310
-287
@@ -1,33 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo Manager — FastAPI service for managing demo sites.
|
||||
Pocket ID SSO auth via X-Auth-Request-Email header (set by OAuth2 proxy).
|
||||
Sites start as drafts (unpublished). Publishing makes them public.
|
||||
Pocket ID SSO + API key auth.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
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", "hermes-demo-secret-key-change-me")
|
||||
API_KEY = os.environ.get("DEMO_API_KEY", "")
|
||||
OAUTH_PROXY = os.environ.get("OAUTH_PROXY_URL", "https://pass.junv.cc")
|
||||
|
||||
app = FastAPI(title="Demo Manager", version="1.0.0")
|
||||
app = FastAPI(title="Demo Manager", version="1.1.0")
|
||||
|
||||
# ─── Models ─────────────────────────────────────────
|
||||
class SiteInfo(BaseModel):
|
||||
name: str
|
||||
enabled: bool = True
|
||||
published: bool = False # NEW: draft by default
|
||||
description: str = ""
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
@@ -35,6 +35,7 @@ class SiteInfo(BaseModel):
|
||||
class SiteCreate(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
published: bool = False # Can opt to publish on create
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────
|
||||
def load_meta() -> dict:
|
||||
@@ -48,42 +49,30 @@ def save_meta(meta: dict):
|
||||
META_FILE.write_text(json.dumps(meta, indent=2))
|
||||
|
||||
def check_auth(request: Request) -> Optional[str]:
|
||||
"""Return user email if authenticated, None otherwise."""
|
||||
# API key auth (for agent access)
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
if token == API_KEY:
|
||||
return "api-agent"
|
||||
# OAuth2 proxy header (for browser SSO — set when using Nginx auth annotations)
|
||||
email = request.headers.get("X-Auth-Request-Email") or request.headers.get("X-Forwarded-Email")
|
||||
if email:
|
||||
return email
|
||||
# Server-side cookie validation via OAuth2 Proxy
|
||||
# The _oauth2_proxy cookie is scoped to .junv.cc, so it's sent to demo.junv.cc
|
||||
oauth_cookie = request.cookies.get("_oauth2_proxy")
|
||||
if oauth_cookie:
|
||||
try:
|
||||
# Validate session with OAuth2 Proxy
|
||||
resp = httpx.get(
|
||||
f"{OAUTH_PROXY}/oauth2/auth",
|
||||
cookies={"_oauth2_proxy": oauth_cookie},
|
||||
headers={"X-Auth-Request-Redirect": str(request.url)},
|
||||
timeout=5,
|
||||
)
|
||||
if resp.status_code == 202:
|
||||
# Valid session — user is authenticated
|
||||
user_email = resp.headers.get("X-Auth-Request-Email", "")
|
||||
if user_email:
|
||||
return user_email
|
||||
except Exception:
|
||||
pass # Fail open → fall through to None
|
||||
pass
|
||||
return None
|
||||
|
||||
async def check_auth_async(request: Request) -> Optional[str]:
|
||||
"""Async wrapper for check_auth."""
|
||||
return check_auth(request)
|
||||
|
||||
def require_auth(request: Request) -> str:
|
||||
user = check_auth(request)
|
||||
if not user:
|
||||
@@ -91,42 +80,75 @@ def require_auth(request: Request) -> str:
|
||||
return user
|
||||
|
||||
def site_exists(name: str) -> bool:
|
||||
"""Check if site exists: meta entry must exist + directory should exist.
|
||||
If meta entry doesn't exist, site is considered deleted even if directory lingers (NFS stale handles)."""
|
||||
meta = load_meta()
|
||||
if name not in meta.get("sites", {}):
|
||||
return False
|
||||
# Meta says it exists — verify directory too
|
||||
site_dir = DEMO_ROOT / name
|
||||
try:
|
||||
return site_dir.is_dir()
|
||||
except OSError:
|
||||
return True # NFS stale handle, trust meta
|
||||
|
||||
def site_index_path(name: str) -> bool:
|
||||
"""Check if the site has an index.html."""
|
||||
return (DEMO_ROOT / name / "index.html").exists()
|
||||
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)
|
||||
|
||||
def ensure_disabled_index(name: str):
|
||||
"""Copy index.html to .index.html.bak and remove index.html."""
|
||||
# ─── 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
|
||||
site_dir.mkdir(parents=True, exist_ok=True)
|
||||
real_index = site_dir / "index.html"
|
||||
backup_index = site_dir / ".index.html.bak"
|
||||
backup_index = site_dir / f".index.html{suffix}"
|
||||
if real_index.exists():
|
||||
# Copy content then remove original (avoids NFS rename issues)
|
||||
backup_index.write_bytes(real_index.read_bytes())
|
||||
real_index.unlink()
|
||||
|
||||
def ensure_enabled_index(name: str):
|
||||
"""Restore index.html from .index.html.bak."""
|
||||
def restore_index(name: str, suffix: str):
|
||||
"""Restore index.html from backup."""
|
||||
site_dir = DEMO_ROOT / name
|
||||
backup_index = site_dir / ".index.html.bak"
|
||||
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()
|
||||
|
||||
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)
|
||||
site_dir = DEMO_ROOT / name
|
||||
real_index = site_dir / "index.html"
|
||||
draft_index = site_dir / ".index.html.draft"
|
||||
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()
|
||||
|
||||
# ─── API Routes ─────────────────────────────────────
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
@@ -142,8 +164,20 @@ def list_sites(request: Request):
|
||||
site_dir = DEMO_ROOT / name
|
||||
file_count = 0
|
||||
if site_dir.is_dir():
|
||||
file_count = sum(1 for _ in site_dir.rglob("*") if _.is_file())
|
||||
sites.append({**info, "name": name, "exists": site_dir.is_dir(), "file_count": file_count})
|
||||
try:
|
||||
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),
|
||||
"file_count": file_count,
|
||||
"url": f"https://demo.junv.cc/{name}" if is_site_visible(name) else None,
|
||||
"public": is_site_visible(name),
|
||||
})
|
||||
return {"sites": sites, "user": user}
|
||||
|
||||
@app.post("/api/sites")
|
||||
@@ -154,32 +188,39 @@ 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"] = {}
|
||||
|
||||
|
||||
site_dir = DEMO_ROOT / name
|
||||
site_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Create a default index.html
|
||||
(site_dir / "index.html").write_text(f"""<!DOCTYPE html>
|
||||
|
||||
# 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,
|
||||
}
|
||||
meta["sites"][name] = site_info
|
||||
save_meta(meta)
|
||||
|
||||
# Sync visibility
|
||||
sync_visibility(name)
|
||||
|
||||
return site_info
|
||||
|
||||
@app.delete("/api/sites/{name}")
|
||||
@@ -187,16 +228,14 @@ def delete_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")
|
||||
# Always remove from meta first (handles NFS stale file issues)
|
||||
meta = load_meta()
|
||||
meta["sites"].pop(name, None)
|
||||
save_meta(meta)
|
||||
# Try to remove directory; handle NFS issues gracefully
|
||||
site_dir = DEMO_ROOT / name
|
||||
try:
|
||||
shutil.rmtree(site_dir)
|
||||
except OSError:
|
||||
pass # NFS stale file handle — directory will be orphaned but meta is clean
|
||||
pass
|
||||
return {"status": "deleted", "name": name}
|
||||
|
||||
@app.post("/api/sites/{name}/enable")
|
||||
@@ -204,12 +243,12 @@ 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")
|
||||
ensure_enabled_index(name)
|
||||
meta = load_meta()
|
||||
if name in meta.get("sites", {}):
|
||||
meta["sites"][name]["enabled"] = True
|
||||
meta["sites"][name]["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
save_meta(meta)
|
||||
sync_visibility(name)
|
||||
return {"status": "enabled", "name": name}
|
||||
|
||||
@app.post("/api/sites/{name}/disable")
|
||||
@@ -217,72 +256,120 @@ 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")
|
||||
ensure_disabled_index(name)
|
||||
meta = load_meta()
|
||||
if name in meta.get("sites", {}):
|
||||
meta["sites"][name]["enabled"] = False
|
||||
meta["sites"][name]["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
save_meta(meta)
|
||||
sync_visibility(name)
|
||||
return {"status": "disabled", "name": name}
|
||||
|
||||
@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")
|
||||
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}",
|
||||
}
|
||||
|
||||
@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")
|
||||
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"}
|
||||
|
||||
@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")
|
||||
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")
|
||||
|
||||
@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")
|
||||
meta = load_meta()
|
||||
info = meta.get("sites", {}).get(name, {"name": name, "enabled": True})
|
||||
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
|
||||
files = []
|
||||
if site_dir.is_dir():
|
||||
for f in sorted(site_dir.rglob("*")):
|
||||
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})
|
||||
return {"site": info, "files": files, "url": f"https://demo.junv.cc/{name}"}
|
||||
try:
|
||||
for f in sorted(site_dir.rglob("*")):
|
||||
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
|
||||
return {
|
||||
"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("")):
|
||||
"""Upload a file to a demo site. Set path to put file in a subdirectory."""
|
||||
user = require_auth(request)
|
||||
if not site_exists(name):
|
||||
raise HTTPException(status_code=404, detail=f"Site '{name}' not found. Create it first.")
|
||||
|
||||
site_dir = DEMO_ROOT / name
|
||||
# Prevent path traversal
|
||||
safe_path = path.strip("/").replace("..", "")
|
||||
dest_dir = site_dir / safe_path
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = dest_dir / file.filename
|
||||
content = await file.read()
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Update timestamp
|
||||
meta = load_meta()
|
||||
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))}"
|
||||
"url": f"https://demo.junv.cc/{name}/{str(file_path.relative_to(site_dir))}" if is_site_visible(name) else None,
|
||||
}
|
||||
|
||||
@app.delete("/api/sites/{name}/files")
|
||||
def delete_file(name: str, request: Request, path: str = Form("")):
|
||||
"""Delete a file from a demo site."""
|
||||
user = require_auth(request)
|
||||
if not site_exists(name):
|
||||
raise HTTPException(status_code=404, detail=f"Site '{name}' not found")
|
||||
|
||||
safe_path = path.strip("/").replace("..", "")
|
||||
if not safe_path:
|
||||
raise HTTPException(status_code=400, detail="path is 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}")
|
||||
@@ -290,12 +377,11 @@ def delete_file(name: str, request: Request, path: str = Form("")):
|
||||
shutil.rmtree(file_path)
|
||||
else:
|
||||
file_path.unlink()
|
||||
|
||||
return {"status": "deleted", "name": name, "file": safe_path}
|
||||
|
||||
|
||||
# ─── Web UI ─────────────────────────────────────────
|
||||
UI_HTML = """<!DOCTYPE html>
|
||||
UI_HTML = r"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -314,6 +400,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
.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}
|
||||
@@ -324,10 +414,12 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
.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:8px}
|
||||
.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}
|
||||
@@ -338,267 +430,200 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
.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:flex;align-items:center;justify-content:center;z-index:100}
|
||||
.modal{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:24px;max-width:500px;width:90%}
|
||||
.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}
|
||||
.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="header"><h1>🧪 Demo Manager</h1><div class="user" id="user-info">Loading...</div></div>
|
||||
<div class="container">
|
||||
<div class="section">
|
||||
<h2>➕ Create Demo Site</h2>
|
||||
<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="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>📂 My Demo Sites</h2>
|
||||
<div id="sites-list">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="file-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal" id="file-modal-content"></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';
|
||||
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');
|
||||
}
|
||||
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');
|
||||
}
|
||||
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 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(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
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">'+
|
||||
(isPublic?'<a href="https://demo.junv.cc/'+s.name+'" target="_blank">'+s.name+'</a>':'<span>'+s.name+'</span>')+
|
||||
' '+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>';}
|
||||
}
|
||||
|
||||
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 + ' (via Pocket ID)';
|
||||
|
||||
if (data.sites.length === 0) {
|
||||
container.innerHTML = '<p style="color:#8b949e;text-align:center;padding:32px">No demo sites yet. Create one above!</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = data.sites.map(s => `
|
||||
<div class="site-card">
|
||||
<div class="info">
|
||||
<div class="name">
|
||||
<a href="https://demo.junv.cc/${s.name}" target="_blank">${s.name}</a>
|
||||
<span class="badge ${s.enabled ? 'badge-on' : 'badge-off'}">${s.enabled ? 'ON' : 'OFF'}</span>
|
||||
</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">
|
||||
<button class="btn btn-sm" onclick="manageSite('${s.name}')">📁 Files</button>
|
||||
${s.enabled
|
||||
? `<button class="btn btn-sm btn-danger" onclick="toggleSite('${s.name}','disable')">⏸ Disable</button>`
|
||||
: `<button class="btn btn-sm" onclick="toggleSite('${s.name}','enable')" style="background:#1b3a1b;border-color:#3fb950;color:#3fb950">▶ Enable</button>`
|
||||
}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteSite('${s.name}')">🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p style="color:#f85149">Error loading sites: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSite(e) {
|
||||
async function createSite(e){
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('site-name').value.trim();
|
||||
const desc = document.getElementById('site-desc').value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await api('POST', API, { name, description: desc });
|
||||
document.getElementById('site-name').value = '';
|
||||
document.getElementById('site-desc').value = '';
|
||||
toast(`Site "${name}" created!`, 'success');
|
||||
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');
|
||||
}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 toggleSite(name, action) {
|
||||
try {
|
||||
await api('POST', `${API}/${name}/${action}`);
|
||||
toast(`Site "${name}" ${action}d`, 'success');
|
||||
loadSites();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
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>';
|
||||
|
||||
async function deleteSite(name) {
|
||||
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await api('DELETE', `${API}/${name}`);
|
||||
toast(`Site "${name}" deleted`, 'success');
|
||||
loadSites();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<a href="https://demo.junv.cc/${name}" target="_blank" style="color:#58a6ff;text-decoration:none">🔗 demo.junv.cc/${name}</a>
|
||||
</div>
|
||||
<div class="upload-zone" id="upload-zone-${name}">
|
||||
<div id="upload-label-${name}">📤 Drop files here 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 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" onclick="document.getElementById('file-modal').style.display='none'">Close</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Load files
|
||||
try {
|
||||
const data = await api('GET', `${API}/${name}`);
|
||||
const filesDiv = document.getElementById(`files-list-${name}`);
|
||||
if (data.files.length === 0) {
|
||||
filesDiv.innerHTML = '<p style="color:#8b949e">No files yet.</p>';
|
||||
} else {
|
||||
filesDiv.innerHTML = '<ul class="file-list">' + data.files.map(f => `
|
||||
<li>
|
||||
<span>
|
||||
<a href="https://demo.junv.cc/${name}/${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>';
|
||||
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>`;
|
||||
}
|
||||
|
||||
// Upload handler
|
||||
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 = '';
|
||||
const files = e.dataTransfer.files;
|
||||
await handleUpload(name, files);
|
||||
};
|
||||
input.onchange = async () => {
|
||||
await handleUpload(name, input.files);
|
||||
input.value = '';
|
||||
};
|
||||
}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 progressDiv = document.getElementById(`upload-progress-${siteName}`);
|
||||
const label = document.getElementById(`upload-label-${siteName}`);
|
||||
progressDiv.style.display = 'block';
|
||||
|
||||
for (const file of files) {
|
||||
progressDiv.innerHTML = `<span class="spinner"></span> Uploading ${file.name}...`;
|
||||
try {
|
||||
await uploadFile(siteName, file, '');
|
||||
progressDiv.innerHTML += ` ✅ ${file.name}<br>`;
|
||||
} catch (e) {
|
||||
progressDiv.innerHTML += ` ❌ ${file.name}: ${e.message}<br>`;
|
||||
}
|
||||
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); // Refresh
|
||||
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); // Refresh
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
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;
|
||||
document.getElementById('create-form').onsubmit=createSite;
|
||||
loadSites();
|
||||
</script>
|
||||
</body>
|
||||
@@ -608,13 +633,11 @@ loadSites();
|
||||
@app.get("/admin/", response_class=HTMLResponse)
|
||||
@app.get("/admin/{path:path}", response_class=HTMLResponse)
|
||||
def admin_ui(request: Request, path: str = ""):
|
||||
"""Serve the admin Web UI."""
|
||||
require_auth(request)
|
||||
return HTMLResponse(UI_HTML)
|
||||
|
||||
@app.get("/")
|
||||
def index(request: Request):
|
||||
"""Root redirects to admin."""
|
||||
return HTMLResponse("""<!DOCTYPE html>
|
||||
<html><head><meta http-equiv="refresh" content="0;url=/admin"></head>
|
||||
<body><p>Redirecting to <a href="/admin">Admin</a>...</p></body>
|
||||
|
||||
Reference in New Issue
Block a user