ntdservices's picture
Update static/index.html
fa5b314 verified
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Overlay Hotspots (Ephemeral Autosave)</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root{
--bg:#0b1220; --panel:#121a2e; --muted:#9fb0cf; --text:#e6edf7;
--accent:#3a8dde; --accent2:#6ea8ff; --ok:#2ecc71; --warn:#ffb020; --danger:#ff6b6b;
--radius:16px; --shadow:0 12px 30px rgba(0,0,0,.35);
--panelW:760px; --panelPeek:14px;
}
*{box-sizing:border-box}
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}
#app{position:relative; height:100vh; width:100vw; overflow:hidden}
/* Stage is full-viewport and underlays everything */
#stage{position:fixed; inset:0; width:100vw; height:100vh; overflow:hidden; background:#fff; user-select:none; z-index:0}
.bgmedia{position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:0}
#overlay{position:absolute; inset:0; z-index:2}
#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}
/* Sidebar overlays the stage */
#panel{
position:fixed; top:0; left:0; height:100vh; width:var(--panelW);
background:linear-gradient(180deg,#121a2e,#0e162b);
border-right:1px solid #1e2844; padding:16px; overflow:auto;
transition:transform .28s ease; box-shadow:var(--shadow); z-index:5;
transform:translateX(0);
}
#panel.min{
transform: translateX(-100%);
overflow: hidden;
border-right: none;
background: transparent;
box-shadow: none;
pointer-events: none;
}
#panel h2{margin:.3rem 0 1rem; font-size:1.05rem; color:#cfe0ff}
fieldset{border:1px solid #2a395f; border-radius:12px; padding:12px; margin:10px 0}
legend{font-size:.9rem; color:#bcd0ff; padding:0 6px}
label{display:block; font-size:.85rem; color:var(--muted); margin:8px 0 4px}
input[type="text"], input[type="url"], select{width:100%; padding:10px 12px; border-radius:10px; border:1px solid #26365a; background:#0c1430; color:var(--text)}
input[type="number"]{width:100%; padding:10px 12px; border-radius:10px; border:1px solid #26365a; background:#0c1430; color:#e6edf7}
.row{display:flex; gap:8px}
.row>*{flex:1}
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}
button:hover{filter:brightness(1.1)}
button:active{transform:translateY(1px)}
.btn-accent{background:linear-gradient(180deg, #274d8a, #1d3b6b)}
.btn-danger{background:#3a1420; border-color:#5a2a39}
.btn-ghost{background:transparent}
.hint{font-size:.8rem; color:#9fb0cf; opacity:.9}
.badge{display:inline-block; padding:2px 8px; border-radius:999px; font-size:.75rem; background:#1b2b54; color:#cfe0ff}
.hr{height:1px; background:#203059; margin:12px 0}
/* Panel toggle */
#toggle{
position:fixed; top:12px; left:calc(var(--panelW) + 8px);
z-index:6; width:36px; height:36px; border-radius:10px; display:grid; place-items:center;
background:#0d1a36; border:1px solid #2a395f;
transition:left .28s ease;
}
#panel.min + #toggle{
left:16px;
opacity:0;
filter:grayscale(40%);
}
#saveState{position:fixed; bottom:12px; left:calc(var(--panelW) + 12px); font-size:.8rem; border-radius:999px; padding:6px 10px; opacity:.9; z-index:4; transition:left .28s ease}
#panel.min ~ #stage #saveState, #panel.min ~ #saveState { left:28px; }
#panel.min ~ #saveState { display: none; }
/* Pins (bigger + more transparent) */
/* Pins as very transparent rectangles with faded edges */
.pin {
position: absolute;
left: 0;
top: 0;
width: 80px; /* adjust rectangle size */
height: 40px;
border-radius: 12px; /* rounded edges */
background: radial-gradient(
ellipse at center,
rgba(255, 255, 255, 0.25) 0%,
rgba(255, 255, 255, 0.12) 60%,
rgba(255, 255, 255, 0.0) 100%
);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
transform: translate(-50%, -50%);
z-index: 3;
cursor: pointer;
display: block;
padding: 0;
line-height: 0;
-webkit-appearance: none;
appearance: none;
border: none; /* remove solid border */
}
.pin:hover {
background: radial-gradient(
ellipse at center,
rgba(255, 255, 255, 0.4) 0%,
rgba(255, 255, 255, 0.18) 60%,
rgba(255, 255, 255, 0.0) 100%
);
}
.pin::before{content:""; position:absolute; inset:-10px; border-radius:50%;}
.pin:hover{background:rgba(255,255,255,.26)}
/* Popups centered on pin midpoint */
.popup{
position:absolute; z-index:4;
transform: translate(-50%, -50%);
background:rgba(13,20,40,.92); border:1px solid #2a395f; border-radius:16px; box-shadow:var(--shadow);
padding:0; min-width:120px; max-width:none; backdrop-filter: blur(6px);
}
.media{display:block; width:100%; height:auto; border-radius:16px; overflow:hidden; background:#000}
/* Reserve space so videos don't appear as a thin line pre-metadata */
.media[data-kind="video"]{ aspect-ratio:16/9; min-height:100px; }
.media video, .media img{display:block; width:100%; height:auto}
/* Items list */
.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}
.item .name{font-size:.9rem}
.item .tiny{font-size:.75rem; color:#9fb0cf}
.item .actions{display:flex; gap:6px}
#resizer {
position: absolute;
top: 0;
right: 0;
width: 6px; /* thickness of the draggable bar */
height: 100%;
cursor: ew-resize;
background: transparent; /* invisible but still clickable */
}
</style>
</head>
<body>
<div id="app">
<aside id="panel">
<h2>Background</h2>
<fieldset>
<div class="row">
<label>Type
<select id="bgType">
<option value="image">Image</option>
<option value="video">Video (loop, muted)</option>
</select>
</label>
<label>Fit
<select id="bgFit">
<option value="cover" selected>Cover</option>
<option value="contain">Contain</option>
</select>
</label>
</div>
<label>URL (https://…)</label>
<input type="url" id="bgUrl" placeholder="Paste image/video URL" />
<label>Or choose file
<input type="file" id="bgFile" accept="image/*,video/*" />
</label>
<div class="row" style="margin-top:8px">
<button id="applyBg" class="btn-accent">Apply</button>
<button id="clearBg" class="btn-ghost">Clear</button>
</div>
<div class="hint">Files are converted to data URLs so they persist across reloads (memory only). For large videos, prefer a URL.</div>
</fieldset>
<h2>Add Item</h2>
<fieldset>
<div class="row">
<label>Type
<select id="itemType">
<option value="image">Image</option>
<option value="video">Video</option>
</select>
</label>
<label>Popup width</label>
</div>
<div class="row">
<input type="number" id="itemWidth" min="10" max="2000" value="25" />
<select id="itemWidthUnit">
<option value="vw" selected>vw (responsive)</option>
<option value="px">px (exact)</option>
</select>
</div>
<label>Title</label>
<input type="text" id="itemTitle" placeholder="Short label" />
<label>Media URL (https://…)</label>
<input type="url" id="itemUrl" placeholder="Paste direct image/video URL" />
<label>Or choose file
<input type="file" id="itemFile" accept="image/*,video/*" />
</label>
<div class="row" style="margin-top:8px">
<button id="addItem" class="btn-accent">Add</button>
<button id="addAndPlace">Add & Place</button>
</div>
<div class="hint">After “Add & Place,” click the stage where you want the hotspot.</div>
</fieldset>
<h2>Items <span class="badge" id="count">0</span></h2>
<div id="items"></div>
<div class="hr"></div>
<div class="row">
<button id="clearAll" class="btn-danger">Clear All</button>
<button id="downloadJson">Download JSON</button>
</div>
<div id="help" class="hint" style="margin-top:8px">
Minimize the panel with the chevron. Click a translucent dot to open its popup.
</div>
<div id="resizer"></div>
</aside>
<button id="toggle" title="Hide/Show panel"></button>
<main id="stage" aria-label="Stage">
<img id="bgImg" class="bgmedia" alt="" style="display:none" />
<!-- attrs for autoplay/loop/muted already present -->
<video id="bgVid" class="bgmedia" autoplay loop muted playsinline style="display:none"></video>
<div id="overlay" title="Click to place when in placement mode"></div>
<div id="stageMsg" style="display:none"></div>
</main>
<button id="saveState" class="btn-ghost">Saved</button>
</div>
<!-- HLS support for .m3u8 streams -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
(() => {
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
const stage = $('#stage'), overlay = $('#overlay');
const bgImg = $('#bgImg'), bgVid = $('#bgVid');
const panel = $('#panel'), toggle = $('#toggle');
const stageMsg = $('#stageMsg');
const saveBadge = $('#saveState');
let hlsBg = null; // background HLS instance
const hlsMap = new Map(); // per-popup HLS instances
const state = {
background: null, // {type:'image'|'video', src:'data/http(s) URL', fit:'cover'|'contain'}
items: [], // {id,type,title,src,x,y,width,widthUnit,open:false}
placingId: null
};
const uid = () => Math.random().toString(36).slice(2,9);
const markDirty = (() => {
let t;
return function(){ saveBadge.textContent = 'Saving…'; saveBadge.style.background = '#1b2b54';
window.clearTimeout(t); t = setTimeout(saveToServer, 350);
};
})();
const updateToggleIcon = () => { toggle.textContent = panel.classList.contains('min') ? '▶' : '◀'; };
toggle.addEventListener('click', () => { panel.classList.toggle('min'); updateToggleIcon(); });
panel.classList.add('min'); updateToggleIcon();
function escapeHtml(s){return (s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
async function fileToDataURL(file){
if (!file) return '';
const maxInlineMB = 250;
if (file.size > maxInlineMB * 1024 * 1024) { alert(`File is larger than ${maxInlineMB} MB. Use a URL instead.`); return ''; }
return new Promise((res,rej)=>{
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = () => rej(new Error('Failed to read file'));
r.readAsDataURL(file);
});
}
function guessVideoMime(src){
try{
if (src.startsWith('data:')) {
const m = src.slice(5, src.indexOf(';'));
if (m.startsWith('video/')) return m;
}
const u = new URL(src, window.location.href).pathname.toLowerCase();
if (u.endsWith('.mp4')) return 'video/mp4';
if (u.endsWith('.webm')) return 'video/webm';
if (u.endsWith('.ogv') || u.endsWith('.ogg')) return 'video/ogg';
if (u.endsWith('.mov')) return 'video/quicktime';
if (u.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl';
}catch(e){}
return '';
}
function showStageMessage(msg, ms=2500){
stageMsg.textContent = msg; stageMsg.style.display='block';
setTimeout(()=>stageMsg.style.display='none', ms);
}
// ---------- Background ----------
$('#applyBg').addEventListener('click', async () => {
const type = $('#bgType').value;
const fit = $('#bgFit').value;
let src = $('#bgUrl').value.trim();
const file = $('#bgFile').files[0];
if (file) {
src = await fileToDataURL(file);
if (!src) return;
} else if (!src) {
alert('Provide a media URL or choose a file.'); return;
}
state.background = { type, src, fit };
renderBackground(); markDirty();
$('#bgFile').value=''; $('#bgUrl').value='';
});
$('#clearBg').addEventListener('click', () => {
state.background = null;
renderBackground(); markDirty();
});
function cleanupHlsBg(){
if (hlsBg){ try{ hlsBg.destroy(); }catch(e){} hlsBg = null; }
}
function renderBackground(){
cleanupHlsBg();
bgImg.style.display='none'; bgVid.style.display='none';
// clear previous <source> tags
bgVid.removeAttribute('src');
while (bgVid.firstChild) bgVid.removeChild(bgVid.firstChild);
if (!state.background) return;
const {type, src, fit} = state.background;
bgImg.style.objectFit = fit; bgVid.style.objectFit = fit;
if (type === 'video'){
bgVid.autoplay = true; bgVid.loop = true; bgVid.muted = true; bgVid.playsInline = true;
bgVid.crossOrigin = 'anonymous';
if (window.Hls && window.Hls.isSupported() && /\.m3u8($|\?)/i.test(src)) {
hlsBg = new Hls();
hlsBg.loadSource(src);
hlsBg.attachMedia(bgVid);
hlsBg.on(Hls.Events.MANIFEST_PARSED, () => { const p = bgVid.play(); if (p && p.catch) p.catch(()=>{}); });
} else {
const s = document.createElement('source');
s.src = src; const mime=guessVideoMime(src); if (mime) s.type=mime;
bgVid.appendChild(s);
bgVid.load();
const p = bgVid.play(); if (p && p.catch) p.catch(()=>{});
}
bgVid.style.display='block';
bgVid.addEventListener('error', () => {
showStageMessage('Background video failed to load. Try MP4 (H.264/AAC), WEBM, or HLS .m3u8.');
}, {once:true});
} else {
bgImg.src = src; bgImg.style.display='block';
}
}
// ---------- Incremental DOM helpers for items ----------
const getPinEl = (id) => overlay.querySelector(`.pin[data-id="${id}"]`);
const getPopupEl = (id) => overlay.querySelector(`.popup[data-id="${id}"]`);
function createPin(it){
const pin = document.createElement('div');
pin.className = 'pin';
pin.dataset.id = it.id;
pin.setAttribute('role', 'button');
pin.setAttribute('tabindex', '0');
pin.style.left = it.x + '%';
pin.style.top = it.y + '%';
pin.title = it.title || it.type;
pin.addEventListener('click', (e) => { e.stopPropagation(); togglePopup(it.id); });
pin.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePopup(it.id); } });
overlay.appendChild(pin);
}
function createPopup(it){
if (getPopupEl(it.id)) return; // already open
const pop = document.createElement('div');
pop.className = 'popup';
pop.dataset.id = it.id;
pop.style.left = it.x + '%';
pop.style.top = it.y + '%';
pop.style.width = (it.widthUnit === 'px' ? it.width + 'px' : it.width + 'vw');
const wrap = document.createElement('div');
wrap.className = 'media';
if (it.type === 'video') {
wrap.setAttribute('data-kind','video'); // reserve space via CSS
const v = document.createElement('video');
v.autoplay = true; v.loop = true; v.muted = true; v.playsInline = true;
v.crossOrigin = 'anonymous';
v.style.width = '100%'; v.style.height = 'auto';
let hlsInst = null;
if (window.Hls && window.Hls.isSupported() && /\.m3u8($|\?)/i.test(it.src)) {
hlsInst = new Hls();
hlsInst.loadSource(it.src);
hlsInst.attachMedia(v);
hlsInst.on(Hls.Events.MANIFEST_PARSED, () => { v.play().catch(()=>{}); });
hlsMap.set(it.id, hlsInst);
} else {
const srcEl = document.createElement('source');
srcEl.src = it.src;
const mime = guessVideoMime(it.src); if (mime) srcEl.type = mime;
v.appendChild(srcEl);
v.load();
v.play().catch(()=>{});
}
v.addEventListener('loadedmetadata', () => {
if (v.videoWidth && v.videoHeight) wrap.style.aspectRatio = (v.videoWidth / v.videoHeight).toString();
});
// Close THIS popup on click (don’t touch others)
const closeSelf = (e) => { e.stopPropagation(); removePopup(it.id); it.open = false; };
wrap.addEventListener('click', closeSelf);
v.addEventListener('click', closeSelf);
v.addEventListener('error', () => {
const note = document.createElement('div');
note.style.padding = '10px 12px';
note.style.color = '#ffd2d2';
note.textContent = 'Video failed to load. Use MP4 (H.264/AAC), WEBM, or HLS (.m3u8).';
wrap.innerHTML = ''; wrap.appendChild(note);
}, {once:true});
wrap.appendChild(v);
} else {
const img = document.createElement('img');
img.src = it.src; img.alt = it.title || '';
// Close THIS popup on click
wrap.addEventListener('click', (e) => { e.stopPropagation(); removePopup(it.id); it.open = false; });
wrap.appendChild(img);
}
pop.appendChild(wrap);
overlay.appendChild(pop);
requestAnimationFrame(() => adjustPopupPosition(pop));
}
function removePopup(id){
const el = getPopupEl(id);
if (!el) return;
// stop video & destroy HLS if present
const v = el.querySelector('video');
if (v){ try{ v.pause(); }catch{} }
const hlsInst = hlsMap.get(id);
if (hlsInst){ try{ hlsInst.destroy(); }catch{} hlsMap.delete(id); }
el.remove();
}
// ---------- Items ----------
$('#addItem').addEventListener('click', () => addOrPlace(false));
$('#addAndPlace').addEventListener('click', () => addOrPlace(true));
async function addOrPlace(shouldPlace){
const type = $('#itemType').value;
const title = $('#itemTitle').value.trim();
const url = $('#itemUrl').value.trim();
const file = $('#itemFile').files[0];
const width = Number($('#itemWidth').value);
const widthUnit = $('#itemWidthUnit').value;
let src = url;
if (file){ src = await fileToDataURL(file); if (!src) return; }
if (!src){ alert('Provide a media URL or choose a file.'); return; }
const it = { id: uid(), type, title, src, x:50, y:50, width, widthUnit, open:false };
state.items.push(it);
renderItems(); markDirty();
$('#itemFile').value=''; $('#itemUrl').value=''; $('#itemTitle').value='';
if (shouldPlace) startPlacing(it.id);
}
function removeItem(id){
const i = state.items.findIndex(x=>x.id===id);
if (i>=0){
const it = state.items[i];
removePopup(id);
const pin = getPinEl(id); if (pin) pin.remove();
state.items.splice(i,1);
renderItemsList(); // refresh list & count only
markDirty();
}
}
function startPlacing(id){
state.placingId = id;
stageMsg.textContent = 'Placement mode: click anywhere on the stage';
stageMsg.style.display='block';
}
function stopPlacing(){ state.placingId=null; stageMsg.style.display='none'; }
overlay.addEventListener('click', (ev) => {
if (!state.placingId) return;
const rect = overlay.getBoundingClientRect();
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
const yPct = ((ev.clientY - rect.top) / rect.height) * 100;
const it = state.items.find(x=>x.id===state.placingId);
if (it){
it.x = Math.max(0, Math.min(100, xPct));
it.y = Math.max(0, Math.min(100, yPct));
// update only that pin/popup
const pinEl = getPinEl(it.id);
if (pinEl){ pinEl.style.left = it.x + '%'; pinEl.style.top = it.y + '%'; }
const popEl = getPopupEl(it.id);
if (popEl){
popEl.style.left = it.x + '%';
popEl.style.top = it.y + '%';
requestAnimationFrame(()=>adjustPopupPosition(popEl));
}
markDirty();
}
stopPlacing();
});
// Keep multiple popups; toggling pin affects only that item
function togglePopup(id){
const it = state.items.find(x=>x.id===id);
if (!it) return;
if (it.open){ removePopup(id); it.open = false; }
else { it.open = true; createPopup(it); }
// no global re-render here, so other videos keep playing
}
function adjustPopupPosition(el){
const rect = el.getBoundingClientRect();
const margin = 8; let dx=0, dy=0;
if (rect.left < margin) dx = margin - rect.left;
if (rect.right > innerWidth - margin) dx = (innerWidth - margin) - rect.right;
if (rect.top < margin) dy = margin - rect.top;
if (rect.bottom > innerHeight - margin) dy = (innerHeight - margin) - rect.bottom;
if (dx || dy) el.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
}
function renderItems(){
// Initial (or full) rebuild of pins/popups + list
overlay.innerHTML = '';
for (const it of state.items){
createPin(it);
if (it.open) createPopup(it);
}
renderItemsList();
}
function renderItemsList(){
$('#count').textContent = String(state.items.length);
const list = $('#items'); list.innerHTML='';
for (const it of state.items){
const row = document.createElement('div'); row.className='item';
const kind = document.createElement('div'); kind.className='badge'; kind.textContent = it.type.toUpperCase();
const info = document.createElement('div');
const updateInfoTiny = () => info.querySelector('.tiny').textContent =
`${it.width}${it.widthUnit} • (${it.x.toFixed(1)}%, ${it.y.toFixed(1)}%)`;
info.innerHTML = `<div class="name">${escapeHtml(it.title||'')}</div>
<div class="tiny"></div>`;
const actions = document.createElement('div'); actions.className='actions';
updateInfoTiny();
const btnPlace = document.createElement('button'); btnPlace.textContent='Place';
btnPlace.addEventListener('click', ()=> startPlacing(it.id));
const btnPreview = document.createElement('button'); btnPreview.textContent = it.open ? 'Hide' : 'Preview';
btnPreview.addEventListener('click', ()=> { togglePopup(it.id); btnPreview.textContent = it.open ? 'Hide' : 'Preview'; });
const widthIn = document.createElement('input');
widthIn.type = 'number'; widthIn.min = '10'; widthIn.max = '2000';
widthIn.value = String(it.width ?? 25);
const unitSel = document.createElement('select');
unitSel.innerHTML = '<option value="vw">vw</option><option value="px">px</option>';
unitSel.value = it.widthUnit || 'vw';
widthIn.addEventListener('change', () => {
it.width = Number(widthIn.value) || (it.widthUnit === 'px' ? 320 : 25);
const popEl = getPopupEl(it.id);
if (popEl){
popEl.style.width = (it.widthUnit === 'px' ? it.width + 'px' : it.width + 'vw');
requestAnimationFrame(()=>adjustPopupPosition(popEl));
}
updateInfoTiny(); markDirty();
});
unitSel.addEventListener('change', () => {
it.widthUnit = unitSel.value;
const popEl = getPopupEl(it.id);
if (popEl){
popEl.style.width = (it.widthUnit === 'px' ? it.width + 'px' : it.width + 'vw');
requestAnimationFrame(()=>adjustPopupPosition(popEl));
}
updateInfoTiny(); markDirty();
});
const btnDel = document.createElement('button'); btnDel.textContent='Delete'; btnDel.className='btn-danger';
btnDel.addEventListener('click', ()=> removeItem(it.id));
actions.append(btnPlace, btnPreview, widthIn, unitSel, btnDel);
row.append(kind, info, actions); list.appendChild(row);
}
}
// Save / Load (ephemeral)
async function saveToServer(){
try{
const payload = {
background: state.background ? { ...state.background } : null,
items: state.items.map(({open, ...rest}) => rest)
};
const res = await fetch('/api/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)});
if (!res.ok) throw new Error('Save failed');
saveBadge.textContent = 'Saved'; saveBadge.style.background = '#132042';
}catch(e){
saveBadge.textContent = 'Save error'; saveBadge.style.background = '#5a2a39';
}
}
async function loadFromServer(){
try{
const res = await fetch('/api/config');
if (!res.ok) throw new Error('Load failed');
const data = await res.json();
state.background = data.background || null;
state.items = Array.isArray(data.items)
? data.items.map(x => ({
open:false,
...x,
width: (x.width != null) ? x.width : (x.widthPct != null ? x.widthPct : 25),
widthUnit: x.widthUnit || (x.widthPct != null ? 'vw' : 'vw')
}))
: [];
renderBackground(); renderItems();
saveBadge.textContent = 'Loaded'; setTimeout(()=> saveBadge.textContent='Saved', 700);
}catch(e){
saveBadge.textContent = 'No saved state';
}
}
// Clear / Download
$('#clearAll').addEventListener('click', async () => {
if (!confirm('Clear background and all items?')) return;
// close all popups, clean HLS
state.items.forEach(it => removePopup(it.id));
state.background = null; state.items = [];
overlay.innerHTML = '';
renderItemsList();
renderBackground();
markDirty();
});
$('#downloadJson').addEventListener('click', () => {
const data = JSON.stringify({
background: state.background || null,
items: state.items.map(({open, ...rest})=>rest)
}, null, 2);
const blob = new Blob([data], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'overlay-config.json'; a.click();
URL.revokeObjectURL(url);
});
// Outside click: do nothing (don’t close popups)
window.addEventListener('resize', ()=>{
$$('.popup').forEach(el => {
el.style.transform='translate(-50%, -50%)';
requestAnimationFrame(()=>adjustPopupPosition(el));
});
});
stage.addEventListener('click', e => {
if (state.placingId) return; // placement handled on overlay
// intentionally no-op so outside clicks don't close anything
});
// --- Sidebar resizing ---
const resizer = document.getElementById('resizer');
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
document.body.style.cursor = 'ew-resize';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const newWidth = Math.max(200, Math.min(e.clientX, 1000)); // min 200px, max 1000px
panel.style.width = newWidth + 'px';
document.documentElement.style.setProperty('--panelW', newWidth + 'px');
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = '';
}
});
loadFromServer();
})();
</script>
</body>
</html>