96 lines
6.5 KiB
JavaScript
96 lines
6.5 KiB
JavaScript
/* Dona Visual Context v1 — safe DOM-only renderer, no HTML from Hermes. */
|
||
(() => {
|
||
"use strict";
|
||
const EVENT = 'dona_visual_card';
|
||
const ACCEPT = new Set(['action_state', 'recipe', 'media', 'weather', 'calendar', 'info']);
|
||
const PATH = (document.currentScript && document.currentScript.src) || '';
|
||
const SVG = {
|
||
'lightbulb-on': '<path d="M9 18h6m-5 4h4m-5-7a7 7 0 1 1 6 0c-.6.5-1 1.3-1 2H10c0-.7-.4-1.5-1-2Z"/><path d="M12 1v2M4 4l2 2M20 4l-2 2M1 12h2M21 12h2"/>',
|
||
'lightbulb-off': '<path d="M9 18h6m-5 4h4M5 5l14 14M10 15c-2.8-2.8-3.4-6.2-.8-9.1 3.1-3 7.7-2 9.3 1.4"/><path d="M12 1v2M1 12h2M21 12h2"/>',
|
||
'chef-hat': '<path d="M6 13a4 4 0 0 1-2-7 4 4 0 0 1 6-2 4.5 4.5 0 0 1 8 2 4 4 0 0 1 0 7v7H6zM6 16h12"/>',
|
||
'music': '<path d="M10 19V5l11-2v14M10 9l11-2"/><ellipse cx="7" cy="19" rx="3" ry="2"/><ellipse cx="18" cy="17" rx="3" ry="2"/>',
|
||
'cloud-sun': '<circle cx="18" cy="6" r="3"/><path d="M18 1v1M18 10v1M13 6h1M22 6h1M4 18a5 5 0 0 1 5-5 6 6 0 0 1 11 3 4 4 0 0 1-1 7H7a4 4 0 0 1-3-5Z"/>',
|
||
'calendar': '<rect x="3" y="5" width="18" height="17" rx="2"/><path d="M7 2v6M17 2v6M3 11h18"/>',
|
||
'check': '<path d="m5 12 4 4L19 6"/>',
|
||
'power': '<path d="M12 2v10M5 5a10 10 0 1 0 14 0"/>',
|
||
'alert': '<path d="M12 2 1 22h22L12 2Zm0 8v5m0 3v1"/>',
|
||
'thermometer': '<path d="M10 14V5a3 3 0 0 1 6 0v9a5 5 0 1 1-6 0Z"/>',
|
||
'lock': '<rect x="5" y="10" width="14" height="12" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
|
||
'unlock': '<rect x="5" y="10" width="14" height="12" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0"/>',
|
||
'blinds-open': '<rect x="3" y="3" width="18" height="19" rx="2"/><path d="M3 8h18M3 13h18"/>',
|
||
'blinds-closed': '<rect x="3" y="3" width="18" height="19" rx="2"/><path d="M3 8h18M3 13h18M3 18h18"/>',
|
||
'info': '<circle cx="12" cy="12" r="10"/><path d="M12 11v6M12 7v1"/>'
|
||
};
|
||
const el = (tag, cls, txt) => { const x = document.createElement(tag); if (cls) x.className = cls; if (txt !== undefined) x.textContent = String(txt); return x; };
|
||
const safeUrl = value => {
|
||
if (typeof value !== 'string' || value.length > 2048) return null;
|
||
try {
|
||
const u = new URL(value, location.href);
|
||
if (u.origin === location.origin && value.startsWith('/') && !value.startsWith('//')) return u.href;
|
||
if (u.protocol === 'https:' && ['upload.wikimedia.org', 'commons.wikimedia.org'].includes(u.hostname)) return u.href;
|
||
} catch (_e) { /* ignore */ }
|
||
return null;
|
||
};
|
||
function dock() {
|
||
let node = document.getElementById('dona-visual-dock');
|
||
if (!node) {node = el('aside'); node.id = 'dona-visual-dock'; node.setAttribute('aria-live', 'polite'); node.setAttribute('aria-label', 'Illustrations Dona'); document.body.appendChild(node);}
|
||
return node;
|
||
}
|
||
function addSection(card, heading, values, ordered) {
|
||
if (!Array.isArray(values) || !values.length) return;
|
||
const sec = el('div', 'dvc-section'), strong = el('strong', '', heading), list = el(ordered ? 'ol' : 'ul');
|
||
sec.appendChild(strong); values.slice(0, ordered ? 15 : 20).forEach(item => {
|
||
if (typeof item === 'string') list.appendChild(el('li', '', item));
|
||
}); sec.appendChild(list); card.appendChild(sec);
|
||
}
|
||
function imageFor(card, image) {
|
||
if (!image || typeof image !== 'object') return;
|
||
const img = el('img', 'dvc-image'); img.alt = String(image.alt || 'Illustration').slice(0, 140); img.loading = 'lazy';
|
||
img.referrerPolicy = 'no-referrer';
|
||
const apply = url => {const safe = safeUrl(url); if (safe) {img.src = safe; img.onerror = () => img.removeAttribute('src');}};
|
||
if (image.source === 'web' && image.query) {
|
||
const endpoint = '/api/dona-visuals/image?q=' + encodeURIComponent(String(image.query).slice(0, 80));
|
||
fetch(endpoint, {credentials: 'same-origin'}).then(r => r.ok ? r.json() : null).then(data => {
|
||
if (!data || !img.isConnected) return;
|
||
apply(data.url);
|
||
if (data.source_url) {
|
||
const source = safeUrl(data.source_url);
|
||
if (source) { const a = el('a', '', data.attribution || 'Source : Wikimedia Commons'); a.href = source; a.target = '_blank'; a.rel = 'noopener noreferrer'; card.appendChild(a); }
|
||
}
|
||
}).catch(() => {});
|
||
} else if (image.url && ['local', 'comfyui', 'web'].includes(image.source)) apply(image.url);
|
||
card.appendChild(img);
|
||
}
|
||
function render(payload) {
|
||
if (!payload || payload.schema !== 'dona.visual.v1' || !ACCEPT.has(payload.type) || typeof payload.title !== 'string') return;
|
||
const root = dock(); root.replaceChildren(); // one context card; no pile of overlays
|
||
const card = el('section', 'dvc-card'); card.dataset.status = String(payload.status || 'informational');
|
||
const close = el('button', 'dvc-close', '×'); close.type = 'button'; close.setAttribute('aria-label', 'Fermer la carte'); close.addEventListener('click', () => root.replaceChildren()); card.appendChild(close);
|
||
const head = el('div', 'dvc-head'), icon = el('div', 'dvc-icon'), svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true');
|
||
svg.innerHTML = SVG[payload.icon] || SVG.info; // only trusted local constant strings
|
||
icon.appendChild(svg); head.appendChild(icon);
|
||
const texts = el('div'); texts.appendChild(el('h3', 'dvc-title', payload.title));
|
||
if (payload.subtitle) texts.appendChild(el('p', 'dvc-subtitle', payload.subtitle));
|
||
head.appendChild(texts); card.appendChild(head);
|
||
imageFor(card, payload.image);
|
||
if (payload.duration) card.appendChild(el('div', 'dvc-meta', 'Durée : ' + payload.duration));
|
||
addSection(card, 'Ingrédients', payload.ingredients, false);
|
||
addSection(card, 'Préparation', payload.steps, true);
|
||
addSection(card, 'Détails', payload.details, false);
|
||
root.appendChild(card);
|
||
if (payload.type === 'action_state') window.setTimeout(() => { if (root.contains(card)) card.remove(); }, 13000);
|
||
}
|
||
window.DonaVisuals = Object.freeze({render});
|
||
function init() {
|
||
if (typeof window.io === 'function') {
|
||
try { const socket = window.io(); socket.on(EVENT, render); }
|
||
catch (err) { console.warn('[DONA-VISUAL] Cannot attach socket:', err); }
|
||
} else console.warn('[DONA-VISUAL] Socket.IO client not loaded');
|
||
const params = new URLSearchParams(location.search);
|
||
const demo = params.get('dona_visual_demo');
|
||
if (['light', 'recipe'].includes(demo)) fetch('/api/dona-visuals/demo/' + demo).then(r => r.json()).then(render).catch(() => {});
|
||
}
|
||
if (document.readyState === 'complete') init(); else window.addEventListener('load', init, {once: true});
|
||
})();
|