Spaces:
Sleeping
Sleeping
| <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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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> | |