import { showToast } from '../components/toast.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());
const WIDGET_TYPES = [
{ id: 'clock', name: 'Clock', icon: '🕓', desc: 'Digital clock with date' },
{ id: 'weather', name: 'Weather', icon: '⛅', desc: 'Current weather conditions' },
{ id: 'rss', name: 'News Ticker', icon: '📰', desc: 'Scrolling RSS feed' },
{ id: 'text', name: 'Text/HTML', icon: '📝', desc: 'Custom text or HTML content' },
{ id: 'webpage', name: 'Webpage', icon: '🌐', desc: 'Embed a webpage' },
{ id: 'social', name: 'Social Feed', icon: '💬', desc: 'Social media feed' },
{ id: 'directory-board', name: 'Directory Board', icon: '🏢', desc: 'Scrolling tenant/room directory for lobbies' },
];
function escAttr(s) {
return String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>');
}
function openContentPicker({ multiple = false, title = 'Select Image' } = {}) {
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}
${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 = `${selected.size} selected`;
};
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 ? 'No matches.' : 'No images in your content library. Upload images first from Content Library.'}
`;
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 = `
`;
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 = WIDGET_TYPES.find(t => t.id === type)?.name || type;
document.getElementById('widgetModalTitle').textContent = editingWidget ? `Edit ${typeName}` : `New ${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 += `
`;
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 = 'Add your first floor or department to get started
';
return;
}
cont.innerHTML = dirState.categories.map((cat, i) => {
const entryRows = (cat.entries || []).map((e, j) => `
`).join('');
return `
${cat.entries.length} ${cat.entries.length === 1 ? 'entry' : 'entries'}
${cat._expanded ? `
${entryRows || '
No entries yet
'}
` : ''}
`;
}).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 || '(unnamed)';
if (!confirm(`Delete category "${label}" and all its entries?`)) 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: '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 = 'No background images selected
';
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: 'Select Background 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('Widget 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('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 = 'No widgets yet
Create a widget to add dynamic content to your layouts.
';
return;
}
grid.innerHTML = widgets.map(w => {
const typeMeta = WIDGET_TYPES.find(t => t.id === w.widget_type) || {};
return `
${typeMeta.icon || '?'}
${escAttr(w.name)}
${escAttr(typeMeta.name || w.widget_type)}
`;
}).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 : 'this widget';
if (!confirm(`Delete "${label}"? This cannot be undone.`)) return;
try {
await API(`/widgets/${deleteBtn.dataset.deleteWidget}`, { method: 'DELETE' });
showToast('Widget deleted', 'success');
loadWidgets();
} catch (err) { showToast(err.message, 'error'); }
}
};
}
loadWidgets();
}
export function cleanup() {}