Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- Dockerfile.txt +14 -0
- app.py +49 -0
- requirements.txt +2 -0
- static/index.html +414 -0
Dockerfile.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
COPY requirements.txt /app/
|
| 8 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 9 |
+
|
| 10 |
+
COPY app.py /app/
|
| 11 |
+
COPY static /app/static
|
| 12 |
+
|
| 13 |
+
EXPOSE 7860
|
| 14 |
+
CMD ["python", "app.py"]
|
app.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import threading
|
| 3 |
+
from fastapi import FastAPI, Request
|
| 4 |
+
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from fastapi.staticfiles import StaticFiles
|
| 7 |
+
import uvicorn
|
| 8 |
+
|
| 9 |
+
app = FastAPI()
|
| 10 |
+
app.add_middleware(
|
| 11 |
+
CORSMiddleware,
|
| 12 |
+
allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
# In-memory, process-lifetime only
|
| 16 |
+
_CONFIG = {}
|
| 17 |
+
_LOCK = threading.Lock()
|
| 18 |
+
|
| 19 |
+
# Serve static frontend
|
| 20 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 21 |
+
|
| 22 |
+
@app.get("/", response_class=FileResponse)
|
| 23 |
+
def root():
|
| 24 |
+
return FileResponse("static/index.html")
|
| 25 |
+
|
| 26 |
+
@app.get("/api/ping", response_class=PlainTextResponse)
|
| 27 |
+
def ping():
|
| 28 |
+
return "pong"
|
| 29 |
+
|
| 30 |
+
@app.get("/api/config")
|
| 31 |
+
def get_config():
|
| 32 |
+
with _LOCK:
|
| 33 |
+
return JSONResponse(_CONFIG or {"background": None, "items": []})
|
| 34 |
+
|
| 35 |
+
@app.post("/api/config")
|
| 36 |
+
async def set_config(req: Request):
|
| 37 |
+
data = await req.json()
|
| 38 |
+
# Basic shape guard
|
| 39 |
+
if not isinstance(data, dict):
|
| 40 |
+
return JSONResponse({"error": "Invalid payload"}, status_code=400)
|
| 41 |
+
with _LOCK:
|
| 42 |
+
# store exactly what client sent (ephemeral)
|
| 43 |
+
_CONFIG.clear()
|
| 44 |
+
_CONFIG.update(data)
|
| 45 |
+
return JSONResponse({"ok": True})
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
port = int(os.environ.get("PORT", "7860"))
|
| 49 |
+
uvicorn.run("app:app", host="0.0.0.0", port=port, reload=False)
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.30.6
|
static/index.html
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<title>Overlay Hotspots (Ephemeral Autosave)</title>
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<style>
|
| 8 |
+
:root{
|
| 9 |
+
--bg:#0b1220; --panel:#121a2e; --muted:#9fb0cf; --text:#e6edf7;
|
| 10 |
+
--accent:#3a8dde; --accent2:#6ea8ff; --ok:#2ecc71; --warn:#ffb020; --danger:#ff6b6b;
|
| 11 |
+
--radius:16px; --shadow:0 12px 30px rgba(0,0,0,.35);
|
| 12 |
+
}
|
| 13 |
+
*{box-sizing:border-box}
|
| 14 |
+
html,body{height:100%; margin:0; background:var(--bg); color:var(--text); font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}
|
| 15 |
+
#app{display:grid; grid-template-columns: 360px 1fr; height:100%}
|
| 16 |
+
|
| 17 |
+
/* Sidebar */
|
| 18 |
+
#panel{background:linear-gradient(180deg,#121a2e,#0e162b); border-right:1px solid #1e2844; padding:16px; overflow:auto; position:relative; transition:transform .28s ease; box-shadow:var(--shadow); z-index:5;}
|
| 19 |
+
#panel.min{transform:translateX(-100%)}
|
| 20 |
+
#panel h2{margin:.3rem 0 1rem; font-size:1.05rem; color:#cfe0ff}
|
| 21 |
+
fieldset{border:1px solid #2a395f; border-radius:12px; padding:12px; margin:10px 0}
|
| 22 |
+
legend{font-size:.9rem; color:#bcd0ff; padding:0 6px}
|
| 23 |
+
label{display:block; font-size:.85rem; color:var(--muted); margin:8px 0 4px}
|
| 24 |
+
input[type="text"], input[type="url"], select{width:100%; padding:10px 12px; border-radius:10px; border:1px solid #26365a; background:#0c1430; color:var(--text)}
|
| 25 |
+
input[type="range"]{width:100%}
|
| 26 |
+
.row{display:flex; gap:8px}
|
| 27 |
+
.row>*{flex:1}
|
| 28 |
+
button{cursor:pointer; border:1px solid #2a3a63; background:#132042; color:#e8f0ff; padding:10px 12px; border-radius:12px; transition:filter .2s, transform .05s; font-weight:600}
|
| 29 |
+
button:hover{filter:brightness(1.1)}
|
| 30 |
+
button:active{transform:translateY(1px)}
|
| 31 |
+
.btn-accent{background:linear-gradient(180deg, #274d8a, #1d3b6b)}
|
| 32 |
+
.btn-danger{background:#3a1420; border-color:#5a2a39}
|
| 33 |
+
.btn-ghost{background:transparent}
|
| 34 |
+
.hint{font-size:.8rem; color:#9fb0cf; opacity:.9}
|
| 35 |
+
.badge{display:inline-block; padding:2px 8px; border-radius:999px; font-size:.75rem; background:#1b2b54; color:#cfe0ff}
|
| 36 |
+
.hr{height:1px; background:#203059; margin:12px 0}
|
| 37 |
+
|
| 38 |
+
/* Panel toggle */
|
| 39 |
+
#toggle{position:absolute; left:355px; top:12px; z-index:6; width:36px; height:36px; border-radius:10px; display:grid; place-items:center; background:#0d1a36; border:1px solid #2a395f;}
|
| 40 |
+
#panel.min + #toggle{left:8px}
|
| 41 |
+
#saveState{position:absolute; bottom:10px; right:12px; font-size:.8rem; border-radius:999px; padding:6px 10px; opacity:.9}
|
| 42 |
+
|
| 43 |
+
/* Stage */
|
| 44 |
+
#stage{position:relative; height:100%; width:100%; overflow:hidden; background:#000; user-select:none}
|
| 45 |
+
.bgmedia{position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:0}
|
| 46 |
+
#overlay{position:absolute; inset:0; z-index:2}
|
| 47 |
+
#stageMsg{position:absolute; top:12px; right:12px; background:rgba(10,14,30,.55); border:1px solid #2a3a63; padding:8px 12px; border-radius:12px; z-index:4; backdrop-filter: blur(4px); font-size:.9rem}
|
| 48 |
+
|
| 49 |
+
/* Pins and popups */
|
| 50 |
+
.pin{position:absolute; width:18px; height:18px; border-radius:50%; background:rgba(255,255,255,.25); border:2px solid rgba(255,255,255,.5); box-shadow:0 2px 10px rgba(0,0,0,.45); transform:translate(-50%, -50%); z-index:3}
|
| 51 |
+
.pin:hover{background:rgba(255,255,255,.35)}
|
| 52 |
+
.popup{position:absolute; z-index:4; transform: translate(-50%, calc(-100% - 12px)); background:rgba(13,20,40,.92); border:1px solid #2a395f; border-radius:16px; box-shadow:var(--shadow); padding:10px; min-width:200px; max-width:min(40vw, 520px); backdrop-filter: blur(6px)}
|
| 53 |
+
.popup header{display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px}
|
| 54 |
+
.popup h4{margin:0; font-size:.95rem; color:#d9e6ff}
|
| 55 |
+
.x{background:transparent; border:none; color:#cfe0ff; font-size:18px; padding:4px 8px}
|
| 56 |
+
.media{width:100%; height:auto; border-radius:12px; overflow:hidden; display:block; background:#000}
|
| 57 |
+
.media video{width:100%; height:auto; display:block}
|
| 58 |
+
.media img{width:100%; height:auto; display:block}
|
| 59 |
+
|
| 60 |
+
/* Items list */
|
| 61 |
+
.item{display:grid; grid-template-columns:auto 1fr auto; gap:8px; align-items:center; padding:8px; border:1px solid #21345d; border-radius:10px; margin:8px 0; background:#0e1834}
|
| 62 |
+
.item .name{font-size:.9rem}
|
| 63 |
+
.item .tiny{font-size:.75rem; color:#9fb0cf}
|
| 64 |
+
.item .actions{display:flex; gap:6px}
|
| 65 |
+
</style>
|
| 66 |
+
</head>
|
| 67 |
+
<body>
|
| 68 |
+
<div id="app">
|
| 69 |
+
<aside id="panel">
|
| 70 |
+
<h2>Background</h2>
|
| 71 |
+
<fieldset>
|
| 72 |
+
<div class="row">
|
| 73 |
+
<label>Type
|
| 74 |
+
<select id="bgType">
|
| 75 |
+
<option value="image">Image</option>
|
| 76 |
+
<option value="video">Video (loop, muted)</option>
|
| 77 |
+
</select>
|
| 78 |
+
</label>
|
| 79 |
+
<label>Fit
|
| 80 |
+
<select id="bgFit">
|
| 81 |
+
<option value="cover" selected>Cover</option>
|
| 82 |
+
<option value="contain">Contain</option>
|
| 83 |
+
</select>
|
| 84 |
+
</label>
|
| 85 |
+
</div>
|
| 86 |
+
<label>URL (https://…)</label>
|
| 87 |
+
<input type="url" id="bgUrl" placeholder="Paste image/video URL" />
|
| 88 |
+
<label>Or choose file
|
| 89 |
+
<input type="file" id="bgFile" accept="image/*,video/*" />
|
| 90 |
+
</label>
|
| 91 |
+
<div class="row" style="margin-top:8px">
|
| 92 |
+
<button id="applyBg" class="btn-accent">Apply</button>
|
| 93 |
+
<button id="clearBg" class="btn-ghost">Clear</button>
|
| 94 |
+
</div>
|
| 95 |
+
<div class="hint">Files are converted to data URLs so they persist across reloads (memory only). For large videos, prefer a URL.</div>
|
| 96 |
+
</fieldset>
|
| 97 |
+
|
| 98 |
+
<h2>Add Item</h2>
|
| 99 |
+
<fieldset>
|
| 100 |
+
<div class="row">
|
| 101 |
+
<label>Type
|
| 102 |
+
<select id="itemType"><option value="image">Image</option><option value="video">Video</option></select>
|
| 103 |
+
</label>
|
| 104 |
+
<label>Size (% of viewport width)
|
| 105 |
+
<input type="range" id="itemSize" min="10" max="40" value="25" />
|
| 106 |
+
</label>
|
| 107 |
+
</div>
|
| 108 |
+
<label>Title</label>
|
| 109 |
+
<input type="text" id="itemTitle" placeholder="Short label" />
|
| 110 |
+
<label>Media URL (https://…)</label>
|
| 111 |
+
<input type="url" id="itemUrl" placeholder="Paste direct image/video URL" />
|
| 112 |
+
<label>Or choose file
|
| 113 |
+
<input type="file" id="itemFile" accept="image/*,video/*" />
|
| 114 |
+
</label>
|
| 115 |
+
<div class="row" style="margin-top:8px">
|
| 116 |
+
<button id="addItem" class="btn-accent">Add</button>
|
| 117 |
+
<button id="addAndPlace">Add & Place</button>
|
| 118 |
+
</div>
|
| 119 |
+
<div class="hint">After “Add & Place,” click the stage where you want the hotspot.</div>
|
| 120 |
+
</fieldset>
|
| 121 |
+
|
| 122 |
+
<h2>Items <span class="badge" id="count">0</span></h2>
|
| 123 |
+
<div id="items"></div>
|
| 124 |
+
|
| 125 |
+
<div class="hr"></div>
|
| 126 |
+
<div class="row">
|
| 127 |
+
<button id="clearAll" class="btn-danger">Clear All</button>
|
| 128 |
+
<button id="downloadJson">Download JSON</button>
|
| 129 |
+
</div>
|
| 130 |
+
<div id="help" class="hint" style="margin-top:8px">
|
| 131 |
+
Minimize the panel with the chevron. Click a translucent dot to open its popup.
|
| 132 |
+
</div>
|
| 133 |
+
</aside>
|
| 134 |
+
|
| 135 |
+
<button id="toggle" title="Hide/Show panel">◀</button>
|
| 136 |
+
|
| 137 |
+
<main id="stage" aria-label="Stage">
|
| 138 |
+
<img id="bgImg" class="bgmedia" alt="" style="display:none" />
|
| 139 |
+
<video id="bgVid" class="bgmedia" autoplay muted loop playsinline style="display:none"></video>
|
| 140 |
+
<div id="overlay" title="Click to place when in placement mode"></div>
|
| 141 |
+
<div id="stageMsg" style="display:none"></div>
|
| 142 |
+
<button id="saveState" class="btn-ghost">Saved</button>
|
| 143 |
+
</main>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<script>
|
| 147 |
+
(() => {
|
| 148 |
+
const $ = s => document.querySelector(s);
|
| 149 |
+
const $$ = s => Array.from(document.querySelectorAll(s));
|
| 150 |
+
|
| 151 |
+
const stage = $('#stage'), overlay = $('#overlay');
|
| 152 |
+
const bgImg = $('#bgImg'), bgVid = $('#bgVid');
|
| 153 |
+
const panel = $('#panel'), toggle = $('#toggle');
|
| 154 |
+
const stageMsg = $('#stageMsg');
|
| 155 |
+
const saveBadge = $('#saveState');
|
| 156 |
+
|
| 157 |
+
const state = {
|
| 158 |
+
background: null, // {type:'image'|'video', src:'data/http(s) URL', fit:'cover'|'contain'}
|
| 159 |
+
items: [], // {id,type,title,src,x,y,widthPct,open:false}
|
| 160 |
+
placingId: null
|
| 161 |
+
};
|
| 162 |
+
const uid = () => Math.random().toString(36).slice(2,9);
|
| 163 |
+
const markDirty = (() => {
|
| 164 |
+
let t;
|
| 165 |
+
return function(){ saveBadge.textContent = 'Saving…'; saveBadge.style.background = '#1b2b54';
|
| 166 |
+
window.clearTimeout(t); t = setTimeout(saveToServer, 350);
|
| 167 |
+
};
|
| 168 |
+
})();
|
| 169 |
+
|
| 170 |
+
// Panel toggle
|
| 171 |
+
const updateToggleIcon = () => { toggle.textContent = panel.classList.contains('min') ? '▶' : '◀'; };
|
| 172 |
+
toggle.addEventListener('click', () => { panel.classList.toggle('min'); updateToggleIcon(); });
|
| 173 |
+
updateToggleIcon();
|
| 174 |
+
|
| 175 |
+
// Helpers
|
| 176 |
+
function escapeHtml(s){return (s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
| 177 |
+
|
| 178 |
+
async function fileToDataURL(file){
|
| 179 |
+
if (!file) return '';
|
| 180 |
+
const maxInlineMB = 40; // guardrail
|
| 181 |
+
if (file.size > maxInlineMB * 1024 * 1024) { alert(`File is larger than ${maxInlineMB} MB. Use a URL instead.`); return ''; }
|
| 182 |
+
return new Promise((res,rej)=>{
|
| 183 |
+
const r = new FileReader();
|
| 184 |
+
r.onload = () => res(r.result);
|
| 185 |
+
r.onerror = () => rej(new Error('Failed to read file'));
|
| 186 |
+
r.readAsDataURL(file);
|
| 187 |
+
});
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// Background
|
| 191 |
+
$('#applyBg').addEventListener('click', async () => {
|
| 192 |
+
const type = $('#bgType').value;
|
| 193 |
+
const fit = $('#bgFit').value;
|
| 194 |
+
let src = $('#bgUrl').value.trim();
|
| 195 |
+
const file = $('#bgFile').files[0];
|
| 196 |
+
|
| 197 |
+
if (file) {
|
| 198 |
+
src = await fileToDataURL(file);
|
| 199 |
+
if (!src) return;
|
| 200 |
+
} else if (!src) {
|
| 201 |
+
alert('Provide a media URL or choose a file.'); return;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
state.background = { type, src, fit };
|
| 205 |
+
renderBackground(); markDirty();
|
| 206 |
+
$('#bgFile').value=''; $('#bgUrl').value='';
|
| 207 |
+
});
|
| 208 |
+
|
| 209 |
+
$('#clearBg').addEventListener('click', () => {
|
| 210 |
+
state.background = null;
|
| 211 |
+
renderBackground(); markDirty();
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
function renderBackground(){
|
| 215 |
+
if (!state.background){
|
| 216 |
+
bgImg.style.display='none'; bgVid.style.display='none'; return;
|
| 217 |
+
}
|
| 218 |
+
const {type, src, fit} = state.background;
|
| 219 |
+
bgImg.style.objectFit = fit; bgVid.style.objectFit = fit;
|
| 220 |
+
if (type === 'video'){ bgVid.src = src; bgVid.style.display='block'; bgImg.style.display='none'; }
|
| 221 |
+
else { bgImg.src = src; bgImg.style.display='block'; bgVid.style.display='none'; }
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// Items
|
| 225 |
+
$('#addItem').addEventListener('click', () => addOrPlace(false));
|
| 226 |
+
$('#addAndPlace').addEventListener('click', () => addOrPlace(true));
|
| 227 |
+
|
| 228 |
+
async function addOrPlace(shouldPlace){
|
| 229 |
+
const type = $('#itemType').value;
|
| 230 |
+
const title = $('#itemTitle').value.trim();
|
| 231 |
+
const url = $('#itemUrl').value.trim();
|
| 232 |
+
const file = $('#itemFile').files[0];
|
| 233 |
+
const widthPct = Number($('#itemSize').value);
|
| 234 |
+
let src = url;
|
| 235 |
+
|
| 236 |
+
if (file){ src = await fileToDataURL(file); if (!src) return; }
|
| 237 |
+
if (!src){ alert('Provide a media URL or choose a file.'); return; }
|
| 238 |
+
|
| 239 |
+
const it = { id: uid(), type, title, src, x:50, y:50, widthPct, open:false };
|
| 240 |
+
state.items.push(it);
|
| 241 |
+
renderItems(); markDirty();
|
| 242 |
+
|
| 243 |
+
$('#itemFile').value=''; $('#itemUrl').value=''; $('#itemTitle').value='';
|
| 244 |
+
if (shouldPlace) startPlacing(it.id);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
function removeItem(id){
|
| 248 |
+
const i = state.items.findIndex(x=>x.id===id);
|
| 249 |
+
if (i>=0){ state.items.splice(i,1); renderItems(); markDirty(); }
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function startPlacing(id){
|
| 253 |
+
state.placingId = id;
|
| 254 |
+
stageMsg.textContent = 'Placement mode: click anywhere on the stage';
|
| 255 |
+
stageMsg.style.display='block';
|
| 256 |
+
}
|
| 257 |
+
function stopPlacing(){ state.placingId=null; stageMsg.style.display='none'; }
|
| 258 |
+
|
| 259 |
+
overlay.addEventListener('click', (ev) => {
|
| 260 |
+
if (!state.placingId) return;
|
| 261 |
+
const rect = overlay.getBoundingClientRect();
|
| 262 |
+
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
|
| 263 |
+
const yPct = ((ev.clientY - rect.top) / rect.height) * 100;
|
| 264 |
+
const it = state.items.find(x=>x.id===state.placingId);
|
| 265 |
+
if (it){
|
| 266 |
+
it.x = Math.max(0, Math.min(100, xPct));
|
| 267 |
+
it.y = Math.max(0, Math.min(100, yPct));
|
| 268 |
+
renderItems(); markDirty();
|
| 269 |
+
}
|
| 270 |
+
stopPlacing();
|
| 271 |
+
});
|
| 272 |
+
|
| 273 |
+
function togglePopup(id){
|
| 274 |
+
const it = state.items.find(x=>x.id===id);
|
| 275 |
+
if (!it) return;
|
| 276 |
+
state.items.forEach(x=>{ if (x.id!==id) x.open=false; });
|
| 277 |
+
it.open = !it.open;
|
| 278 |
+
renderItems(); // visual only, don't mark dirty for open state
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
function adjustPopupPosition(el){
|
| 282 |
+
const rect = el.getBoundingClientRect();
|
| 283 |
+
const margin = 8; let dx=0, dy=0;
|
| 284 |
+
if (rect.left < margin) dx = margin - rect.left;
|
| 285 |
+
if (rect.right > innerWidth - margin) dx = (innerWidth - margin) - rect.right;
|
| 286 |
+
if (rect.top < margin) dy = margin - rect.top;
|
| 287 |
+
if (rect.bottom > innerHeight - margin) dy = (innerHeight - margin) - rect.bottom;
|
| 288 |
+
if (dx || dy) el.style.transform += ` translate(${dx}px, ${dy}px)`;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function renderItems(){
|
| 292 |
+
$('#count').textContent = String(state.items.length);
|
| 293 |
+
overlay.innerHTML = '';
|
| 294 |
+
for (const it of state.items){
|
| 295 |
+
const pin = document.createElement('button');
|
| 296 |
+
pin.className='pin';
|
| 297 |
+
pin.style.left = it.x+'%'; pin.style.top = it.y+'%';
|
| 298 |
+
pin.title = it.title || it.type;
|
| 299 |
+
pin.addEventListener('click', e => { e.stopPropagation(); togglePopup(it.id); });
|
| 300 |
+
overlay.appendChild(pin);
|
| 301 |
+
|
| 302 |
+
if (it.open){
|
| 303 |
+
const pop = document.createElement('div'); pop.className='popup';
|
| 304 |
+
pop.style.left = it.x+'%'; pop.style.top = it.y+'%'; pop.style.width = it.widthPct+'vw';
|
| 305 |
+
const header = document.createElement('header');
|
| 306 |
+
const h4 = document.createElement('h4'); h4.textContent = it.title || (it.type==='image'?'Image':'Video');
|
| 307 |
+
const close = document.createElement('button'); close.className='x'; close.innerHTML='×';
|
| 308 |
+
close.addEventListener('click', e=>{ e.stopPropagation(); it.open=false; renderItems(); });
|
| 309 |
+
header.append(h4, close); pop.appendChild(header);
|
| 310 |
+
const wrap = document.createElement('div'); wrap.className='media';
|
| 311 |
+
if (it.type==='video'){ const v = document.createElement('video'); v.src=it.src; v.controls=true; v.playsInline=true; wrap.appendChild(v); }
|
| 312 |
+
else { const img = document.createElement('img'); img.src=it.src; img.alt=it.title||'Image'; wrap.appendChild(img); }
|
| 313 |
+
pop.appendChild(wrap); overlay.appendChild(pop);
|
| 314 |
+
requestAnimationFrame(()=>adjustPopupPosition(pop));
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// items list
|
| 319 |
+
const list = $('#items'); list.innerHTML='';
|
| 320 |
+
for (const it of state.items){
|
| 321 |
+
const row = document.createElement('div'); row.className='item';
|
| 322 |
+
const kind = document.createElement('div'); kind.className='badge'; kind.textContent = it.type.toUpperCase();
|
| 323 |
+
const info = document.createElement('div');
|
| 324 |
+
info.innerHTML = `<div class="name">${escapeHtml(it.title||'')}</div>
|
| 325 |
+
<div class="tiny">${it.widthPct}% • (${it.x.toFixed(1)}%, ${it.y.toFixed(1)}%)</div>`;
|
| 326 |
+
const actions = document.createElement('div'); actions.className='actions';
|
| 327 |
+
|
| 328 |
+
const btnPlace = document.createElement('button'); btnPlace.textContent='Place';
|
| 329 |
+
btnPlace.addEventListener('click', ()=> startPlacing(it.id));
|
| 330 |
+
|
| 331 |
+
const btnPreview = document.createElement('button'); btnPreview.textContent = it.open ? 'Hide' : 'Preview';
|
| 332 |
+
btnPreview.addEventListener('click', ()=> togglePopup(it.id));
|
| 333 |
+
|
| 334 |
+
const sizeIn = document.createElement('input'); sizeIn.type='range'; sizeIn.min='10'; sizeIn.max='40'; sizeIn.value=String(it.widthPct);
|
| 335 |
+
sizeIn.addEventListener('input', ()=>{ it.widthPct=Number(sizeIn.value); renderItems(); markDirty(); });
|
| 336 |
+
|
| 337 |
+
const btnDel = document.createElement('button'); btnDel.textContent='Delete'; btnDel.className='btn-danger';
|
| 338 |
+
btnDel.addEventListener('click', ()=> removeItem(it.id));
|
| 339 |
+
|
| 340 |
+
actions.append(btnPlace, btnPreview, sizeIn, btnDel);
|
| 341 |
+
row.append(kind, info, actions); list.appendChild(row);
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// Save / Load (ephemeral)
|
| 346 |
+
async function saveToServer(){
|
| 347 |
+
try{
|
| 348 |
+
const payload = {
|
| 349 |
+
background: state.background ? { ...state.background } : null,
|
| 350 |
+
items: state.items.map(({open, ...rest}) => rest)
|
| 351 |
+
};
|
| 352 |
+
const res = await fetch('/api/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)});
|
| 353 |
+
if (!res.ok) throw new Error('Save failed');
|
| 354 |
+
saveBadge.textContent = 'Saved'; saveBadge.style.background = '#132042';
|
| 355 |
+
}catch(e){
|
| 356 |
+
saveBadge.textContent = 'Save error'; saveBadge.style.background = '#5a2a39';
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
async function loadFromServer(){
|
| 361 |
+
try{
|
| 362 |
+
const res = await fetch('/api/config');
|
| 363 |
+
if (!res.ok) throw new Error('Load failed');
|
| 364 |
+
const data = await res.json();
|
| 365 |
+
state.background = data.background || null;
|
| 366 |
+
state.items = Array.isArray(data.items) ? data.items.map(x=>({open:false, ...x})) : [];
|
| 367 |
+
renderBackground(); renderItems();
|
| 368 |
+
saveBadge.textContent = 'Loaded'; setTimeout(()=> saveBadge.textContent='Saved', 700);
|
| 369 |
+
}catch(e){
|
| 370 |
+
saveBadge.textContent = 'No saved state';
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
// Clear / Download
|
| 375 |
+
$('#clearAll').addEventListener('click', async () => {
|
| 376 |
+
if (!confirm('Clear background and all items?')) return;
|
| 377 |
+
state.background = null; state.items = [];
|
| 378 |
+
renderBackground(); renderItems(); markDirty();
|
| 379 |
+
});
|
| 380 |
+
|
| 381 |
+
$('#downloadJson').addEventListener('click', () => {
|
| 382 |
+
const data = JSON.stringify({
|
| 383 |
+
background: state.background || null,
|
| 384 |
+
items: state.items.map(({open, ...rest})=>rest)
|
| 385 |
+
}, null, 2);
|
| 386 |
+
const blob = new Blob([data], {type:'application/json'});
|
| 387 |
+
const url = URL.createObjectURL(blob);
|
| 388 |
+
const a = document.createElement('a'); a.href = url; a.download = 'overlay-config.json'; a.click();
|
| 389 |
+
URL.revokeObjectURL(url);
|
| 390 |
+
});
|
| 391 |
+
|
| 392 |
+
// Misc
|
| 393 |
+
function flash(msg){
|
| 394 |
+
stageMsg.textContent = msg;
|
| 395 |
+
stageMsg.style.display='block'; stageMsg.style.opacity='1';
|
| 396 |
+
setTimeout(()=>{ stageMsg.style.transition='opacity .4s'; stageMsg.style.opacity='0';
|
| 397 |
+
setTimeout(()=>{ stageMsg.style.display='none'; stageMsg.style.transition=''; }, 450);
|
| 398 |
+
}, 900);
|
| 399 |
+
}
|
| 400 |
+
window.addEventListener('resize', ()=>{
|
| 401 |
+
$$('.popup').forEach(el => { el.style.transform='translate(-50%, calc(-100% - 12px))'; requestAnimationFrame(()=>adjustPopupPosition(el)); });
|
| 402 |
+
});
|
| 403 |
+
stage.addEventListener('click', e => {
|
| 404 |
+
if (state.placingId) return; // placement handled on overlay
|
| 405 |
+
const inPopup = e.target.closest('.popup,.pin');
|
| 406 |
+
if (!inPopup){ state.items.forEach(x=>x.open=false); renderItems(); }
|
| 407 |
+
});
|
| 408 |
+
|
| 409 |
+
// Boot
|
| 410 |
+
loadFromServer();
|
| 411 |
+
})();
|
| 412 |
+
</script>
|
| 413 |
+
</body>
|
| 414 |
+
</html>
|