import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { t } from '../i18n.js';
// Background swatches: ids resolve to translated names; values are the actual
// CSS to apply.
const BACKGROUNDS = [
{ id: 'black', value: '#000000' },
{ id: 'dark_blue', value: '#0f172a' },
{ id: 'dark_gradient', value: 'linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%)' },
{ id: 'blue_gradient', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ id: 'sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
{ id: 'ocean', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
{ id: 'forest', value: 'linear-gradient(135deg, #134e5e 0%, #71b280 100%)' },
{ id: 'dark_red', value: 'linear-gradient(135deg, #200122 0%, #6f0000 100%)' },
{ id: 'white', value: '#FFFFFF' },
];
const FONTS = ['Arial', 'Helvetica', 'Georgia', 'Impact', 'Verdana', 'Trebuchet MS', 'Courier New', 'Times New Roman'];
let elements = [];
let selectedIdx = -1;
let bgValue = '#000000';
let bgImageDataUrl = null;
let dragging = null;
let dragStart = null;
export function render(container) {
elements = [];
selectedIdx = -1;
bgValue = '#000000';
bgImageDataUrl = null;
container.innerHTML = `
${t('designer.preview_hint')}
${t('designer.ai.title')}
${t('designer.ai.generate')}
${t('designer.add_element')}
💬 ${t('designer.el.text')}
📜 ${t('designer.el.heading')}
📷 ${t('designer.el.image')}
🎬 ${t('designer.el.video')}
🕓 ${t('designer.el.clock')}
📅 ${t('designer.el.date')}
⛅ ${t('designer.el.weather')}
📰 ${t('designer.el.ticker')}
■ ${t('designer.el.shape')}
▩ ${t('designer.el.qr')}
⏱ ${t('designer.el.countdown')}
🌐 ${t('designer.el.webpage')}
${t('designer.properties')}
${t('common.delete')}
`;
// Background handlers
document.querySelectorAll('[data-bg]').forEach(el => {
el.onclick = () => { bgValue = el.dataset.bg; bgImageDataUrl = null; redraw(); };
});
document.getElementById('bgColor').oninput = (e) => { bgValue = e.target.value; bgImageDataUrl = null; redraw(); };
document.getElementById('bgImageBtn').onclick = () => document.getElementById('bgImageInput').click();
document.getElementById('bgImageInput').onchange = (e) => {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => { bgImageDataUrl = ev.target.result; redraw(); };
reader.readAsDataURL(file);
};
// AI generate (#41): prompt -> validated design spec -> load onto the canvas.
document.getElementById('aiSettingsBtn').onclick = openAiSettings;
const aiGenBtn = document.getElementById('aiGenerateBtn');
aiGenBtn.onclick = async () => {
const prompt = document.getElementById('aiPrompt').value.trim();
const status = document.getElementById('aiStatus');
if (!prompt) { status.textContent = t('designer.ai.need_prompt'); return; }
aiGenBtn.disabled = true; aiGenBtn.textContent = t('designer.ai.generating');
status.textContent = t('designer.ai.contacting');
try {
const design = await api.aiGenerateDesign(prompt);
elements = []; selectedIdx = -1;
if (design.backgroundImage) {
bgImageDataUrl = design.backgroundImage; // AI-generated backdrop
if (design.background) bgValue = design.background; // kept as fallback
} else if (design.background) {
bgValue = design.background; bgImageDataUrl = null;
const bc = document.getElementById('bgColor'); if (bc) bc.value = design.background;
}
(design.elements || []).forEach(el => elements.push(el));
redraw();
status.textContent = design.image_warning
? t('designer.ai.done_imgwarn', { n: (design.elements || []).length })
: t('designer.ai.done', { n: (design.elements || []).length });
} catch (err) {
status.textContent = (err && err.message) || t('designer.ai.failed');
} finally {
aiGenBtn.disabled = false; aiGenBtn.textContent = t('designer.ai.generate');
}
};
// Add element handlers
document.getElementById('addText').onclick = () => addElement({ type: 'text', x: 10, y: 60, text: t('designer.default.text'), fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', bold: false, shadow: false });
document.getElementById('addHeading').onclick = () => addElement({ type: 'text', x: 5, y: 5, text: t('designer.default.heading'), fontSize: 64, fontFamily: 'Impact', color: '#FFFFFF', bold: true, shadow: true });
document.getElementById('addImage').onclick = () => {
const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*';
input.onchange = () => {
const reader = new FileReader();
reader.onload = (ev) => addElement({ type: 'image', x: 10, y: 10, width: 30, height: 30, src: ev.target.result });
reader.readAsDataURL(input.files[0]);
};
input.click();
};
document.getElementById('addVideo').onclick = () => {
const url = prompt(t('designer.prompt.video_url'));
if (url) addElement({ type: 'video', x: 5, y: 5, width: 50, height: 50, src: url, muted: true, loop: true });
};
document.getElementById('addClock').onclick = () => addElement({ type: 'clock', x: 60, y: 5, fontSize: 48, fontFamily: 'Arial', color: '#FFFFFF', format: '12h', showSeconds: true, shadow: true });
document.getElementById('addDate').onclick = () => addElement({ type: 'date', x: 60, y: 20, fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', shadow: false });
document.getElementById('addWeather').onclick = () => {
const location = prompt(t('designer.prompt.weather_location'), 'Milwaukee, WI');
if (location) addElement({ type: 'weather', x: 5, y: 70, fontSize: 36, color: '#FFFFFF', location, units: 'imperial' });
};
document.getElementById('addTicker').onclick = () => {
const url = prompt(t('designer.prompt.rss_url'), 'https://feeds.bbci.co.uk/news/rss.xml');
if (url) addElement({ type: 'ticker', x: 0, y: 90, width: 100, height: 10, feedUrl: url, speed: 30, fontSize: 20, color: '#FFFFFF', bgColor: 'rgba(0,0,0,0.7)' });
};
document.getElementById('addShape').onclick = () => addElement({ type: 'shape', x: 20, y: 20, width: 30, height: 20, color: '#3b82f6', opacity: 0.7, radius: 8, shape: 'rect' });
document.getElementById('addQR').onclick = () => {
const data = prompt(t('designer.prompt.qr_url'), 'https://example.com');
if (data) addElement({ type: 'qr', x: 80, y: 70, size: 15, data, fgColor: '#FFFFFF', bgColor: '#000000' });
};
document.getElementById('addCountdown').onclick = () => {
const target = prompt(t('designer.prompt.countdown_date'), '2026-04-01');
if (target) addElement({ type: 'countdown', x: 20, y: 40, fontSize: 48, color: '#FFFFFF', targetDate: target, label: t('designer.default.coming_soon') });
};
document.getElementById('addWebpage').onclick = () => {
const url = prompt(t('designer.prompt.webpage_url'));
if (url) addElement({ type: 'webpage', x: 5, y: 5, width: 40, height: 40, url });
};
document.getElementById('deleteEl').onclick = () => { if (selectedIdx >= 0) { elements.splice(selectedIdx, 1); selectedIdx = -1; redraw(); } };
// Publish as dynamic HTML content
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 } })
});
if (res.ok) showToast(t('designer.toast.published'), 'success');
else showToast(t('designer.toast.publish_failed'), 'error');
} catch (err) { showToast(err.message, 'error'); }
};
// Export PNG screenshot
document.getElementById('exportPngBtn').onclick = async () => {
try {
const preview = document.getElementById('designPreview');
// Use a canvas to capture
const canvas = document.createElement('canvas');
canvas.width = 1920; canvas.height = 1080;
const ctx = canvas.getContext('2d');
// Draw background
if (bgImageDataUrl) {
const img = new Image(); img.src = bgImageDataUrl;
await new Promise(r => { img.onload = r; });
ctx.drawImage(img, 0, 0, 1920, 1080);
} else if (bgValue.startsWith('linear')) {
const colors = bgValue.match(/#[a-f0-9]{6}/gi) || ['#000'];
const grad = ctx.createLinearGradient(0, 0, 1920, 1080);
colors.forEach((c, i) => grad.addColorStop(i / Math.max(1, colors.length - 1), c));
ctx.fillStyle = grad; ctx.fillRect(0, 0, 1920, 1080);
} else { ctx.fillStyle = bgValue; ctx.fillRect(0, 0, 1920, 1080); }
// Draw text elements
for (const el of elements) {
if (el.type === 'text' || el.type === 'clock' || el.type === 'date' || el.type === 'countdown') {
ctx.save();
ctx.font = `${el.bold ? 'bold ' : ''}${(el.fontSize / 100) * 1080}px ${el.fontFamily || 'Arial'}`;
ctx.fillStyle = el.color || '#FFF';
if (el.shadow) { ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 8; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 2; }
let text = el.text || el.label || '';
if (el.type === 'clock') text = new Date().toLocaleTimeString();
if (el.type === 'date') text = new Date().toLocaleDateString();
ctx.fillText(text, (el.x / 100) * 1920, (el.y / 100) * 1080 + (el.fontSize / 100) * 1080);
ctx.restore();
} else if (el.type === 'shape') {
ctx.save();
ctx.globalAlpha = el.opacity || 1;
ctx.fillStyle = el.color;
ctx.fillRect((el.x / 100) * 1920, (el.y / 100) * 1080, (el.width / 100) * 1920, (el.height / 100) * 1080);
ctx.restore();
}
}
const link = document.createElement('a');
link.download = 'signage-design.png'; link.href = canvas.toDataURL('image/png'); link.click();
} catch (err) { showToast(t('designer.toast.export_failed', { error: err.message }), 'error'); }
};
// Load saved design
document.getElementById('loadDesignBtn').onclick = () => {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json';
input.onchange = () => {
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
elements = data.elements || [];
bgValue = data.bgValue || '#000';
bgImageDataUrl = data.bgImageDataUrl || null;
redraw();
showToast(t('designer.toast.loaded'), 'success');
} catch { showToast(t('designer.toast.invalid_file'), 'error'); }
};
reader.readAsText(input.files[0]);
};
input.click();
};
// Mouse interaction on preview
const preview = document.getElementById('designPreview');
preview.onmousedown = (e) => {
const rect = preview.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * 100;
const py = ((e.clientY - rect.top) / rect.height) * 100;
selectedIdx = -1;
for (let i = elements.length - 1; i >= 0; i--) {
const el = elements[i];
const b = getBounds(el);
if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) {
selectedIdx = i;
dragging = el;
dragStart = { px, py, ox: el.x, oy: el.y };
break;
}
}
redraw();
};
preview.onmousemove = (e) => {
if (!dragging || !dragStart) return;
const rect = preview.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * 100;
const py = ((e.clientY - rect.top) / rect.height) * 100;
dragging.x = Math.max(0, Math.min(95, dragStart.ox + (px - dragStart.px)));
dragging.y = Math.max(0, Math.min(95, dragStart.oy + (py - dragStart.py)));
redraw();
};
preview.onmouseup = () => { dragging = null; dragStart = null; };
redraw();
}
function addElement(el) {
elements.push(el);
selectedIdx = elements.length - 1;
redraw();
}
// #41: per-workspace AI endpoint config (BYO OpenAI-compatible endpoint + key).
async function openAiSettings() {
let cur = {};
try { cur = await api.aiGetSettings(); } catch { /* show empty form */ }
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelectorAll('[data-ai-close]').forEach(b => b.onclick = close);
overlay.onclick = (e) => { if (e.target === overlay) close(); };
// Load the model list from the entered endpoint into the dropdown.
overlay.querySelector('#aiLoadModels').onclick = async () => {
const msg = overlay.querySelector('#aiModelMsg');
const base_url = overlay.querySelector('#aiBaseUrl').value.trim();
if (!base_url) { msg.style.color = 'var(--danger)'; msg.textContent = t('designer.ai.need_base_url'); return; }
const btn = overlay.querySelector('#aiLoadModels');
btn.disabled = true;
msg.style.color = 'var(--text-muted)'; msg.textContent = t('designer.ai.loading_models');
try {
const r = await api.aiListModels(base_url, overlay.querySelector('#aiKey').value || undefined);
const models = r.models || [];
overlay.querySelector('#aiModelList').innerHTML = models.map(m => ` `).join('');
const modelInput = overlay.querySelector('#aiModel');
if (models.length && !modelInput.value) modelInput.value = models[0];
msg.textContent = t('designer.ai.models_loaded', { n: models.length });
} catch (e2) {
msg.style.color = 'var(--danger)'; msg.textContent = (e2 && e2.message) || t('designer.ai.models_failed');
} finally {
btn.disabled = false;
}
};
overlay.querySelector('#aiSaveSettings').onclick = async () => {
const errEl = overlay.querySelector('#aiSettingsErr');
errEl.style.display = 'none';
const data = {
base_url: overlay.querySelector('#aiBaseUrl').value.trim(),
model: overlay.querySelector('#aiModel').value.trim(),
image_provider: overlay.querySelector('#aiImageProvider').value,
image_base_url: overlay.querySelector('#aiImageBaseUrl').value.trim(),
image_model: overlay.querySelector('#aiImageModel').value.trim(),
};
const key = overlay.querySelector('#aiKey').value;
if (key) data.api_key = key;
try {
await api.aiSaveSettings(data);
showToast(t('designer.ai.saved'), 'success');
close();
} catch (e2) {
errEl.textContent = (e2 && e2.message) || t('designer.ai.save_failed');
errEl.style.display = 'block';
}
};
}
function getBounds(el) {
const w = el.width || el.size || (el.fontSize ? el.fontSize * 0.6 * (el.text?.length || 8) / 100 * 100 : 20);
const h = el.height || el.size || (el.fontSize ? el.fontSize * 1.2 / 100 * 100 : 10);
return { x: el.x, y: el.y, w: Math.min(w, 100), h: Math.min(h, 100) };
}
function redraw() {
const preview = document.getElementById('designPreview');
if (!preview) return;
let html = '';
// Background
if (bgImageDataUrl) {
preview.style.background = `url(${bgImageDataUrl}) center/cover`;
} else {
preview.style.background = bgValue;
}
// Elements
elements.forEach((el, i) => {
const selected = i === selectedIdx;
const border = selected ? 'outline:2px solid #3b82f6;outline-offset:2px;' : '';
const cursor = 'cursor:move;';
switch (el.type) {
case 'text':
html += `${el.text}
`;
break;
case 'clock':
html += `
`;
break;
case 'date':
html += `
`;
break;
case 'image':
html += ` `;
break;
case 'video':
html += ` `;
break;
case 'shape':
html += `
`;
break;
case 'weather':
html += `⛅ ${t('common.loading')}
`;
break;
case 'ticker':
html += `
${t('designer.loading_news')}
`;
break;
case 'qr':
html += `
${t('designer.qr_label')}
${el.data?.slice(0, 25)}
`;
break;
case 'countdown':
html += ``;
break;
case 'webpage':
html += ``;
break;
}
});
// Add ticker animation CSS
html += ``;
preview.innerHTML = html;
// Update dynamic elements
updateDynamic();
// Update properties panel
updateProps();
updateLayers();
}
function updateDynamic() {
elements.forEach((el, i) => {
if (el.type === 'clock') {
const clockEl = document.getElementById(`clock_${i}`);
if (clockEl) {
const update = () => {
const opts = { hour: '2-digit', minute: '2-digit' };
if (el.showSeconds) opts.second = '2-digit';
opts.hour12 = el.format !== '24h';
clockEl.textContent = new Date().toLocaleTimeString('en-US', opts);
};
update();
// Only set interval if element still exists
const iv = setInterval(() => { if (document.getElementById(`clock_${i}`)) update(); else clearInterval(iv); }, 1000);
}
}
if (el.type === 'date') {
const dateEl = document.getElementById(`date_${i}`);
if (dateEl) dateEl.textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
if (el.type === 'countdown') {
const cdEl = document.getElementById(`countdown_${i}`);
if (cdEl && el.targetDate) {
const update = () => {
const diff = new Date(el.targetDate) - new Date();
if (diff <= 0) { cdEl.textContent = t('designer.countdown_now'); return; }
const days = Math.floor(diff / 86400000);
const hours = Math.floor((diff % 86400000) / 3600000);
const mins = Math.floor((diff % 3600000) / 60000);
cdEl.textContent = `${days}d ${hours}h ${mins}m`;
};
update();
const iv = setInterval(() => { if (document.getElementById(`countdown_${i}`)) update(); else clearInterval(iv); }, 60000);
}
}
if (el.type === 'weather') {
const wEl = document.getElementById(`weather_${i}`);
if (wEl && el.location) {
fetch(`https://wttr.in/${encodeURIComponent(el.location)}?format=j1`).then(r => r.json()).then(d => {
const cur = d.current_condition?.[0];
if (cur) {
const temp = el.units === 'metric' ? cur.temp_C + '°C' : cur.temp_F + '°F';
wEl.textContent = `${temp} ${cur.weatherDesc?.[0]?.value || ''}`;
}
}).catch(() => { wEl.textContent = '⛅ ' + el.location; });
}
}
if (el.type === 'ticker') {
const tEl = document.getElementById(`ticker_${i}`);
if (tEl && el.feedUrl) {
fetch(`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(el.feedUrl)}`).then(r => r.json()).then(d => {
tEl.textContent = (d.items || []).map(item => item.title).join(' • ') || t('designer.no_items');
}).catch(() => { tEl.textContent = t('designer.feed_unavailable'); });
}
}
});
}
function updateProps() {
const panel = document.getElementById('propPanel');
const fields = document.getElementById('propFields');
if (selectedIdx < 0 || !elements[selectedIdx]) { panel.style.display = 'none'; return; }
panel.style.display = 'block';
const el = elements[selectedIdx];
let html = '';
// Common position
html += ``;
if (el.type === 'text') {
html += `${t('designer.prop.text')}
${t('designer.prop.size')} ${el.fontSize}px
${t('designer.prop.font')} ${FONTS.map(f => `${f} `).join('')}
${t('designer.prop.color')}
${t('designer.prop.bold')}
${t('designer.prop.shadow')} `;
} else if (el.type === 'clock') {
html += `${t('designer.prop.size')}
${t('designer.prop.color')}
${t('designer.prop.format')} 12h 24h
${t('designer.prop.show_seconds')} `;
} else if (el.type === 'image' || el.type === 'video' || el.type === 'webpage') {
html += ``;
if (el.type === 'video') html += ` ${t('designer.prop.muted')}
${t('designer.prop.loop')} `;
} else if (el.type === 'shape') {
html += `
${t('designer.prop.color')}
${t('designer.prop.opacity')}
${t('designer.prop.shape')} rect circle
`;
} else if (el.type === 'weather') {
html += `${t('designer.prop.location')}
${t('designer.prop.size')}
${t('designer.prop.color')}
`;
} else if (el.type === 'ticker') {
html += `${t('designer.prop.feed_url')}
${t('designer.prop.speed')}
${t('designer.prop.text_color')}
${t('designer.prop.bg_color')}
`;
} else if (el.type === 'countdown') {
html += `${t('designer.prop.target_date')}
${t('designer.prop.label')}
${t('designer.prop.size')}
${t('designer.prop.color')}
`;
}
// Save design button
html += `${t('designer.save_design_file')} `;
fields.innerHTML = html;
fields.querySelectorAll('[data-prop]').forEach(input => {
const handler = () => {
const prop = input.dataset.prop;
if (input.type === 'checkbox') el[prop] = input.checked;
else if (input.type === 'number' || input.type === 'range') el[prop] = parseFloat(input.value);
else el[prop] = input.value;
redraw();
};
input.oninput = handler;
input.onchange = handler;
});
}
function updateLayers() {
const list = document.getElementById('layerList');
if (!list) return;
const typeIcons = { text: '💬', clock: '🕓', date: '📅', image: '📷', video: '🎬', shape: '■', weather: '⛅', ticker: '📰', qr: '▩', countdown: '⏱', webpage: '🌐' };
list.innerHTML = elements.map((el, i) => `
${typeIcons[el.type] || '?'}
${el.text || el.type}
`).join('') || `${t('designer.no_elements')}
`;
list.querySelectorAll('[data-layer]').forEach(el => {
el.onclick = () => { selectedIdx = parseInt(el.dataset.layer); redraw(); };
});
}
function generateInnerHTML() {
let html = '';
// A background image (e.g. AI-generated) is the body background in the editor;
// bake it into the published HTML as a full-cover bottom layer so it survives.
if (bgImageDataUrl) html += ` `;
elements.forEach((el, i) => {
// Use vw units for font sizes (same as designer preview) so output scales to any viewport
const fs = el.fontSize / 10;
const fsLabel = el.fontSize / 15;
switch (el.type) {
case 'text':
html += `${el.text}
`;
break;
case 'clock':
html += `
`;
break;
case 'date':
html += `
`;
break;
case 'image':
html += ` `;
break;
case 'video':
html += ` `;
break;
case 'shape':
html += `
`;
break;
case 'weather':
html += `Loading...
`;
break;
case 'ticker':
html += `
`;
break;
case 'countdown':
html += `
`;
break;
case 'webpage':
html += ``;
break;
}
});
return html;
}
function generateHTML() {
return `${generateInnerHTML()}`;
}
export function cleanup() {}