feat(designer): edit designer-made widgets in the designer (+ legacy reconstruction) (#207)

Designs round-trip for visual editing: store the design source in the widget config, reroute Edit to the designer, reconstruct legacy HTML-only designs, PUT the original in place.
This commit is contained in:
screentinker 2026-07-21 09:00:36 -05:00 committed by GitHub
parent 397c4e1aec
commit 3efae1d2d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 122 additions and 15 deletions

View file

@ -134,6 +134,7 @@ export const api = {
// Widgets
getWidgets: () => request('/widgets'),
getWidget: (id) => request('/widgets/' + id),
// Device Groups
getGroups: () => request('/groups'),

View file

@ -393,7 +393,7 @@ function route() {
else if ((hash.startsWith('#/wall') || hash === '#/walls') && link.dataset.view === 'walls') link.classList.add('active');
else if (hash === '#/reports' && link.dataset.view === 'reports') link.classList.add('active');
else if (hash === '#/activity' && link.dataset.view === 'activity') link.classList.add('active');
else if (hash === '#/designer' && link.dataset.view === 'designer') link.classList.add('active');
else if ((hash === '#/designer' || hash.startsWith('#/designer/')) && link.dataset.view === 'designer') link.classList.add('active');
else if ((hash === '#/kiosk' || hash.startsWith('#/kiosk/')) && link.dataset.view === 'kiosk') link.classList.add('active');
else if (hash === '#/help' && link.dataset.view === 'help') link.classList.add('active');
else if (hash.startsWith('#/device/') && link.dataset.view === 'dashboard') link.classList.add('active');
@ -431,9 +431,11 @@ function route() {
} else if (hash === '#/kiosk' || hash.startsWith('#/kiosk/')) {
currentView = kiosk;
kiosk.render(app);
} else if (hash === '#/designer') {
} else if (hash === '#/designer' || hash.startsWith('#/designer/')) {
currentView = designer;
designer.render(app);
// #/designer/<widgetId> reopens a designer-made widget for editing; #/designer starts fresh.
const wid = hash.startsWith('#/designer/') ? hash.split('#/designer/')[1].split('/')[0] : null;
designer.render(app, wid || undefined);
} else if (hash === '#/activity') {
currentView = activity;
activity.render(app);

View file

@ -25,12 +25,18 @@ let bgValue = '#000000';
let bgImageDataUrl = null;
let dragging = null;
let dragStart = null;
// When editing an existing designer-made widget: its id + name, so Publish UPDATES it (PUT) instead of
// creating a new one. Null = a fresh design (create).
let editingWidgetId = null;
let editingWidgetName = null;
export function render(container) {
export function render(container, widgetId) {
elements = [];
selectedIdx = -1;
bgValue = '#000000';
bgImageDataUrl = null;
editingWidgetId = null;
editingWidgetName = null;
container.innerHTML = `
<div class="page-header">
@ -197,18 +203,25 @@ export function render(container) {
document.getElementById('deleteEl').onclick = () => { if (selectedIdx >= 0) { elements.splice(selectedIdx, 1); selectedIdx = -1; redraw(); } };
// Publish as dynamic HTML content
// Publish as a widget. The config carries the rendered HTML (what the player shows) AND the `design`
// source (elements + background) so the widget can be reopened in THIS designer and edited visually
// instead of dropping into the raw HTML editor. Editing an existing designer widget PUTs it in place.
document.getElementById('publishBtn').onclick = async () => {
try {
const html = generateHTML();
const blob = new Blob([html], { type: 'text/html' });
const file = new File([blob], `design-${Date.now()}.html`, { type: 'text/html' });
// Upload as a widget instead - create a text widget with the HTML
const res = await fetch('/api/widgets', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ widget_type: 'text', name: t('designer.widget_name', { date: new Date().toLocaleDateString() }), config: { html: generateInnerHTML(), css: '', background: bgValue } })
});
const config = { html: generateInnerHTML(), css: '', background: bgValue, design: { elements, bgValue, bgImage: bgImageDataUrl } };
const auth = { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` };
// The URL is the source of truth for WHICH widget is being edited (#/designer/<id>). route() can
// re-fire and momentarily reset editingWidgetId, so fall back to the hash — otherwise a save races
// to POST a duplicate instead of updating the original.
const targetId = editingWidgetId || (location.hash.match(/#\/designer\/([^/?]+)/) || [])[1] || null;
let res;
if (targetId) {
const body = { config };
if (editingWidgetName) body.name = editingWidgetName; // preserve the name; server keeps it if omitted
res = await fetch('/api/widgets/' + targetId, { method: 'PUT', headers: auth, body: JSON.stringify(body) });
} else {
res = await fetch('/api/widgets', { method: 'POST', headers: auth, body: JSON.stringify({ widget_type: 'text', name: t('designer.widget_name', { date: new Date().toLocaleDateString() }), config }) });
}
if (res.ok) showToast(t('designer.toast.published'), 'success');
else showToast(t('designer.toast.publish_failed'), 'error');
} catch (err) { showToast(err.message, 'error'); }
@ -310,6 +323,87 @@ export function render(container) {
preview.onmouseup = () => { dragging = null; dragStart = null; };
redraw();
if (widgetId) loadWidgetForEdit(widgetId);
}
// Reopen a designer-made widget for visual editing: pull its stored `design` source back into the
// canvas and remember its id/name so Publish updates it in place. Widgets without a design source
// (e.g. hand-written text widgets) simply don't route here.
async function loadWidgetForEdit(widgetId) {
try {
const w = await api.getWidget(widgetId);
const cfg = typeof w.config === 'string' ? JSON.parse(w.config || '{}') : (w.config || {});
if (cfg.design && Array.isArray(cfg.design.elements)) {
// Preferred: the exact design source saved with the widget.
elements = cfg.design.elements;
bgValue = cfg.design.bgValue || '#000000';
bgImageDataUrl = cfg.design.bgImage || null;
} else if (typeof cfg.html === 'string' && cfg.html) {
// Legacy widget (published before the design source was stored): best-effort reconstruct the
// editable elements from the deterministic HTML the designer produced.
const r = reconstructDesign(cfg.html);
elements = r.elements;
bgValue = cfg.background || '#000000';
bgImageDataUrl = r.bgImage || null;
} else {
return; // nothing to rehydrate
}
editingWidgetId = w.id;
editingWidgetName = w.name;
selectedIdx = -1;
const pub = document.getElementById('publishBtn');
if (pub) pub.textContent = t('common.save'); // updating an existing widget, not publishing a new one
redraw();
showToast(t('designer.toast.loaded'), 'success');
} catch (e) { showToast((e && e.message) || t('designer.toast.invalid_file'), 'error'); }
}
// Best-effort reverse of generateInnerHTML() for LEGACY widgets that stored only the rendered HTML.
// The designer's output is deterministic (positioned elements + a known <script> per dynamic type), so
// it round-trips cleanly for designer-made content; unrecognized nodes are skipped. Returns { elements,
// bgImage }.
function reconstructDesign(html) {
const out = { elements: [], bgImage: null };
let root;
try { root = new DOMParser().parseFromString(`<div id="__r">${html}</div>`, 'text/html').getElementById('__r'); }
catch (e) { return out; }
if (!root) return out;
const rgbToHex = (c) => {
if (!c) return '#FFFFFF';
if (c[0] === '#') return c;
const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
return m ? '#' + [1, 2, 3].map((i) => (+m[i]).toString(16).padStart(2, '0')).join('') : '#FFFFFF';
};
const pct = (v) => parseFloat(v) || 0;
const fontVw = (el) => { const f = parseFloat(el.style.fontSize); return isFinite(f) ? Math.round(f * 10) : 36; };
const scriptFor = (id) => { for (const s of root.querySelectorAll('script')) if ((s.textContent || '').includes(`'${id}'`)) return s.textContent || ''; return ''; };
for (const node of Array.from(root.children)) {
const tag = node.tagName.toLowerCase();
if (tag === 'script' || tag === 'style') continue;
const st = node.style;
const x = pct(st.left), y = pct(st.top);
if (tag === 'img') {
if (!st.left && (st.inset || st.objectFit === 'cover')) { out.bgImage = node.getAttribute('src'); continue; } // baked background
out.elements.push({ type: 'image', x, y, width: pct(st.width), height: pct(st.height), src: node.getAttribute('src') });
} else if (tag === 'video') {
out.elements.push({ type: 'video', x, y, width: pct(st.width), height: pct(st.height), src: node.getAttribute('src'), muted: node.hasAttribute('muted'), loop: node.hasAttribute('loop') });
} else if (tag === 'iframe') {
out.elements.push({ type: 'webpage', x, y, width: pct(st.width), height: pct(st.height), url: node.getAttribute('src') });
} else if (tag === 'div') {
const id = node.id || '';
const base = { x, y, fontSize: fontVw(node), color: rgbToHex(st.color), fontFamily: st.fontFamily || 'Arial' };
const cd = node.querySelector('[id^="cd"]'); // countdown: inner cdN div
const tk = node.querySelector('[id^="t"]'); // ticker: inner tN div
if (/^c\d+$/.test(id)) { const sc = scriptFor(id); out.elements.push({ ...base, bold: true, type: 'clock', showSeconds: /second:'2-digit'/.test(sc), format: /hour12:false/.test(sc) ? '24h' : '12h' }); }
else if (/^d\d+$/.test(id)) { out.elements.push({ ...base, type: 'date' }); }
else if (/^w\d+$/.test(id)) { const sc = scriptFor(id); const loc = (sc.match(/wttr\.in\/([^?]+)\?/) || [])[1]; out.elements.push({ ...base, type: 'weather', location: loc ? decodeURIComponent(loc) : '', units: /temp_C/.test(sc) ? 'metric' : 'imperial' }); }
else if (cd) { const sc = scriptFor(cd.id); const lbl = node.querySelector('div'); out.elements.push({ ...base, type: 'countdown', label: lbl ? lbl.textContent : '', targetDate: (sc.match(/new Date\('([^']+)'\)/) || [])[1] || '' }); }
else if (tk && (st.overflow === 'hidden' || st.display === 'flex')) { const sc = scriptFor(tk.id); const feed = (sc.match(/rss_url=([^']+)'/) || [])[1]; out.elements.push({ type: 'ticker', x, y, width: pct(st.width), height: pct(st.height), bgColor: rgbToHex(st.background || st.backgroundColor), color: rgbToHex(tk.style.color), feedUrl: feed ? decodeURIComponent(feed) : '', speed: parseFloat((tk.style.animation || '').match(/([\d.]+)s/)?.[1]) || 30, fontSize: fontVw(tk) }); }
else if ((st.background || st.backgroundColor) && st.width && st.height && !node.textContent.trim()) { const circle = st.borderRadius === '50%'; out.elements.push({ type: 'shape', x, y, width: pct(st.width), height: pct(st.height), color: rgbToHex(st.background || st.backgroundColor), opacity: parseFloat(st.opacity) || 1, shape: circle ? 'circle' : 'rect', radius: circle ? 0 : (parseInt(st.borderRadius) || 0) }); }
else { out.elements.push({ ...base, type: 'text', text: node.textContent, bold: st.fontWeight === 'bold' || st.fontWeight === '700', shadow: !!st.textShadow }); }
}
}
return out;
}
function addElement(el) {

View file

@ -1028,9 +1028,19 @@ export async function render(container) {
if (editBtn) {
const w = widgets.find(x => x.id === editBtn.dataset.editWidget);
if (w) {
const config = JSON.parse(w.config || '{}');
// Reopen designer-made widgets IN the designer for visual editing instead of the raw HTML form.
// New designs carry a `design` source; legacy ones (HTML only) are detected by the designer's
// signature output (every element is absolutely positioned) — the designer reconstructs their
// elements from the HTML. Hand-written HTML text widgets lack the pattern and keep the editor.
const designerMade = (config.design && Array.isArray(config.design.elements))
|| (w.widget_type === 'text' && typeof config.html === 'string' && /position:absolute;left:/.test(config.html));
if (designerMade) {
window.location.hash = '#/designer/' + w.id;
return;
}
editingWidget = w;
creatingType = w.widget_type;
const config = JSON.parse(w.config || '{}');
config._name = w.name;
showConfigForm(w.widget_type, config);
}