ntdservices commited on
Commit
30314cf
·
verified ·
1 Parent(s): a40d4b6

Update static/index.html

Browse files
Files changed (1) hide show
  1. static/index.html +160 -129
static/index.html CHANGED
@@ -31,23 +31,23 @@
31
  transition:transform .28s ease; box-shadow:var(--shadow); z-index:5;
32
  transform:translateX(0);
33
  }
34
- #panel.min{
35
- /* Fully off-canvas; no sliver */
36
- transform: translateX(-100%);
37
- /* If you ever see a 0.5px hairline from subpixel rounding, use:
38
- transform: translateX(calc(-100% - 1px)); */
39
- overflow: hidden;
40
- border-right: none;
41
- background: transparent; /* kill gradient tint */
42
- box-shadow: none; /* kill edge glow */
43
- pointer-events: none; /* panel won’t catch clicks while hidden */
44
- }
45
  #panel h2{margin:.3rem 0 1rem; font-size:1.05rem; color:#cfe0ff}
46
  fieldset{border:1px solid #2a395f; border-radius:12px; padding:12px; margin:10px 0}
47
  legend{font-size:.9rem; color:#bcd0ff; padding:0 6px}
48
  label{display:block; font-size:.85rem; color:var(--muted); margin:8px 0 4px}
49
- input[type="text"], input[type="url"], select{width:100%; padding:10px 12px; border-radius:10px; border:1px solid #26365a; background:#0c1430; color:var(--text)}
50
- input[type="range"]{width:100%}
51
  .row{display:flex; gap:8px}
52
  .row>*{flex:1}
53
  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}
@@ -71,38 +71,34 @@
71
 
72
  #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}
73
  #panel.min ~ #stage #saveState, #panel.min ~ #saveState { left:28px; }
 
 
74
 
75
  /* Pins (bigger + more transparent) */
76
- /* Bigger, lighter pin with a guaranteed full hit area */
77
- .pin{
78
- position:absolute;
79
- left:0; top:0;
80
- width:34px; height:34px; /* bigger */
81
- border-radius:50%;
82
- border:2px solid rgba(255,255,255,.45);
83
- background:rgba(255,255,255,.12); /* more transparent */
84
- box-shadow:0 2px 10px rgba(0,0,0,.45);
85
- transform:translate(-50%, -50%);
86
- z-index:3;
87
- cursor:pointer;
88
-
89
- /* kill any UA quirks if it’s a <button> */
90
- display:block;
91
- padding:0;
92
- line-height:0;
93
- -webkit-appearance:none;
94
- appearance:none;
95
- }
96
-
97
- /* Expand the clickable area beyond the visual circle (keeps look the same) */
98
- .pin::before{
99
- content:"";
100
- position:absolute;
101
- inset:-10px; /* +10px all around = easier tapping */
102
- border-radius:50%;
103
- /* no background needed; clicks will still land on .pin */
104
- }
105
-
106
  .pin:hover{background:rgba(255,255,255,.26)}
107
 
108
  /* Popups: centered exactly on the pin midpoint */
@@ -110,15 +106,8 @@
110
  position:absolute; z-index:4;
111
  transform: translate(-50%, -50%); /* center on pin */
112
  background:rgba(13,20,40,.92); border:1px solid #2a395f; border-radius:16px; box-shadow:var(--shadow);
113
- padding:0; min-width:200px; max-width:min(40vw, 520px); backdrop-filter: blur(6px);
114
  }
115
- /* Make header overlay so media stays geometrically centered */
116
- .popup header{
117
- position:absolute; top:6px; right:6px; left:auto;
118
- display:flex; align-items:center; gap:8px; margin:0; padding:0;
119
- }
120
- .popup h4{margin:0; font-size:.95rem; color:#d9e6ff; background:rgba(0,0,0,.35); padding:6px 10px; border-radius:10px}
121
- .x{background:rgba(0,0,0,.35); border:1px solid #2a395f; color:#cfe0ff; font-size:16px; padding:6px 8px; border-radius:10px}
122
 
123
  .media{display:block; width:100%; height:auto; border-radius:16px; overflow:hidden; background:#000}
124
  .media video, .media img{display:block; width:100%; height:auto}
@@ -128,10 +117,6 @@
128
  .item .name{font-size:.9rem}
129
  .item .tiny{font-size:.75rem; color:#9fb0cf}
130
  .item .actions{display:flex; gap:6px}
131
-
132
- /* Hide the "Saved" badge when the sidebar is minimized */
133
- #panel.min ~ #saveState { display: none; }
134
-
135
  </style>
136
  </head>
137
  <body>
@@ -169,11 +154,19 @@
169
  <fieldset>
170
  <div class="row">
171
  <label>Type
172
- <select id="itemType"><option value="image">Image</option><option value="video">Video</option></select>
173
- </label>
174
- <label>Size (% of viewport width)
175
- <input type="range" id="itemSize" min="10" max="40" value="25" />
176
  </label>
 
 
 
 
 
 
 
 
177
  </div>
178
  <label>Title</label>
179
  <input type="text" id="itemTitle" placeholder="Short label" />
@@ -214,6 +207,9 @@
214
  <button id="saveState" class="btn-ghost">Saved</button>
215
  </div>
216
 
 
 
 
217
  <script>
218
  (() => {
219
  const $ = s => document.querySelector(s);
@@ -227,7 +223,7 @@
227
 
228
  const state = {
229
  background: null, // {type:'image'|'video', src:'data/http(s) URL', fit:'cover'|'contain'}
230
- items: [], // {id,type,title,src,x,y,widthPct,open:false}
231
  placingId: null
232
  };
233
  const uid = () => Math.random().toString(36).slice(2,9);
@@ -238,7 +234,6 @@
238
  };
239
  })();
240
 
241
- // Panel toggle
242
  // Panel toggle
243
  const updateToggleIcon = () => {
244
  toggle.textContent = panel.classList.contains('min') ? '▶' : '◀';
@@ -247,12 +242,10 @@
247
  panel.classList.toggle('min');
248
  updateToggleIcon();
249
  });
250
-
251
  // start minimized on load
252
  panel.classList.add('min');
253
  updateToggleIcon();
254
 
255
-
256
  // Helpers
257
  function escapeHtml(s){return (s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
258
 
@@ -268,6 +261,22 @@
268
  });
269
  }
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  // Background
272
  $('#applyBg').addEventListener('click', async () => {
273
  const type = $('#bgType').value;
@@ -311,13 +320,14 @@
311
  const title = $('#itemTitle').value.trim();
312
  const url = $('#itemUrl').value.trim();
313
  const file = $('#itemFile').files[0];
314
- const widthPct = Number($('#itemSize').value);
 
315
  let src = url;
316
 
317
  if (file){ src = await fileToDataURL(file); if (!src) return; }
318
  if (!src){ alert('Provide a media URL or choose a file.'); return; }
319
 
320
- const it = { id: uid(), type, title, src, x:50, y:50, widthPct, open:false };
321
  state.items.push(it);
322
  renderItems(); markDirty();
323
 
@@ -351,10 +361,10 @@
351
  stopPlacing();
352
  });
353
 
 
354
  function togglePopup(id){
355
  const it = state.items.find(x=>x.id===id);
356
  if (!it) return;
357
- state.items.forEach(x=>{ if (x.id!==id) x.open=false; });
358
  it.open = !it.open;
359
  renderItems(); // visual only, don't mark dirty for open state
360
  }
@@ -373,66 +383,66 @@
373
  function renderItems(){
374
  $('#count').textContent = String(state.items.length);
375
  overlay.innerHTML = '';
 
376
  for (const it of state.items){
 
377
  const pin = document.createElement('div');
378
- pin.className = 'pin';
379
- pin.setAttribute('role', 'button');
380
- pin.setAttribute('tabindex', '0');
381
-
382
- pin.style.left = it.x + '%';
383
- pin.style.top = it.y + '%';
384
- pin.title = it.title || it.type;
385
-
386
- pin.addEventListener('click', (e) => {
387
- e.stopPropagation();
388
- togglePopup(it.id);
389
- });
390
- pin.addEventListener('keydown', (e) => {
391
- if (e.key === 'Enter' || e.key === ' ') {
392
- e.preventDefault();
393
- togglePopup(it.id);
394
- }
395
- });
396
-
397
- overlay.appendChild(pin);
398
-
399
-
400
- if (it.open){
401
- const pop = document.createElement('div');
402
- pop.className = 'popup';
403
- pop.style.left = it.x + '%';
404
- pop.style.top = it.y + '%';
405
- pop.style.width = it.widthPct + 'vw';
406
-
407
- // Media wrapper — clicking anywhere on the small image/video closes it
408
- const wrap = document.createElement('div');
409
- wrap.className = 'media';
410
- wrap.addEventListener('click', (e) => {
411
- e.stopPropagation();
412
- it.open = false;
413
- renderItems();
414
- });
415
-
416
- if (it.type === 'video') {
417
- const v = document.createElement('video');
418
- v.src = it.src;
419
- v.playsInline = true;
420
- v.muted = true; // no controls; click closes
421
- v.autoplay = false;
422
- v.loop = false;
423
- wrap.appendChild(v);
424
- } else {
425
- const img = document.createElement('img');
426
- img.src = it.src;
427
- img.alt = it.title || '';
428
- wrap.appendChild(img);
429
- }
430
-
431
- pop.appendChild(wrap);
432
- overlay.appendChild(pop);
433
- requestAnimationFrame(() => adjustPopupPosition(pop));
434
- }
435
-
436
  }
437
 
438
  // items list
@@ -442,7 +452,7 @@ if (it.open){
442
  const kind = document.createElement('div'); kind.className='badge'; kind.textContent = it.type.toUpperCase();
443
  const info = document.createElement('div');
444
  info.innerHTML = `<div class="name">${escapeHtml(it.title||'')}</div>
445
- <div class="tiny">${it.widthPct}% • (${it.x.toFixed(1)}%, ${it.y.toFixed(1)}%)</div>`;
446
  const actions = document.createElement('div'); actions.className='actions';
447
 
448
  const btnPlace = document.createElement('button'); btnPlace.textContent='Place';
@@ -451,13 +461,27 @@ if (it.open){
451
  const btnPreview = document.createElement('button'); btnPreview.textContent = it.open ? 'Hide' : 'Preview';
452
  btnPreview.addEventListener('click', ()=> togglePopup(it.id));
453
 
454
- const sizeIn = document.createElement('input'); sizeIn.type='range'; sizeIn.min='10'; sizeIn.max='40'; sizeIn.value=String(it.widthPct);
455
- sizeIn.addEventListener('input', ()=>{ it.widthPct=Number(sizeIn.value); renderItems(); markDirty(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
 
457
  const btnDel = document.createElement('button'); btnDel.textContent='Delete'; btnDel.className='btn-danger';
458
  btnDel.addEventListener('click', ()=> removeItem(it.id));
459
 
460
- actions.append(btnPlace, btnPreview, sizeIn, btnDel);
461
  row.append(kind, info, actions); list.appendChild(row);
462
  }
463
  }
@@ -483,7 +507,14 @@ if (it.open){
483
  if (!res.ok) throw new Error('Load failed');
484
  const data = await res.json();
485
  state.background = data.background || null;
486
- state.items = Array.isArray(data.items) ? data.items.map(x=>({open:false, ...x})) : [];
 
 
 
 
 
 
 
487
  renderBackground(); renderItems();
488
  saveBadge.textContent = 'Loaded'; setTimeout(()=> saveBadge.textContent='Saved', 700);
489
  }catch(e){
 
31
  transition:transform .28s ease; box-shadow:var(--shadow); z-index:5;
32
  transform:translateX(0);
33
  }
34
+ #panel.min{
35
+ /* Fully off-canvas; no sliver */
36
+ transform: translateX(-100%);
37
+ /* If you ever see a 0.5px hairline from subpixel rounding, use:
38
+ transform: translateX(calc(-100% - 1px)); */
39
+ overflow: hidden;
40
+ border-right: none;
41
+ background: transparent; /* kill gradient tint */
42
+ box-shadow: none; /* kill edge glow */
43
+ pointer-events: none; /* panel won’t catch clicks while hidden */
44
+ }
45
  #panel h2{margin:.3rem 0 1rem; font-size:1.05rem; color:#cfe0ff}
46
  fieldset{border:1px solid #2a395f; border-radius:12px; padding:12px; margin:10px 0}
47
  legend{font-size:.9rem; color:#bcd0ff; padding:0 6px}
48
  label{display:block; font-size:.85rem; color:var(--muted); margin:8px 0 4px}
49
+ input[type="text"], input[type="url"], select{width:100%; padding:10px 12px; border-radius:10px; border:1px solid #26365a; background:#0c1430; color:#var(--text)}
50
+ input[type="number"]{width:100%; padding:10px 12px; border-radius:10px; border:1px solid #26365a; background:#0c1430; color:#e6edf7}
51
  .row{display:flex; gap:8px}
52
  .row>*{flex:1}
53
  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}
 
71
 
72
  #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}
73
  #panel.min ~ #stage #saveState, #panel.min ~ #saveState { left:28px; }
74
+ /* Hide the "Saved" badge when the sidebar is minimized */
75
+ #panel.min ~ #saveState { display: none; }
76
 
77
  /* Pins (bigger + more transparent) */
78
+ .pin{
79
+ position:absolute;
80
+ left:0; top:0;
81
+ width:34px; height:34px; /* bigger */
82
+ border-radius:50%;
83
+ border:2px solid rgba(255,255,255,.45);
84
+ background:rgba(255,255,255,.12); /* more transparent */
85
+ box-shadow:0 2px 10px rgba(0,0,0,.45);
86
+ transform:translate(-50%, -50%);
87
+ z-index:3;
88
+ cursor:pointer;
89
+ display:block;
90
+ padding:0;
91
+ line-height:0;
92
+ -webkit-appearance:none;
93
+ appearance:none;
94
+ }
95
+ /* Expand the clickable area beyond the visual circle (keeps look the same) */
96
+ .pin::before{
97
+ content:"";
98
+ position:absolute;
99
+ inset:-10px; /* +10px all around = easier tapping */
100
+ border-radius:50%;
101
+ }
 
 
 
 
 
 
102
  .pin:hover{background:rgba(255,255,255,.26)}
103
 
104
  /* Popups: centered exactly on the pin midpoint */
 
106
  position:absolute; z-index:4;
107
  transform: translate(-50%, -50%); /* center on pin */
108
  background:rgba(13,20,40,.92); border:1px solid #2a395f; border-radius:16px; box-shadow:var(--shadow);
109
+ padding:0; min-width:120px; max-width:none; backdrop-filter: blur(6px);
110
  }
 
 
 
 
 
 
 
111
 
112
  .media{display:block; width:100%; height:auto; border-radius:16px; overflow:hidden; background:#000}
113
  .media video, .media img{display:block; width:100%; height:auto}
 
117
  .item .name{font-size:.9rem}
118
  .item .tiny{font-size:.75rem; color:#9fb0cf}
119
  .item .actions{display:flex; gap:6px}
 
 
 
 
120
  </style>
121
  </head>
122
  <body>
 
154
  <fieldset>
155
  <div class="row">
156
  <label>Type
157
+ <select id="itemType">
158
+ <option value="image">Image</option>
159
+ <option value="video">Video</option>
160
+ </select>
161
  </label>
162
+ <label>Popup width</label>
163
+ </div>
164
+ <div class="row">
165
+ <input type="number" id="itemWidth" min="10" max="2000" value="25" />
166
+ <select id="itemWidthUnit">
167
+ <option value="vw" selected>vw (responsive)</option>
168
+ <option value="px">px (exact)</option>
169
+ </select>
170
  </div>
171
  <label>Title</label>
172
  <input type="text" id="itemTitle" placeholder="Short label" />
 
207
  <button id="saveState" class="btn-ghost">Saved</button>
208
  </div>
209
 
210
+ <!-- HLS support for .m3u8 streams -->
211
+ <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
212
+
213
  <script>
214
  (() => {
215
  const $ = s => document.querySelector(s);
 
223
 
224
  const state = {
225
  background: null, // {type:'image'|'video', src:'data/http(s) URL', fit:'cover'|'contain'}
226
+ items: [], // {id,type,title,src,x,y,width,widthUnit,open:false}
227
  placingId: null
228
  };
229
  const uid = () => Math.random().toString(36).slice(2,9);
 
234
  };
235
  })();
236
 
 
237
  // Panel toggle
238
  const updateToggleIcon = () => {
239
  toggle.textContent = panel.classList.contains('min') ? '▶' : '◀';
 
242
  panel.classList.toggle('min');
243
  updateToggleIcon();
244
  });
 
245
  // start minimized on load
246
  panel.classList.add('min');
247
  updateToggleIcon();
248
 
 
249
  // Helpers
250
  function escapeHtml(s){return (s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
251
 
 
261
  });
262
  }
263
 
264
+ // Infer a video MIME type from URL (rough guess)
265
+ function guessVideoMime(src){
266
+ try{
267
+ if (src.startsWith('data:')) {
268
+ const m = src.slice(5, src.indexOf(';'));
269
+ if (m.startsWith('video/')) return m;
270
+ }
271
+ const u = new URL(src, window.location.href).pathname.toLowerCase();
272
+ if (u.endsWith('.mp4')) return 'video/mp4';
273
+ if (u.endsWith('.webm')) return 'video/webm';
274
+ if (u.endsWith('.ogv') || u.endsWith('.ogg')) return 'video/ogg';
275
+ if (u.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl';
276
+ }catch(e){}
277
+ return '';
278
+ }
279
+
280
  // Background
281
  $('#applyBg').addEventListener('click', async () => {
282
  const type = $('#bgType').value;
 
320
  const title = $('#itemTitle').value.trim();
321
  const url = $('#itemUrl').value.trim();
322
  const file = $('#itemFile').files[0];
323
+ const width = Number($('#itemWidth').value);
324
+ const widthUnit = $('#itemWidthUnit').value; // 'vw' | 'px'
325
  let src = url;
326
 
327
  if (file){ src = await fileToDataURL(file); if (!src) return; }
328
  if (!src){ alert('Provide a media URL or choose a file.'); return; }
329
 
330
+ const it = { id: uid(), type, title, src, x:50, y:50, width, widthUnit, open:false };
331
  state.items.push(it);
332
  renderItems(); markDirty();
333
 
 
361
  stopPlacing();
362
  });
363
 
364
+ // Allow multiple popups to stay open; clicking pin toggles only that one
365
  function togglePopup(id){
366
  const it = state.items.find(x=>x.id===id);
367
  if (!it) return;
 
368
  it.open = !it.open;
369
  renderItems(); // visual only, don't mark dirty for open state
370
  }
 
383
  function renderItems(){
384
  $('#count').textContent = String(state.items.length);
385
  overlay.innerHTML = '';
386
+
387
  for (const it of state.items){
388
+ // PIN
389
  const pin = document.createElement('div');
390
+ pin.className = 'pin';
391
+ pin.setAttribute('role', 'button');
392
+ pin.setAttribute('tabindex', '0');
393
+ pin.style.left = it.x + '%';
394
+ pin.style.top = it.y + '%';
395
+ pin.title = it.title || it.type;
396
+ pin.addEventListener('click', (e) => { e.stopPropagation(); togglePopup(it.id); });
397
+ pin.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePopup(it.id); } });
398
+ overlay.appendChild(pin);
399
+
400
+ // POPUP
401
+ if (it.open){
402
+ const pop = document.createElement('div');
403
+ pop.className = 'popup';
404
+ pop.style.left = it.x + '%';
405
+ pop.style.top = it.y + '%';
406
+ pop.style.width = (it.widthUnit === 'px' ? it.width + 'px' : it.width + 'vw');
407
+
408
+ const wrap = document.createElement('div');
409
+ wrap.className = 'media';
410
+
411
+ if (it.type === 'video') {
412
+ const v = document.createElement('video');
413
+ v.controls = true;
414
+ v.preload = 'metadata';
415
+ v.playsInline = true;
416
+ v.style.width = '100%';
417
+ v.style.height = 'auto';
418
+ v.style.aspectRatio = '16/9'; // placeholder to avoid "thin line" before metadata
419
+ // HLS support
420
+ if (window.Hls && window.Hls.isSupported() && /\.m3u8($|\?)/i.test(it.src)) {
421
+ const hls = new Hls();
422
+ hls.loadSource(it.src);
423
+ hls.attachMedia(v);
424
+ } else {
425
+ const srcEl = document.createElement('source');
426
+ srcEl.src = it.src;
427
+ const mime = guessVideoMime(it.src);
428
+ if (mime) srcEl.type = mime;
429
+ v.appendChild(srcEl);
430
+ }
431
+ v.addEventListener('loadedmetadata', () => {
432
+ if (v.videoWidth && v.videoHeight) v.style.aspectRatio = (v.videoWidth / v.videoHeight).toString();
433
+ });
434
+ wrap.appendChild(v);
435
+ } else {
436
+ const img = document.createElement('img');
437
+ img.src = it.src;
438
+ img.alt = it.title || '';
439
+ wrap.appendChild(img);
440
+ }
441
+
442
+ pop.appendChild(wrap);
443
+ overlay.appendChild(pop);
444
+ requestAnimationFrame(() => adjustPopupPosition(pop));
445
+ }
 
 
446
  }
447
 
448
  // items list
 
452
  const kind = document.createElement('div'); kind.className='badge'; kind.textContent = it.type.toUpperCase();
453
  const info = document.createElement('div');
454
  info.innerHTML = `<div class="name">${escapeHtml(it.title||'')}</div>
455
+ <div class="tiny">${it.width}${it.widthUnit} • (${it.x.toFixed(1)}%, ${it.y.toFixed(1)}%)</div>`;
456
  const actions = document.createElement('div'); actions.className='actions';
457
 
458
  const btnPlace = document.createElement('button'); btnPlace.textContent='Place';
 
461
  const btnPreview = document.createElement('button'); btnPreview.textContent = it.open ? 'Hide' : 'Preview';
462
  btnPreview.addEventListener('click', ()=> togglePopup(it.id));
463
 
464
+ const widthIn = document.createElement('input');
465
+ widthIn.type = 'number'; widthIn.min = '10'; widthIn.max = '2000';
466
+ widthIn.value = String(it.width ?? 25);
467
+
468
+ const unitSel = document.createElement('select');
469
+ unitSel.innerHTML = '<option value="vw">vw</option><option value="px">px</option>';
470
+ unitSel.value = it.widthUnit || 'vw';
471
+
472
+ widthIn.addEventListener('change', () => {
473
+ it.width = Number(widthIn.value) || (it.widthUnit === 'px' ? 320 : 25);
474
+ renderItems(); markDirty();
475
+ });
476
+ unitSel.addEventListener('change', () => {
477
+ it.widthUnit = unitSel.value;
478
+ renderItems(); markDirty();
479
+ });
480
 
481
  const btnDel = document.createElement('button'); btnDel.textContent='Delete'; btnDel.className='btn-danger';
482
  btnDel.addEventListener('click', ()=> removeItem(it.id));
483
 
484
+ actions.append(btnPlace, btnPreview, widthIn, unitSel, btnDel);
485
  row.append(kind, info, actions); list.appendChild(row);
486
  }
487
  }
 
507
  if (!res.ok) throw new Error('Load failed');
508
  const data = await res.json();
509
  state.background = data.background || null;
510
+ state.items = Array.isArray(data.items)
511
+ ? data.items.map(x => ({
512
+ open:false,
513
+ ...x,
514
+ width: (x.width != null) ? x.width : (x.widthPct != null ? x.widthPct : 25),
515
+ widthUnit: x.widthUnit || (x.widthPct != null ? 'vw' : 'vw')
516
+ }))
517
+ : [];
518
  renderBackground(); renderItems();
519
  saveBadge.textContent = 'Loaded'; setTimeout(()=> saveBadge.textContent='Saved', 700);
520
  }catch(e){