import { showToast } from '../components/toast.js'; import { t } from '../i18n.js'; import { hydrateAuthImages } from '../utils.js'; // A refused request must reject, not resolve. // // This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary // value and the surrounding try/catch was unreachable — every handler took the failure for success. // Concretely: deleting a built-in layout template showed "Layout deleted" while the server had // returned 403 and the template was still there, and a rejected platform-role change showed "Role // updated" while the dropdown kept displaying a value the server refused (its revert lives only in // the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did // not. Same contract now, including the 401 session-expiry reload. const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => { if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); } if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); } return r.json(); }); // Widget type ids only — name + desc are looked up via t() so they switch // language with the rest of the UI. const WIDGET_TYPES = ['clock', 'weather', 'rss', 'text', 'webpage', 'social', 'directory-board', 'directory-search', 'transition']; const WIDGET_ICONS = { clock: '🕓', weather: '⛅', rss: '📰', text: '📝', webpage: '🌐', social: '💬', 'directory-board': '🏢', 'directory-search': '🔍', transition: '🎬', }; const widgetTypeName = (id) => t(`widget.type.${id.replace(/-/g, '_')}.name`); const widgetTypeDesc = (id) => t(`widget.type.${id.replace(/-/g, '_')}.desc`); function escAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } // --- Directory-board bulk import: tolerant parser for JSON, CSV/TSV/pipe/semicolon // tables, or a sectioned "room name" text list. Returns { meta, categories, // background_images, warnings, stats }. Pure (no DOM) — unit-tested in node. --- function _diPick(o, keys) { if (!o || typeof o !== 'object') return undefined; const low = {}; for (const k of Object.keys(o)) low[k.toLowerCase()] = o[k]; for (const k of keys) { const v = low[k]; if (v != null && v !== '') return v; } return undefined; } function _diBool(v) { if (v === true) return true; const s = String(v == null ? '' : v).trim().toLowerCase(); return s === 'true' || s === 'yes' || s === 'y' || s === '1' || s === 'available' || s === 'open' || s === 'vacant'; } function _diEntry(e) { if (e == null) return null; if (typeof e === 'string' || typeof e === 'number') return { identifier: '', name: String(e).trim(), subtitle: '', available: false }; const id = _diPick(e, ['room', 'identifier', 'id', 'number', 'no', 'suite', 'unit', 'office', 'space']); const name = _diPick(e, ['name', 'title', 'company', 'tenant', 'business', 'label', 'text']); const sub = _diPick(e, ['subtitle', 'details', 'detail', 'description', 'desc', 'note', 'notes', 'info']); const avail = _diPick(e, ['available', 'vacant', 'is_available', 'open', 'status']); return { identifier: id == null ? '' : String(id).trim(), name: name == null ? '' : String(name).trim(), subtitle: sub == null ? '' : String(sub).trim(), available: avail == null ? false : _diBool(avail), }; } const _DI_META_ARRAY_KEYS = new Set(['advertisements', 'ads', 'announcements', 'backgroundimages', 'background_images', 'backgrounds']); function _diNormalizeJson(data) { const meta = {}; const warnings = []; const title = _diPick(data, ['company', 'title', 'name', 'building', 'property']); if (title != null) meta.title = String(title).trim(); let footer = _diPick(data, ['footer_text', 'footer', 'footertext', 'leasing', 'contact']); const ads = data.advertisements || data.ads || data.announcements; if (footer == null && Array.isArray(ads)) footer = ads.map(a => (typeof a === 'string' ? a : _diPick(a, ['text', 'message', 'content']))).filter(Boolean).join(' • '); if (footer) meta.footer_text = String(footer).trim(); const theme = _diPick(data, ['theme']); if (theme) meta.theme = String(theme).toLowerCase(); const speed = _diPick(data, ['scroll_speed', 'scrollspeed', 'speed']); if (speed) meta.scroll_speed = String(speed).toLowerCase(); const cols = _diPick(data, ['columns', 'cols']); if (cols != null) meta.columns = String(cols).toLowerCase(); const logo = _diPick(data, ['logo_url', 'logo', 'logourl']); if (logo) meta.logo_url = String(logo); const bgSrc = data.background_images || data.backgroundImages || data.backgrounds || []; const background_images = []; let skippedBg = 0; if (Array.isArray(bgSrc)) for (const b of bgSrc) { const s = typeof b === 'string' ? b : _diPick(b, ['url', 'src', 'path']); if (!s) continue; if (/^(https?:)?\/\//i.test(s) || String(s).startsWith('/')) background_images.push(String(s)); else skippedBg++; } if (skippedBg) warnings.push(t('widget.dir.import_warn_bg', { n: skippedBg })); let categories = []; const mapCat = (c) => ({ name: String(_diPick(c, ['name', 'title', 'floor', 'category', 'section', 'label']) || '').trim(), entries: (c.entries || c.tenants || c.items || c.rooms || []).map(_diEntry).filter(Boolean) }); const tbf = data.tenantsByFloor || data.byfloor || data.tenantsbyfloor || data.sections; if (tbf && typeof tbf === 'object' && !Array.isArray(tbf)) categories = Object.keys(tbf).map(fn => ({ name: fn, entries: (Array.isArray(tbf[fn]) ? tbf[fn] : []).map(_diEntry).filter(Boolean) })); else if (Array.isArray(data.categories)) categories = data.categories.map(mapCat); else if (Array.isArray(data.floors)) categories = data.floors.map(mapCat); else if (Array.isArray(data)) { if (data.some(x => x && typeof x === 'object' && (x.entries || x.tenants || x.items || x.rooms))) categories = data.map(mapCat); else categories = [{ name: '', entries: data.map(_diEntry).filter(Boolean) }]; } else { const floorKeys = Object.keys(data).filter(k => Array.isArray(data[k]) && !_DI_META_ARRAY_KEYS.has(k.toLowerCase())); if (floorKeys.length) categories = floorKeys.map(fn => ({ name: fn, entries: data[fn].map(_diEntry).filter(Boolean) })); } return { meta, categories, background_images, warnings }; } function _diSplit(line, delim) { if (delim !== ',' && delim !== ';') return line.split(delim); const out = []; let cur = ''; let q = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (q) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; } else if (ch === '"') q = true; else if (ch === delim) { out.push(cur); cur = ''; } else cur += ch; } out.push(cur); return out; } const _DI_HEAD = { floor: ['floor', 'category', 'section', 'level', 'wing', 'building', 'group'], id: ['room', 'suite', 'unit', 'id', 'number', 'no', 'no.', 'office', 'space', '#'], name: ['name', 'tenant', 'company', 'business', 'title', 'occupant'], sub: ['subtitle', 'details', 'detail', 'description', 'desc', 'note', 'notes', 'info'], avail: ['available', 'vacant', 'status', 'open'], }; function _diHeaderRole(cell) { const c = cell.trim().toLowerCase(); for (const role of Object.keys(_DI_HEAD)) if (_DI_HEAD[role].includes(c)) return role; return null; } function _diParseTable(lines, delim, warnings) { const rows = lines.map(l => _diSplit(l, delim).map(c => c.trim())); const first = rows[0] || []; const roles = first.map(_diHeaderRole); const hasHeader = roles.filter(Boolean).length >= 2 || (roles.includes('name') && rows.length > 1); const idx = { floor: -1, id: -1, name: -1, sub: -1, avail: -1 }; let body = rows; if (hasHeader) { roles.forEach((r, i) => { if (r && idx[r] === -1) idx[r] = i; }); body = rows.slice(1); } else { const n = Math.max(...rows.map(r => r.length)); if (n <= 2) { idx.id = 0; idx.name = 1; } else if (n === 3) { idx.id = 0; idx.name = 1; idx.sub = 2; } else { idx.floor = 0; idx.id = 1; idx.name = 2; idx.sub = 3; } warnings.push(t('widget.dir.import_warn_noheader')); } if (idx.name === -1 && idx.id === -1) { idx.id = 0; idx.name = 1; } const cats = new Map(); const getCat = (nm) => { const k = nm || ''; if (!cats.has(k)) cats.set(k, { name: k, entries: [] }); return cats.get(k); }; for (const r of body) { if (!r.length || r.every(c => c === '')) continue; const cell = (i) => (i >= 0 && i < r.length ? r[i] : ''); const e = { identifier: cell(idx.id), name: idx.name >= 0 ? cell(idx.name) : '', subtitle: idx.sub >= 0 ? cell(idx.sub) : '', available: idx.avail >= 0 ? _diBool(cell(idx.avail)) : false }; if (!e.identifier && !e.name) continue; getCat(idx.floor >= 0 ? cell(idx.floor) : '').entries.push(e); } return { meta: {}, categories: [...cats.values()], background_images: [], warnings }; } function _diHeading(line) { const l = line.trim(); if (/:$/.test(l)) return l.replace(/:$/, '').trim(); if (/^#{1,6}\s+/.test(l)) return l.replace(/^#{1,6}\s+/, '').trim(); if (/^\[.+\]$/.test(l)) return l.slice(1, -1).trim(); if (/^=+\s*(.+?)\s*=+$/.test(l)) return l.replace(/^=+\s*/, '').replace(/\s*=+$/, '').trim(); if (/^-{3,}\s*(.+?)\s*-{3,}$/.test(l)) return l.replace(/^-+\s*/, '').replace(/\s*-+$/, '').trim(); if (!/^\W*\d/.test(l) && /\b(floor|level|suite|section|wing|building)\b/i.test(l) && l.split(/\s+/).length <= 4) return l; return null; } function _diParseSectioned(lines, warnings) { const cats = []; let cur = null; const ensure = () => { if (!cur) { cur = { name: '', entries: [] }; cats.push(cur); } return cur; }; for (const raw of lines) { const heading = _diHeading(raw); if (heading != null) { cur = { name: heading, entries: [] }; cats.push(cur); continue; } const l = raw.trim(); const m = l.match(/^(#?\d+[A-Za-z]?)[\s.)\-:–—|]+(.+)$/); const e = m ? { identifier: m[1].replace(/^#/, ''), name: m[2].trim(), subtitle: '', available: false } : { identifier: '', name: l, subtitle: '', available: false }; if (e.identifier || e.name) ensure().entries.push(e); } if (!(cats.length > 1 || cats.some(c => c.name))) warnings.push(t('widget.dir.import_warn_nosections')); return { meta: {}, categories: cats, background_images: [], warnings }; } function _diParseDelimited(raw) { const warnings = []; const lines = raw.split(/\r?\n/).map(l => l.replace(/\s+$/, '')).filter(l => l.trim() !== ''); if (!lines.length) throw new Error(t('widget.dir.import_err_empty')); const sample = lines.slice(0, 12); let delim = null, best = 1; for (const d of ['\t', ',', ';', '|']) { const counts = sample.map(l => _diSplit(l, d).length); const withCols = counts.filter(c => c >= 2).length; const avg = counts.reduce((a, b) => a + b, 0) / counts.length; if (withCols >= Math.ceil(sample.length * 0.6) && avg > best) { best = avg; delim = d; } } return delim ? _diParseTable(lines, delim, warnings) : _diParseSectioned(lines, warnings); } function parseDirectoryImport(text) { const raw = String(text == null ? '' : text).trim(); if (!raw) throw new Error(t('widget.dir.import_err_empty')); let json = null; if (/^[[{]/.test(raw)) { try { json = JSON.parse(raw); } catch (e) { /* not JSON */ } } const res = (json && typeof json === 'object') ? _diNormalizeJson(json) : _diParseDelimited(raw); res.categories = (res.categories || []) .map(c => ({ name: String(c.name || '').trim(), entries: (c.entries || []).filter(e => (e.identifier || '').trim() || (e.name || '').trim()) })) .filter(c => c.name || c.entries.length); if (!res.categories.length) throw new Error(t('widget.dir.import_err_norows')); res.stats = { categories: res.categories.length, entries: res.categories.reduce((n, c) => n + c.entries.length, 0) }; return res; } function openContentPicker({ multiple = false, title } = {}) { return new Promise(async (resolve) => { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px'; overlay.innerHTML = `

${title || t('widget.picker.default_title')}

${multiple ? `` : ''}
`; document.body.appendChild(overlay); let items = []; try { items = await API('/content'); } catch {} items = (items || []).filter(i => (i.mime_type || '').startsWith('image/')); const selected = new Set(); const resolveUrl = (item) => item.remote_url || `/api/content/${item.id}/file`; const updateCount = () => { const el = overlay.querySelector('#cpSelCount'); if (el && multiple) el.textContent = t('widget.picker.selected_count', { n: selected.size }); }; function renderList() { const q = (overlay.querySelector('#cpSearch').value || '').toLowerCase(); const filtered = items.filter(i => (i.filename || '').toLowerCase().includes(q)); const list = overlay.querySelector('#cpList'); if (!filtered.length) { list.innerHTML = `
${items.length ? t('widget.picker.no_matches') : t('widget.picker.no_images')}
`; return; } list.innerHTML = `
${ filtered.map(c => { const isSel = selected.has(c.id); const isRemote = !!c.remote_url; const thumb = c.remote_url || `/api/content/${c.id}/thumbnail`; return `
${escAttr(c.filename)}
${isSel ? '
' : ''}
`; }).join('') }
`; hydrateAuthImages(list, { eager: true }); list.querySelectorAll('[data-pick-id]').forEach(el => el.onclick = () => { const id = el.dataset.pickId; if (multiple) { if (selected.has(id)) selected.delete(id); else selected.add(id); updateCount(); renderList(); } else { const item = items.find(x => String(x.id) === id); if (item) { cleanup(); resolve(resolveUrl(item)); } } }); } function cleanup() { overlay.remove(); } overlay.querySelector('#cpSearch').oninput = renderList; overlay.querySelector('#cpCancel').onclick = () => { cleanup(); resolve(multiple ? [] : null); }; if (multiple) { overlay.querySelector('#cpDone').onclick = () => { const urls = Array.from(selected).map(id => { const item = items.find(x => String(x.id) === id); return item ? resolveUrl(item) : null; }).filter(Boolean); cleanup(); resolve(urls); }; } overlay.onclick = (e) => { if (e.target === overlay) { cleanup(); resolve(multiple ? [] : null); } }; updateCount(); renderList(); }); } function showPreviewModal(sessionId, widgetType) { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px'; // #104: webpage widgets pointing at frame-denying sites (X-Frame-Options) can't be // embedded in a browser preview — and an XFO refusal is provably indistinguishable // client-side from a working embed, so we don't guess. Always show the honest note. const webpageNote = widgetType === 'webpage' ? `
${t('widget.webpage_blocked_note')}
` : ''; overlay.innerHTML = `
${t('widget.preview_title')}
${webpageNote}
`; document.body.appendChild(overlay); overlay.querySelector('#pvIframe').src = '/api/widgets/preview-session/' + sessionId; const close = () => overlay.remove(); overlay.querySelector('#pvClose').onclick = close; overlay.onclick = (e) => { if (e.target === overlay) close(); }; document.addEventListener('keydown', function esc(ev) { if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc); } }); } export async function render(container) { container.innerHTML = `
`; let editingWidget = null; let creatingType = null; let dirState = { categories: [], logo_url: '', background_images: [] }; // Cached widget list from the last load — used to populate the directory-search // source-board dropdown without a second fetch. let loadedWidgets = []; document.getElementById('newWidgetBtn').onclick = () => { const grid = document.getElementById('widgetTypeGrid'); grid.style.display = grid.style.display === 'none' ? 'grid' : 'none'; }; container.querySelectorAll('[data-create-type]').forEach(el => { el.onclick = () => { creatingType = el.dataset.createType; editingWidget = null; document.getElementById('widgetTypeGrid').style.display = 'none'; showConfigForm(creatingType, {}); }; }); function showConfigForm(type, config) { const typeName = widgetTypeName(type); document.getElementById('widgetModalTitle').textContent = editingWidget ? t('widget.edit_x', { type: typeName }) : t('widget.new_x', { type: typeName }); let html = `
`; switch (type) { case 'clock': html += `
`; break; case 'weather': html += `
`; break; case 'rss': html += `
`; break; case 'text': html += `
`; break; case 'webpage': html += `
`; break; case 'social': html += `
`; break; case 'directory-board': html += `
${t('widget.dir.import_hint')}
${t('widget.dir.bg_images_hint')}
`; break; case 'directory-search': { const boards = (loadedWidgets || []).filter(w => w.widget_type === 'directory-board'); const sourceField = boards.length ? `
${t('widget.dirsearch.source_hint')}
` : `
${t('widget.dirsearch.source_empty')}
`; html += `
${sourceField}
`; break; } case 'transition': html += `
${t('widget.trans.multi_hint')}
0.00
${t('widget.trans.scope_hint')}
`; break; } document.getElementById('widgetConfigForm').innerHTML = html; const modalEl = document.querySelector('#widgetModal .modal'); if (modalEl) modalEl.style.width = type === 'directory-board' ? '720px' : (type === 'transition' ? '620px' : '560px'); document.getElementById('widgetModal').style.display = 'flex'; // Transitions carry their own live preview, so the iframe "Preview" button doesn't apply. const pvBtn = document.getElementById('previewWidgetBtn'); if (pvBtn) pvBtn.style.display = (type === 'transition') ? 'none' : ''; if (type === 'directory-board') { dirState.logo_url = config.logo_url || ''; dirState.background_images = Array.isArray(config.background_images) ? config.background_images.slice() : []; dirState.categories = (config.categories || []).map(cat => ({ name: cat.name || '', _expanded: false, entries: (cat.entries || []).map(e => ({ identifier: e.identifier || '', name: e.name || '', subtitle: e.subtitle || '', available: !!e.available, })), })); renderLogoPicker(); renderBgList(); renderDirCategories(); document.getElementById('dbAddCategory').onclick = () => { dirState.categories.push({ name: '', _expanded: true, entries: [] }); renderDirCategories({ focusCatName: dirState.categories.length - 1 }); }; document.getElementById('wBgAdd').onclick = pickBgImages; document.getElementById('dbImportData').onclick = openDirImport; } if (type === 'directory-search') { // Reuse the board's logo picker box (#wLogoBox + dirState.logo_url). dirState.logo_url = config.logo_url || ''; renderLogoPicker(); } if (type === 'transition') initTransitionForm(config); } // Live transition picker: a CHECKLIST of effects (pick one or several — the player randomizes among // the chosen set per advance), a WebGL preview of the focused effect crossing two placeholder images, // param sliders (from the shader's declared ranges) + duration + scope. Uses the same runtime the // player ships (/player/transitions.js), so the preview IS the shipping renderer. let transState = { renderer: null, from: null, to: null, raf: 0, playing: false, t0: 0, params: {}, focus: null }; function ensureTransitionRuntime() { if (window.TransitionRenderer && window.__TRANSITION_MANIFEST) return Promise.resolve(true); return new Promise((resolve) => { const s = document.createElement('script'); s.src = '/player/transitions.js'; s.onload = () => resolve(true); s.onerror = () => resolve(false); document.head.appendChild(s); }); } function transPlaceholder(color, label) { const c = document.createElement('canvas'); c.width = 640; c.height = 360; const cx = c.getContext('2d'); const g = cx.createLinearGradient(0, 0, 640, 360); g.addColorStop(0, color[0]); g.addColorStop(1, color[1]); cx.fillStyle = g; cx.fillRect(0, 0, 640, 360); cx.fillStyle = 'rgba(255,255,255,.9)'; cx.textBaseline = 'middle'; cx.font = '700 64px system-ui,sans-serif'; cx.fillText(label, 44, 176); return c; } async function initTransitionForm(config) { const canvas = document.getElementById('wTransCanvas'); const list = document.getElementById('wTransList'); const blurb = document.getElementById('wTransBlurb'); const scrub = document.getElementById('wTransScrub'); const progLbl = document.getElementById('wTransProgress'); const playBtn = document.getElementById('wTransPlay'); const paramsBox = document.getElementById('wTransParams'); if (!canvas || !list) return; const ready = await ensureTransitionRuntime(); if (!ready || !window.__TRANSITION_MANIFEST) { blurb.textContent = t('widget.trans.unavailable'); return; } const MAN = window.__TRANSITION_MANIFEST; const byId = (id) => MAN.find((x) => x.id === id); // initial selection: config.shaders, else legacy single config.shader, else the first effect const initialSel = (Array.isArray(config.shaders) && config.shaders.length ? config.shaders : (config.shader ? [config.shader] : [MAN[0].id])).filter(byId); transState.params = {}; // id -> resolved param values (lazily seeded) canvas.width = 640; canvas.height = 360; try { transState.renderer = window.TransitionRenderer.createRenderer(canvas, window.TransitionParams, { preserveDrawingBuffer: true }); transState.from = transPlaceholder(['#f97316', '#b91c1c'], 'A'); transState.to = transPlaceholder(['#0ea5e9', '#155e75'], 'B'); transState.renderer.setFrom(transState.from); transState.renderer.setTo(transState.to); } catch (e) { blurb.textContent = t('widget.trans.unavailable'); return; } const paramsFor = (id) => { if (transState.params[id]) return transState.params[id]; const m = byId(id); const stored = (config.params && config.params[id]) || (config.shader === id && config.params) || {}; transState.params[id] = window.TransitionParams.resolveParams( m.params.map((pp) => ({ name: pp.name, default: pp.default, min: pp.min, max: pp.max })), stored); return transState.params[id]; }; const render = () => { if (!transState.focus) return; const p = (+scrub.value) / 1000; progLbl.textContent = p.toFixed(2); try { transState.renderer.render(p, paramsFor(transState.focus)); } catch (e) {} }; const buildSliders = (id) => { const m = byId(id), vals = paramsFor(id); paramsBox.innerHTML = ''; m.params.forEach((pp) => { const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:10px;margin-bottom:6px'; const lab = document.createElement('label'); lab.textContent = pp.name; lab.style.cssText = 'font-size:12px;color:var(--text-muted);min-width:104px'; const r = document.createElement('input'); r.type = 'range'; r.min = pp.min; r.max = pp.max; r.step = (pp.max - pp.min) / 200 || 0.001; r.value = vals[pp.name]; r.style.cssText = 'flex:1;accent-color:var(--accent)'; const v = document.createElement('span'); v.textContent = (+vals[pp.name]).toFixed(2); v.style.cssText = 'font:600 11px ui-monospace,monospace;color:var(--text-muted);min-width:44px;text-align:right'; r.oninput = () => { vals[pp.name] = +r.value; v.textContent = (+r.value).toFixed(2); render(); }; row.appendChild(lab); row.appendChild(r); row.appendChild(v); paramsBox.appendChild(row); }); }; const focusShader = (id) => { transState.focus = id; const m = byId(id); blurb.textContent = m ? (m.blurb || '') : ''; list.querySelectorAll('[data-focus]').forEach((el) => { el.style.fontWeight = el.dataset.focus === id ? '700' : '400'; }); try { transState.renderer.setShader(window.__TRANSITION_SHADERS[id]); } catch (e) { blurb.textContent = t('widget.trans.compile_error'); return; } buildSliders(id); render(); }; list.innerHTML = MAN.map((m) => ` `).join(''); list.querySelectorAll('input[type=checkbox]').forEach((cb) => { cb.onchange = () => { if (cb.checked) focusShader(cb.dataset.id); }; }); // clicking the NAME previews/tunes it; preventDefault so the label doesn't also toggle its checkbox list.querySelectorAll('[data-focus]').forEach((sp) => { sp.onclick = (e) => { e.preventDefault(); focusShader(sp.dataset.focus); }; }); scrub.oninput = render; // auto-play loop (eased, with holds); auto-stops when the modal closes (canvas leaves the DOM) const DUR = 1600, HOLD = 500; const loop = (ts) => { if (!document.body.contains(canvas)) { transState.playing = false; return; } if (!transState.t0) transState.t0 = ts; const cycle = DUR + HOLD * 2, e = (ts - transState.t0) % cycle; let p = e < HOLD ? 0 : e > HOLD + DUR ? 1 : (e - HOLD) / DUR; p = p <= 0 ? 0 : p >= 1 ? 1 : (1 - Math.cos(p * Math.PI)) / 2; scrub.value = Math.round(p * 1000); render(); if (transState.playing) transState.raf = requestAnimationFrame(loop); }; playBtn.onclick = () => { transState.playing = !transState.playing; playBtn.textContent = transState.playing ? t('widget.trans.pause') : t('widget.trans.play'); if (transState.playing) { transState.t0 = 0; transState.raf = requestAnimationFrame(loop); } else cancelAnimationFrame(transState.raf); }; focusShader(initialSel[0] || MAN[0].id); } function renderDirCategories(opts = {}) { const cont = document.getElementById('dbCategories'); if (!cont) return; if (!dirState.categories.length) { cont.innerHTML = `
${t('widget.dir.empty_categories')}
`; return; } cont.innerHTML = dirState.categories.map((cat, i) => { const entryRows = (cat.entries || []).map((e, j) => `
`).join(''); const entryCount = cat.entries.length; const entriesLabel = entryCount === 1 ? t('widget.dir.entry') : t('widget.dir.entries'); return `
${entryCount} ${entriesLabel}
${cat._expanded ? `
${entryRows || `
${t('widget.dir.no_entries')}
`}
` : ''}
`; }).join(''); wireDirHandlers(opts); } function wireDirHandlers(opts = {}) { const cont = document.getElementById('dbCategories'); if (!cont) return; cont.querySelectorAll('[data-cat-toggle]').forEach(b => b.onclick = () => { const i = +b.dataset.catToggle; dirState.categories[i]._expanded = !dirState.categories[i]._expanded; renderDirCategories(); }); cont.querySelectorAll('[data-cat-name]').forEach(inp => inp.oninput = () => { dirState.categories[+inp.dataset.catName].name = inp.value; }); cont.querySelectorAll('[data-cat-up]').forEach(b => b.onclick = () => { const i = +b.dataset.catUp; if (i === 0) return; [dirState.categories[i - 1], dirState.categories[i]] = [dirState.categories[i], dirState.categories[i - 1]]; renderDirCategories(); }); cont.querySelectorAll('[data-cat-down]').forEach(b => b.onclick = () => { const i = +b.dataset.catDown; if (i >= dirState.categories.length - 1) return; [dirState.categories[i + 1], dirState.categories[i]] = [dirState.categories[i], dirState.categories[i + 1]]; renderDirCategories(); }); cont.querySelectorAll('[data-cat-delete]').forEach(b => b.onclick = () => { const i = +b.dataset.catDelete; const label = dirState.categories[i].name || t('widget.dir.unnamed'); if (!confirm(t('widget.dir.confirm_delete_category', { name: label }))) return; dirState.categories.splice(i, 1); renderDirCategories(); }); cont.querySelectorAll('[data-entry-id]').forEach(inp => inp.oninput = () => { const [i, j] = inp.dataset.entryId.split('-').map(Number); dirState.categories[i].entries[j].identifier = inp.value; }); cont.querySelectorAll('[data-entry-name]').forEach(inp => inp.oninput = () => { const [i, j] = inp.dataset.entryName.split('-').map(Number); dirState.categories[i].entries[j].name = inp.value; }); cont.querySelectorAll('[data-entry-subtitle]').forEach(inp => inp.oninput = () => { const [i, j] = inp.dataset.entrySubtitle.split('-').map(Number); dirState.categories[i].entries[j].subtitle = inp.value; }); cont.querySelectorAll('[data-entry-avail]').forEach(inp => inp.onchange = () => { const [i, j] = inp.dataset.entryAvail.split('-').map(Number); dirState.categories[i].entries[j].available = inp.checked; }); cont.querySelectorAll('[data-entry-up]').forEach(b => b.onclick = () => { const [i, j] = b.dataset.entryUp.split('-').map(Number); if (j === 0) return; const es = dirState.categories[i].entries; [es[j - 1], es[j]] = [es[j], es[j - 1]]; renderDirCategories(); }); cont.querySelectorAll('[data-entry-down]').forEach(b => b.onclick = () => { const [i, j] = b.dataset.entryDown.split('-').map(Number); const es = dirState.categories[i].entries; if (j >= es.length - 1) return; [es[j + 1], es[j]] = [es[j], es[j + 1]]; renderDirCategories(); }); cont.querySelectorAll('[data-entry-delete]').forEach(b => b.onclick = () => { const [i, j] = b.dataset.entryDelete.split('-').map(Number); dirState.categories[i].entries.splice(j, 1); renderDirCategories(); }); cont.querySelectorAll('[data-add-entry]').forEach(b => b.onclick = () => { const i = +b.dataset.addEntry; dirState.categories[i].entries.push({ identifier: '', name: '', subtitle: '', available: false }); renderDirCategories({ focusEntryId: `${i}-${dirState.categories[i].entries.length - 1}` }); }); if (opts.focusCatName != null) { const inp = cont.querySelector(`[data-cat-name="${opts.focusCatName}"]`); if (inp) { inp.focus(); inp.select(); } } if (opts.focusEntryId) { const inp = cont.querySelector(`[data-entry-id="${opts.focusEntryId}"]`); if (inp) inp.focus(); } } function openDirImport() { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10001;padding:16px'; const hasExisting = dirState.categories.length > 0; overlay.innerHTML = `

${t('widget.dir.import_title')}

${t('widget.dir.import_desc')}
`; document.body.appendChild(overlay); const ta = overlay.querySelector('#diText'); ta.focus(); const cleanup = () => overlay.remove(); overlay.querySelector('#diCancel').onclick = cleanup; overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; overlay.querySelector('#diGo').onclick = () => { const errBox = overlay.querySelector('#diError'); let result; try { result = parseDirectoryImport(ta.value); } catch (e) { errBox.textContent = e.message || t('widget.dir.import_err_norows'); errBox.style.display = 'block'; return; } applyDirImport(result, overlay.querySelector('#diReplace').checked); cleanup(); }; } function applyDirImport(result, replace) { const m = result.meta || {}; const setVal = (id, v) => { const el = document.getElementById(id); if (el && v != null && v !== '') el.value = v; }; setVal('wTitle', m.title); setVal('wFooter', m.footer_text); const setSel = (id, v, allowed) => { if (v == null) return; const el = document.getElementById(id); if (el && allowed.includes(String(v))) el.value = String(v); }; setSel('wTheme', m.theme, ['dark', 'light']); setSel('wSpeed', m.scroll_speed, ['slow', 'medium', 'fast']); setSel('wCols', m.columns, ['auto', '1', '2', '3', '4']); if (m.logo_url && (/^(https?:)?\/\//i.test(m.logo_url) || m.logo_url.startsWith('/'))) { dirState.logo_url = m.logo_url; renderLogoPicker(); } if (Array.isArray(result.background_images) && result.background_images.length) { const seen = new Set(dirState.background_images); for (const u of result.background_images) if (!seen.has(u)) { dirState.background_images.push(u); seen.add(u); } renderBgList(); } const mapped = result.categories.map(c => ({ name: c.name || '', _expanded: false, entries: c.entries.map(e => ({ identifier: e.identifier || '', name: e.name || '', subtitle: e.subtitle || '', available: !!e.available })), })); dirState.categories = replace ? mapped : dirState.categories.concat(mapped); renderDirCategories(); const warnings = result.warnings || []; const msg = [t('widget.dir.import_done', { cats: result.stats.categories, entries: result.stats.entries })].concat(warnings).join(' '); showToast(msg, warnings.length ? 'info' : 'success'); } function renderLogoPicker() { const box = document.getElementById('wLogoBox'); if (!box) return; if (dirState.logo_url) { box.innerHTML = `
${escAttr(dirState.logo_url)}
`; document.getElementById('wLogoChange').onclick = pickLogo; document.getElementById('wLogoClear').onclick = () => { dirState.logo_url = ''; renderLogoPicker(); }; } else { box.innerHTML = ``; document.getElementById('wLogoChoose').onclick = pickLogo; } hydrateAuthImages(box); } async function pickLogo() { const url = await openContentPicker({ multiple: false, title: t('widget.picker.select_logo') }); if (url) { dirState.logo_url = url; renderLogoPicker(); } } function renderBgList() { const list = document.getElementById('wBgList'); if (!list) return; if (!dirState.background_images.length) { list.innerHTML = `
${t('widget.dir.no_bg_images')}
`; return; } list.innerHTML = `
${ dirState.background_images.map((u, i) => `
`).join('') }
`; hydrateAuthImages(list); list.querySelectorAll('[data-bg-remove]').forEach(b => b.onclick = () => { dirState.background_images.splice(+b.dataset.bgRemove, 1); renderBgList(); }); } async function pickBgImages() { const urls = await openContentPicker({ multiple: true, title: t('widget.picker.select_bg_images') }); if (urls && urls.length) { dirState.background_images.push(...urls); renderBgList(); } } function getConfigFromForm(type) { const config = {}; const val = id => document.getElementById(id)?.value; switch (type) { case 'clock': Object.assign(config, { format: val('wFormat'), timezone: val('wTimezone'), font_size: parseInt(val('wFontSize')) || 64, color: val('wColor'), background: val('wBg'), show_date: true }); break; case 'weather': Object.assign(config, { location: val('wLocation'), units: val('wUnits'), font_size: parseInt(val('wFontSize')) || 48, color: val('wColor') }); break; case 'rss': Object.assign(config, { feed_url: val('wFeedUrl'), scroll_speed: parseInt(val('wScrollSpeed')) || 30, max_items: parseInt(val('wMaxItems')) || 10, font_size: parseInt(val('wFontSize')) || 24, color: val('wColor'), background: val('wBg') }); break; case 'text': Object.assign(config, { html: val('wHtml'), css: val('wCss'), background: val('wBg') }); break; case 'webpage': Object.assign(config, { url: val('wUrl'), zoom: parseInt(val('wZoom')) || 100, refresh_interval: parseInt(val('wRefresh')) || 0 }); break; case 'social': Object.assign(config, { platform: val('wPlatform'), query: val('wQuery') }); break; case 'transition': { const shaders = Array.from(document.querySelectorAll('#wTransList input[type=checkbox]:checked')).map(c => c.dataset.id); const params = {}; // per-shader tuned values held in transState.params shaders.forEach(id => { if (transState.params[id]) params[id] = transState.params[id]; }); Object.assign(config, { shaders, params, durationMs: parseInt(val('wTransDuration')) || 800, scope: val('wTransScope') || 'all', }); break; } case 'directory-board': Object.assign(config, { title: val('wTitle') || ' ', logo_url: dirState.logo_url || '', footer_text: val('wFooter') || '', background_images: dirState.background_images.slice(), theme: val('wTheme') || 'dark', scroll_speed: val('wSpeed') || 'medium', columns: val('wCols') || 'auto', categories: dirState.categories.map(cat => ({ name: cat.name || '', entries: (cat.entries || []).map(e => ({ identifier: e.identifier || '', name: e.name || '', subtitle: e.subtitle || '', available: !!e.available, })), })), }); break; case 'directory-search': Object.assign(config, { source_widget_id: val('wSource') || '', title: val('wTitle') || '', logo_url: dirState.logo_url || '', theme: val('wTheme') || 'dark', placeholder_text: val('wPlaceholder') || '', show_onscreen_keyboard: document.getElementById('wKeyboard') ? document.getElementById('wKeyboard').checked : true, }); break; } return config; } document.getElementById('saveWidgetBtn').onclick = async () => { const type = editingWidget?.widget_type || creatingType; const name = document.getElementById('wName').value; const config = getConfigFromForm(type); try { if (editingWidget) { await API(`/widgets/${editingWidget.id}`, { method: 'PUT', body: JSON.stringify({ name, config }) }); } else { await API('/widgets', { method: 'POST', body: JSON.stringify({ widget_type: type, name, config }) }); } document.getElementById('widgetModal').style.display = 'none'; showToast(t('widget.toast.saved'), 'success'); loadWidgets(); } catch (err) { showToast(err.message, 'error'); } }; document.getElementById('previewWidgetBtn').onclick = async () => { const type = editingWidget?.widget_type || creatingType; if (!type) return; const config = getConfigFromForm(type); try { const res = await fetch('/api/widgets/preview-session', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, body: JSON.stringify({ widget_type: type, config }), }); if (!res.ok) throw new Error(t('widget.toast.preview_failed')); const { id } = await res.json(); showPreviewModal(id, type); } catch (err) { showToast(err.message, 'error'); } }; async function loadWidgets() { const widgets = await API('/widgets'); loadedWidgets = Array.isArray(widgets) ? widgets : []; const grid = document.getElementById('widgetGrid'); if (!widgets.length) { grid.innerHTML = `

${t('widget.empty_title')}

${t('widget.empty_desc')}

`; return; } grid.innerHTML = widgets.map(w => { const icon = WIDGET_ICONS[w.widget_type] || '?'; const typeLabel = WIDGET_TYPES.includes(w.widget_type) ? widgetTypeName(w.widget_type) : w.widget_type; return `
${icon}
${escAttr(w.name)}
${escAttr(typeLabel)}
`; }).join(''); grid.onclick = async (e) => { const editBtn = e.target.closest('[data-edit-widget]'); 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; config._name = w.name; showConfigForm(w.widget_type, config); } return; } const deleteBtn = e.target.closest('[data-delete-widget]'); if (deleteBtn) { const w = widgets.find(x => x.id === deleteBtn.dataset.deleteWidget); const label = w ? w.name : t('widget.this_widget'); if (!confirm(t('widget.confirm_delete', { name: label }))) return; try { await API(`/widgets/${deleteBtn.dataset.deleteWidget}`, { method: 'DELETE' }); showToast(t('widget.toast.deleted'), 'success'); loadWidgets(); } catch (err) { showToast(err.message, 'error'); } } }; } loadWidgets(); } export function cleanup() {}