import { showToast } from '../components/toast.js'; import { t } from '../i18n.js'; const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => 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']; const WIDGET_ICONS = { clock: '🕓', weather: '⛅', rss: '📰', text: '📝', webpage: '🌐', social: '💬', 'directory-board': '🏢', }; 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, '>'); } 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 thumb = c.remote_url || `/api/content/${c.id}/thumbnail`; return `
${escAttr(c.filename)}
${isSel ? '
' : ''}
`; }).join('') }
`; 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(html) { 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'; overlay.innerHTML = `
${t('widget.preview_title')}
`; document.body.appendChild(overlay); // srcdoc resolves relative URLs against about:srcdoc, so inject pointing to our origin const baseTag = ``; const withBase = /]*>/i.test(html) ? html.replace(/]*)>/i, `${baseTag}`) : html.replace(/]*)>/i, `${baseTag}`); overlay.querySelector('#pvIframe').srcdoc = withBase; 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: [] }; 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.bg_images_hint')}
`; break; } document.getElementById('widgetConfigForm').innerHTML = html; const modalEl = document.querySelector('#widgetModal .modal'); if (modalEl) modalEl.style.width = type === 'directory-board' ? '720px' : '560px'; document.getElementById('widgetModal').style.display = 'flex'; 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; } } 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 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; } } 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('') }
`; 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 '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; } 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', { 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 html = await res.text(); showPreviewModal(html); } catch (err) { showToast(err.message, 'error'); } }; async function loadWidgets() { const widgets = await API('/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) { editingWidget = w; creatingType = w.widget_type; const config = JSON.parse(w.config || '{}'); 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() {}