ntdservices commited on
Commit
e39cce9
·
verified ·
1 Parent(s): 3fb528b

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile.txt +14 -0
  2. app.py +49 -0
  3. requirements.txt +2 -0
  4. 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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='&times;';
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>