V1
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
/* Pop-up des réglages : ne modifie pas la page configuration historique. */
|
||||
.audio-popover { position:absolute; top:82px; right:36px; z-index:10; width:min(355px,calc(100vw - 28px)); background:#252a2e; border:1px solid #ffffff2b; border-radius:17px; padding:20px; box-shadow:0 24px 70px #0008; }
|
||||
.audio-popover[hidden] { display:none !important; }
|
||||
.popover-heading { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; font-size:16px; }
|
||||
.icon-button.mini { width:28px; height:28px; font-size:22px; border:0; background:none; }
|
||||
.setting-row { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:19px; margin-bottom:12px; color:#d5dbe0; font-size:13px; }
|
||||
.setting-row output { color:#f4f5f6; font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.audio-popover input[type="range"] { display:block; width:100%; margin:0 0 5px; cursor:pointer; accent-color:#a8c7ce; }
|
||||
.audio-popover input[type="range"]:disabled { opacity:.35; cursor:not-allowed; }
|
||||
.setting-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-top:22px; padding-top:16px; border-top:1px solid #ffffff1c; color:#cbd1d5; font-size:13px; }
|
||||
.quiet-button { color:#c3e7dc; background:#42675b33; border:1px solid #83c9ae44; padding:7px 13px; border-radius:8px; cursor:pointer; }
|
||||
.quiet-button[aria-pressed="true"] { color:#f7b5b5; background:#b65c5c26; border-color:#b65c5c66; }
|
||||
.settings-message { min-height:14px; margin:12px 0 0; font-size:11px; color:#e4c089; line-height:1.4; }
|
||||
@media(max-width:760px) { .audio-popover { top:65px; right:14px; } }
|
||||
|
||||
/* Bouton mute microphone dans la barre supérieure. aria-pressed reflète l'état réel de l'API. */
|
||||
.mic-off-icon { display: none; }
|
||||
#micMute[aria-pressed="true"] .mic-on-icon { display: none; }
|
||||
#micMute[aria-pressed="true"] .mic-off-icon { display: block; }
|
||||
@@ -0,0 +1,13 @@
|
||||
.alfred-mode-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
transition: transform .25s ease, opacity .25s ease;
|
||||
}
|
||||
.alfred-mode-icon[data-mode="thinking"] { animation: alfred-thinking 1.8s ease-in-out infinite; }
|
||||
@keyframes alfred-thinking { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.12); } }
|
||||
@media (prefers-reduced-motion: reduce) { .alfred-mode-icon { animation: none !important; } }
|
||||
@@ -0,0 +1,54 @@
|
||||
/* Calendar and Home Assistant views — preserves original dark Alfred layout */
|
||||
#stage:has(.alfred-view) {padding:0 0 6px;justify-content:stretch;align-items:stretch}
|
||||
#content.alfred-view {width:100%;height:100%;text-align:left;min-height:0;}
|
||||
.view-pane {display:flex;flex-direction:column;gap:12px;max-height:100%;height:100%;min-height:0;padding:8px 1px 14px;}
|
||||
.view-head {display:flex;justify-content:space-between;align-items:center;gap:12px;}
|
||||
#content .view-title {font-size:clamp(1.2rem,1.7vw,1.85rem);margin:0;font-weight:500}
|
||||
.view-close,.view-button,.view-pill {background:#363d42;color:#f3f7fa;border:1px solid #ffffff24;border-radius:10px;padding:9px 14px;cursor:pointer;font:inherit}
|
||||
.view-close {font-size:1.4rem;min-width:40px;height:40px;padding:0}
|
||||
.view-button:hover,.view-pill:hover,.view-close:hover {background:#455057}
|
||||
.view-pill.active,.view-button.primary {background:#427789;border-color:#619aad}
|
||||
.view-toolbar {display:flex;gap:9px;align-items:center;flex-wrap:wrap;}
|
||||
.view-nav,.view-modes {display:flex;gap:5px;align-items:center}
|
||||
.view-period {font-size:.85rem;min-width:215px;text-align:center;color:#d8e0e4}
|
||||
.view-select {padding:9px;max-width:220px;border-radius:9px;background:#333a3e;color:#eff3f5;border:1px solid #ffffff2b}
|
||||
.agenda-grid {display:grid;gap:5px;min-height:0;flex:1;overflow:auto;padding:2px;align-content:stretch;}
|
||||
.agenda-seven,.agenda-month {grid-template-columns:repeat(7,minmax(0,1fr))}
|
||||
.agenda-month {grid-template-rows:repeat(6,minmax(110px,1fr));}
|
||||
.agenda-three {grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.agenda-one {grid-template-columns:1fr}
|
||||
.agenda-day {border:1px solid #ffffff1c;border-radius:11px;min-width:0;min-height:120px;background:#ffffff08;padding:9px;display:flex;flex-direction:column;gap:6px;overflow-y:auto}
|
||||
.agenda-day.is-today {border-color:#86bfda7a;background:#57839923}
|
||||
.agenda-day.out-month {opacity:.42}
|
||||
.agenda-date {font-size:.83rem;font-weight:600;color:#dce2e6;letter-spacing:.01em;padding-bottom:7px;border-bottom:1px solid #ffffff14}
|
||||
.agenda-event {width:100%;text-align:left;background:color-mix(in srgb,var(--event-color) 17%,#2a2e32);color:#f1f3f5;border:0;border-left:3px solid var(--event-color);border-radius:5px;padding:7px 5px;font-size:clamp(.72rem,.85vw,.95rem);line-height:1.3;cursor:pointer;overflow-wrap:anywhere}
|
||||
.agenda-event:hover {background:color-mix(in srgb,var(--event-color) 30%,#2a2e32)}
|
||||
.agenda-empty {font-size:.8rem;color:#667781;margin:auto}
|
||||
#content .view-footer {color:#9aa8b3;font-size:.76rem;margin:0}
|
||||
#content .view-error {color:#f7adad;font-size:.85rem;margin:0}
|
||||
#content .view-empty {font-size:1rem;color:#b8c2ca}
|
||||
.agenda-form {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;gap:14px;max-width:850px;width:100%;overflow-y:auto;margin:12px auto;}
|
||||
.form-field {display:flex;flex-direction:column;gap:7px;color:#cfd6dd;font-size:.83rem}
|
||||
.form-field input,.form-field select,.form-field textarea {padding:12px;border-radius:10px;color:#eff3f5;background:#2e3439;border:1px solid #ffffff32;font:inherit;min-width:0}
|
||||
.form-field textarea {resize:vertical}
|
||||
.form-check {display:flex;align-items:center;gap:10px}
|
||||
.view-actions {display:flex;align-items:center;gap:10px;justify-content:flex-end;flex-wrap:wrap;}
|
||||
.form-message {grid-column:1 / -1;}
|
||||
.agenda-form .view-actions {grid-column:1 / -1}
|
||||
.view-danger {color:#ffc4c4;background:#8f383850;border-color:#ed929255}
|
||||
.detail-modal {position:fixed;inset:0;z-index:30;display:grid;place-items:center;background:#000b;padding:20px}
|
||||
.detail-box {min-width:min(92vw,300px);max-width:540px;width:100%;border:1px solid #ffffff36;border-radius:19px;background:#292e33;box-shadow:0 25px 90px #000a;padding:24px;}
|
||||
.detail-box h2 {font-size:1.4rem;margin:0 0 20px}.detail-box p {font-size:1rem;color:#d7dfe2;overflow-wrap:anywhere}
|
||||
.delete-list {display:grid;gap:8px;overflow:auto}.delete-row {display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid #ffffff1c;border-radius:9px;padding:12px}
|
||||
.temperature-grid {display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:4px;max-height:53%;}
|
||||
.temperature-card {border:1px solid #ffffff22;background:#ffffff0a;border-radius:12px;padding:22px;color:#eaf1f3;white-space:pre-line;text-align:left;font:inherit;cursor:pointer;line-height:1.9}
|
||||
.temperature-card:hover {background:#ffffff18}
|
||||
.temperature-history {max-width:700px;text-align:center;margin:auto;width:100%;}
|
||||
.temperature-history svg {width:100%;max-height:155px}
|
||||
.temperature-history h2 {font-size:1.1rem;font-weight:450}
|
||||
.ha-cameras {display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;overflow:auto;}
|
||||
.ha-camera-card {background:#ffffff0a;border-radius:13px;overflow:hidden;}
|
||||
.ha-camera-card h2 {font-size:.95rem;padding:10px;font-weight:500;margin:0}
|
||||
.ha-camera-card img {display:block;width:100%;aspect-ratio:16/9;object-fit:contain;background:#121619}
|
||||
@media(max-width:1100px){.view-toolbar{gap:6px}.view-button,.view-pill {padding:7px;font-size:.77rem}.view-period{min-width:150px}.agenda-day{padding:5px}.agenda-event{padding:4px;font-size:.74rem}}
|
||||
@media(max-width:760px){#stage:has(.alfred-view){min-height:460px}.agenda-grid{min-height:280px}.agenda-seven,.agenda-month{min-width:630px}.agenda-day{min-height:105px}.view-pane{max-height:600px}.view-head{padding:0 4px}.agenda-form{grid-template-columns:1fr}.header-actions{gap:3px}.header-actions .icon-button {width:38px;height:38px}.view-toolbar{overflow:auto}}
|
||||
@@ -0,0 +1,62 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
color: #f1f3f4;
|
||||
background: #202326;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display:none !important; }
|
||||
body { margin: 0; min-height: 100vh; background: radial-gradient(ellipse at 52% 34%, #33373a 0, #25282b 46%, #1a1d20 100%); }
|
||||
.alfred-shell { position:relative; isolation:isolate; display:grid; grid-template-rows: 56px minmax(0,1fr) 142px; gap:16px; min-height:100vh; min-height:100dvh; padding:25px 36px; }
|
||||
.topbar { display:flex; align-items:center; justify-content:space-between; z-index:5; }
|
||||
.status { display:inline-flex; align-items:center; gap:11px; font-size:14px; font-weight:500; color:#e6eaed; letter-spacing:.045em; }
|
||||
.status-dot { width:10px; height:10px; border-radius:50%; background:#7d8790; box-shadow:0 0 0 4px #ffffff0a; transition:background .25s,box-shadow .25s; }
|
||||
.status-dot[data-mode="listening"] { background:#70c7ce; box-shadow:0 0 0 5px #70c7ce17; animation:status-pulse 1.6s infinite; }
|
||||
.status-dot[data-mode="thinking"] { background:#e7bb72; box-shadow:0 0 0 5px #e7bb7217; animation:status-pulse 1.9s infinite; }
|
||||
.status-dot[data-mode="speaking"] { background:#9ad0b7; box-shadow:0 0 0 5px #9ad0b717; animation:status-pulse 1.4s infinite; }
|
||||
.status-dot[data-mode="error"] { background:#f18989; box-shadow:0 0 0 5px #f1898917; }
|
||||
.header-actions { display:flex; gap:10px; align-items:center; }
|
||||
.icon-button { display:grid; place-items:center; width:44px; height:44px; border:1px solid #ffffff22; border-radius:13px; background:#ffffff08; color:#e8ebee; cursor:pointer; transition:background .2s,border-color .2s; }
|
||||
.icon-button:hover { background:#ffffff14; border-color:#ffffff35; }
|
||||
.icon-button:focus-visible,.panel-action:focus-visible,.quiet-button:focus-visible { outline:2px solid #adc8e0; outline-offset:3px; }
|
||||
.icon-button svg { width:21px; height:21px; }
|
||||
.icon-button[aria-pressed="true"] { color:#f4a2a2; background:#ae47471b; border-color:#b95d5d55; }
|
||||
.speaker-off-icon { display:none; }
|
||||
.icon-button[aria-pressed="true"] .speaker-on-icon { display:none; }
|
||||
.icon-button[aria-pressed="true"] .speaker-off-icon { display:block; }
|
||||
#stage { display:flex; align-items:center; justify-content:center; overflow:hidden; min-height:0; padding:0 8%; }
|
||||
#content { width:min(990px,86vw); text-align:center; transition:opacity .25s ease,transform .25s ease; animation:appear .45s ease; }
|
||||
#content.swap { opacity:0; transform:translateY(10px) scale(.99); }
|
||||
#content h1 { font-size:clamp(2rem,4vw,4.3rem); font-weight:350; margin:.2em 0; line-height:1.2; }
|
||||
#content p { font-size:clamp(1rem,1.6vw,1.35rem); color:#cdd1d4; line-height:1.55; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.hero { max-width:min(100%,750px); max-height:56vh; object-fit:contain; border-radius:18px; box-shadow:0 18px 50px #0006; animation:appear .45s ease; }
|
||||
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(205px,1fr)); gap:13px; text-align:left; }
|
||||
.card { padding:18px; border:1px solid #ffffff1c; background:#ffffff0a; border-radius:15px; }
|
||||
.card h2 { margin:0 0 8px; font-weight:500; }
|
||||
.card p { margin:0; font-size:1rem; }
|
||||
#wave { display:flex; align-items:center; justify-self:center; width: min(560px,37vw); height:100px; align-self:end; }
|
||||
#wave canvas { width:100%; height:100%; }
|
||||
.camera-panel,.transcript-panel { position:absolute; z-index:2; bottom:34px; border:1px solid #ffffff20; background:#222629db; box-shadow:0 14px 35px #0002; border-radius:16px; backdrop-filter:blur(12px); }
|
||||
.camera-panel { left:36px; width:clamp(220px,24vw,355px); overflow:hidden; }
|
||||
.transcript-panel { right:36px; width:clamp(250px,25vw,405px); padding:15px 17px 18px; min-height:136px; }
|
||||
.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:42px; padding:0 13px; }
|
||||
.transcript-panel .panel-heading { padding:0; min-height:30px; }
|
||||
.panel-title { display:flex; align-items:center; gap:9px; font-size:10px; letter-spacing:.1em; font-weight:700; color:#abb5be; }
|
||||
.panel-indicator { width:7px; height:7px; border-radius:50%; background:#71818c; }
|
||||
.camera-panel[data-active="true"] .panel-indicator { background:#83c5cc; }
|
||||
.transcript-panel[data-active="true"] .stt-indicator { background:#83c5cc; }
|
||||
.panel-action { border:0; border-radius:8px; background:#ffffff0c; color:#e3e8ed; padding:7px 10px; font-size:12px; cursor:pointer; }
|
||||
.panel-action:hover { background:#ffffff22; }
|
||||
.camera-viewport { display:grid; place-items:center; position:relative; aspect-ratio:16/9; background:#101315; overflow:hidden; }
|
||||
.camera-viewport > * { grid-area:1/1; }
|
||||
.camera-viewport img { width:100%; height:100%; object-fit:contain; }
|
||||
.camera-placeholder { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; width:100%; height:100%; color:#919ca4; font-size:12px; text-align:center; padding:12px; }
|
||||
.camera-placeholder svg { width:30px; height:30px; opacity:.65; }
|
||||
.transcript-state { color:#909ba3; font-size:11px; flex-shrink:0; }
|
||||
.transcript-text { margin:11px 0 0; color:#edf0f2; font-size:14px; line-height:1.6; overflow-wrap:anywhere; max-height:110px; overflow-y:auto; }
|
||||
.transcript-text.is-placeholder { color:#87939c; }
|
||||
@keyframes appear { from { opacity:0; transform:translateY(8px) } to { opacity:1;transform:translateY(0) } }
|
||||
@keyframes status-pulse { 0%,100% { opacity:1 } 50% { opacity:.45 } }
|
||||
@media(max-width:1120px) { #wave { width:28vw; } .camera-panel { width:27vw; } .transcript-panel { width:29vw; } }
|
||||
@media(max-width:760px) { .alfred-shell { padding:14px; grid-template-rows:50px minmax(220px,1fr) 80px auto; gap:8px; } #stage { padding:10px; } #wave { width:80%; height:70px; } .camera-panel,.transcript-panel { position:relative; left:auto; right:auto; bottom:auto; width:100%; } .camera-panel { grid-row:4; max-width:none; } .transcript-panel { grid-row:5; min-height:auto; } .topbar { grid-row:1; } }
|
||||
@media(prefers-reduced-motion:reduce) { * { animation:none !important; transition:none !important; } }
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Lecture MJPEG activée manuellement : libère la caméra si désactivée. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const button = document.getElementById('cameraToggle');
|
||||
const image = document.getElementById('cameraFeed');
|
||||
const placeholder = document.getElementById('cameraPlaceholder');
|
||||
const message = document.getElementById('cameraMessage');
|
||||
const panel = document.querySelector('.camera-panel');
|
||||
let active = false;
|
||||
|
||||
function stop() {
|
||||
active = false;
|
||||
image.removeAttribute('src');
|
||||
image.hidden = true;
|
||||
placeholder.hidden = false;
|
||||
message.textContent = 'Caméra désactivée';
|
||||
button.textContent = 'Activer';
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
panel.dataset.active = 'false';
|
||||
}
|
||||
function start() {
|
||||
active = true;
|
||||
placeholder.hidden = false;
|
||||
message.textContent = 'Connexion à la caméra…';
|
||||
image.hidden = false;
|
||||
image.src = `/api/camera/stream?t=${Date.now()}`;
|
||||
button.textContent = 'Arrêter';
|
||||
button.setAttribute('aria-pressed', 'true');
|
||||
}
|
||||
button.addEventListener('click', () => active ? stop() : start());
|
||||
image.addEventListener('load', () => { if (active) { placeholder.hidden = true; panel.dataset.active = 'true'; } });
|
||||
image.addEventListener('error', () => {
|
||||
if (!active) return;
|
||||
stop();
|
||||
message.textContent = 'Caméra indisponible ou déjà utilisée';
|
||||
});
|
||||
// Si l'onglet n'est pas visible, relâcher le périphérique USB.
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden && active) stop(); });
|
||||
window.addEventListener('pagehide', stop);
|
||||
stop();
|
||||
})();
|
||||
@@ -0,0 +1,93 @@
|
||||
/* Réglages volume/gain dans la fenêtre, mute micro + haut-parleur dans la barre haute. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const panel = document.getElementById('audioSettingsPanel');
|
||||
const button = document.getElementById('audioSettingsButton');
|
||||
const close = document.getElementById('closeAudioSettings');
|
||||
const speaker = document.getElementById('speakerMuteQuick');
|
||||
const mic = document.getElementById('micMute');
|
||||
const volume = document.getElementById('volumeSlider');
|
||||
const volumeValue = document.getElementById('volumeValue');
|
||||
const gain = document.getElementById('micGainSlider');
|
||||
const gainValue = document.getElementById('micGainValue');
|
||||
const message = document.getElementById('audioSettingsMessage');
|
||||
let settings = { volume:70, mic_gain:null, speaker_muted:false, mic_muted:false };
|
||||
let volumeTimer, gainTimer;
|
||||
|
||||
function render() {
|
||||
volume.value = settings.volume;
|
||||
volumeValue.textContent = `${settings.volume} %`;
|
||||
gain.disabled = !Number.isFinite(settings.mic_gain);
|
||||
if (!gain.disabled) gain.value = settings.mic_gain;
|
||||
gainValue.textContent = gain.disabled ? 'N/D' : `${settings.mic_gain} %`;
|
||||
speaker.setAttribute('aria-pressed', String(settings.speaker_muted));
|
||||
speaker.setAttribute('aria-label', settings.speaker_muted ? 'Réactiver le son' : 'Couper le son');
|
||||
speaker.title = speaker.getAttribute('aria-label');
|
||||
mic.setAttribute('aria-pressed', String(settings.mic_muted));
|
||||
mic.setAttribute('aria-label', settings.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone');
|
||||
mic.title = mic.getAttribute('aria-label');
|
||||
}
|
||||
|
||||
async function update(changes) {
|
||||
message.textContent = '';
|
||||
try {
|
||||
const response = await fetch('/api/audio/settings', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(changes)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
||||
settings = { ...settings, ...data };
|
||||
render();
|
||||
} catch (err) {
|
||||
message.textContent = `Impossible de modifier le réglage : ${err.message}`;
|
||||
console.warn('[ALFRED AUDIO]', err);
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch('/api/audio/settings', {cache:'no-store'});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
settings = { ...settings, ...await response.json() };
|
||||
render();
|
||||
} catch (err) {
|
||||
message.textContent = 'Réglages indisponibles.';
|
||||
console.warn('[ALFRED AUDIO]', err);
|
||||
}
|
||||
}
|
||||
|
||||
function setOpen(open) {
|
||||
panel.hidden = !open;
|
||||
button.setAttribute('aria-expanded', String(open));
|
||||
if (open) { refresh(); close.focus(); } else { button.focus(); }
|
||||
}
|
||||
button.addEventListener('click', () => setOpen(panel.hidden));
|
||||
close.addEventListener('click', () => setOpen(false));
|
||||
document.addEventListener('pointerdown', e => {
|
||||
if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) {
|
||||
panel.hidden = true; button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) setOpen(false); });
|
||||
|
||||
volume.addEventListener('input', () => {
|
||||
settings.volume = Number(volume.value); render();
|
||||
clearTimeout(volumeTimer);
|
||||
volumeTimer = setTimeout(() => update({volume:Number(volume.value)}), 220);
|
||||
});
|
||||
gain.addEventListener('input', () => {
|
||||
settings.mic_gain = Number(gain.value); render();
|
||||
clearTimeout(gainTimer);
|
||||
gainTimer = setTimeout(() => update({mic_gain:Number(gain.value)}), 250);
|
||||
});
|
||||
speaker.addEventListener('click', () => update({speaker_muted:!settings.speaker_muted}));
|
||||
mic.addEventListener('click', () => update({mic_muted:!settings.mic_muted}));
|
||||
|
||||
if (window.alfredSocket) window.alfredSocket.on('audio_settings', data => {
|
||||
settings = {...settings, ...data}; render();
|
||||
});
|
||||
render(); refresh();
|
||||
})();
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Icônes de statut : compatible avec alfred.js existant, sans le remplacer. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const el = document.getElementById('mode');
|
||||
if (!el) return;
|
||||
const icons = {
|
||||
idle: ['🛏️', 'Repos'], repos: ['🛏️', 'Repos'],
|
||||
listening: ['👂', 'Écoute'], thinking: ['🧠', 'Réflexion'],
|
||||
speaking: ['🔊', 'Parole'], error: ['⚠️', 'Erreur']
|
||||
};
|
||||
let current = 'idle';
|
||||
function apply(raw) {
|
||||
const key = String(raw || '').trim().toLowerCase();
|
||||
if (!icons[key]) return;
|
||||
current = key;
|
||||
const [icon, label] = icons[key];
|
||||
if (el.textContent !== icon) el.textContent = icon;
|
||||
el.title = label;
|
||||
el.setAttribute('aria-label', label);
|
||||
el.dataset.mode = key;
|
||||
}
|
||||
// L'ancien alfred.js écrit le nom du mode dans #mode.
|
||||
// L'observateur le convertit immédiatement en icône, sans modifier ce script.
|
||||
const observer = new MutationObserver(() => {
|
||||
const value = el.textContent.trim();
|
||||
if (icons[value.toLowerCase()]) apply(value);
|
||||
});
|
||||
observer.observe(el, { childList: true, characterData: true, subtree: true });
|
||||
apply(el.textContent);
|
||||
// Récupère également l'état actuel après un rafraîchissement de page.
|
||||
fetch('/api/state', { cache: 'no-store' })
|
||||
.then(r => { if (!r.ok) throw new Error('État indisponible'); return r.json(); })
|
||||
.then(data => { if (data && data.mode) apply(data.mode); })
|
||||
.catch(() => {});
|
||||
})();
|
||||
@@ -0,0 +1,235 @@
|
||||
/* Alfred • visual views, powered by first-party Flask endpoints. No extra CDN. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const root = document.getElementById('content');
|
||||
let current = null, camerasRefresh = null;
|
||||
const DAYS = ['lun.','mar.','mer.','jeu.','ven.','sam.','dim.'];
|
||||
const PALETTE = ['#77bcdf','#e5b677','#9dc99a','#d3a2d5','#8cb7c9','#ec9f95','#b6b5e1','#b9d78d'];
|
||||
const el = (tag, cls, text) => { const x=document.createElement(tag); if(cls)x.className=cls; if(text!==undefined)x.textContent=String(text); return x; };
|
||||
const btn = (label, fn, cls='view-button') => { const b=el('button',cls,label); b.type='button'; b.addEventListener('click',fn);return b; };
|
||||
const api = async (url, options) => {
|
||||
const r=await fetch(url,{cache:'no-store', ...options});
|
||||
const data=await r.json().catch(()=>({error:`Erreur HTTP ${r.status}`}));
|
||||
if(!r.ok)throw new Error(data.error || `HTTP ${r.status}`);
|
||||
return data;
|
||||
};
|
||||
const post = (url,data) => api(url,{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify(data)});
|
||||
const dateLabel = iso => new Date(iso).toLocaleDateString('fr-FR',{day:'numeric',month:'long',year:'numeric'});
|
||||
const timeLabel = iso => new Date(iso).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
|
||||
const localKey = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
const atDay = (d,h=0) => new Date(d.getFullYear(),d.getMonth(),d.getDate(),h);
|
||||
const dayShift = (d,n) => new Date(d.getFullYear(),d.getMonth(),d.getDate()+n,d.getHours(),d.getMinutes());
|
||||
const eventOnDay=(ev,day)=> {const start=new Date(ev.start),end=new Date(ev.end);return start<dayShift(atDay(day),1) && end>atDay(day);};
|
||||
const fmtRange=(a,b)=>`${dateLabel(a)} — ${dateLabel(new Date(new Date(b).getTime()-1000).toISOString())}`;
|
||||
const wrap = (title) => {
|
||||
clearInterval(camerasRefresh);camerasRefresh=null;root.replaceChildren();
|
||||
root.classList.add('alfred-view');
|
||||
const pane=el('div','view-pane'),head=el('header','view-head');
|
||||
head.append(el('h1','view-title',title));
|
||||
head.append(btn('×',goHome,'view-close'));
|
||||
pane.append(head);root.append(pane);return {pane,head};
|
||||
};
|
||||
const showError=(err,pane)=>pane.append(el('p','view-error',err.message||String(err)));
|
||||
const canRender = data=>['calendar','calendar_form','delete_candidates','temperatures','cameras','home'].includes(data?.type);
|
||||
function render(data){
|
||||
if(!canRender(data))return false;
|
||||
current=data;
|
||||
if(data.type==='calendar')drawCalendar(data);
|
||||
if(data.type==='calendar_form')drawForm(data);
|
||||
if(data.type==='delete_candidates')drawDeleteChoices(data);
|
||||
if(data.type==='temperatures')drawTemps(data);
|
||||
if(data.type==='cameras')drawCameras(data);
|
||||
if(data.type==='home'){root.classList.remove('alfred-view');root.replaceChildren(el('h1','', 'Bonjour.'), el('p','',data.text||'À ton écoute.')); clearInterval(camerasRefresh);camerasRefresh=null;}
|
||||
return true;
|
||||
}
|
||||
const getSelected=()=>document.getElementById('calendar-source')?.value || '';
|
||||
async function loadCalendar(start,end,view,filter){
|
||||
const q=new URLSearchParams({start:new Date(start).toISOString(),end:new Date(end).toISOString()});
|
||||
if(filter)q.append('calendar_id',filter);
|
||||
const data=await api('/api/calendar/events?'+q);
|
||||
render({type:'calendar',view,start:new Date(start).toISOString(),end:new Date(end).toISOString(),...data,selected:filter||''});
|
||||
}
|
||||
function moveCalendar(direction){
|
||||
if(!current || current.type!=='calendar')return;
|
||||
const start=new Date(current.start),end=new Date(current.end),view=current.view;
|
||||
let a,b;
|
||||
if(view==='month'){
|
||||
a=new Date(start.getFullYear(),start.getMonth()+direction,1);
|
||||
b=new Date(a.getFullYear(),a.getMonth()+1,1);
|
||||
}else{
|
||||
const days=Math.round((end-start)/86400000) || 1;
|
||||
a=dayShift(start,days*direction);b=dayShift(end,days*direction);
|
||||
}
|
||||
loadCalendar(a,b,view,getSelected()).catch(err=>{const {pane}=wrap('Agenda');showError(err,pane)});
|
||||
}
|
||||
const details = (event) => {
|
||||
const d=el('div','detail-modal'),box=el('section','detail-box');
|
||||
box.setAttribute('role','dialog');box.setAttribute('aria-modal','true');box.setAttribute('aria-label','Détails de l’événement');
|
||||
box.append(el('h2','',event.title));
|
||||
box.append(el('p','',`${dateLabel(event.start)}${event.all_day?' · Toute la journée':' · '+timeLabel(event.start)+'–'+timeLabel(event.end)}`));
|
||||
box.append(el('p','',event.calendar_name));
|
||||
if(event.location)box.append(el('p','',`Lieu : ${event.location}`));
|
||||
if(event.description)box.append(el('p','',event.description));
|
||||
const acts=el('div','view-actions');acts.append(btn('Fermer',()=>d.remove()));
|
||||
if(event.can_delete) acts.append(btn('Supprimer…',async()=>{
|
||||
const recurrence=event.recurrence_id ? 'Cela supprimera cette occurrence.' : 'Pour une série récurrente, cela peut supprimer toute la série.';
|
||||
if(!confirm(`Supprimer « ${event.title} » du calendrier « ${event.calendar_name} » ?\n${recurrence}`))return;
|
||||
try{await post('/api/calendar/delete',{calendar_id:event.calendar_id,uid:event.id,start:event.start,end:event.end,recurrence_id:event.recurrence_id||''}); d.remove();await loadCalendar(current.start,current.end,current.view,getSelected());}
|
||||
catch(err){alert(err.message)}
|
||||
},'view-danger'));
|
||||
box.append(acts);d.append(box);document.body.append(d);
|
||||
d.addEventListener('click',e=>{if(e.target===d)d.remove()});
|
||||
};
|
||||
function drawCalendar(data){
|
||||
const {pane,head}=wrap('Planning');
|
||||
const toolbar=el('div','view-toolbar');
|
||||
const first=el('div','view-nav');
|
||||
first.append(btn('‹',()=>moveCalendar(-1)),el('span','view-period',fmtRange(data.start,data.end)),btn('›',()=>moveCalendar(1)));
|
||||
toolbar.append(first);
|
||||
const modes=el('div','view-modes');
|
||||
[['Jour',1,'day'],['3 jours',3,'days'],['7 jours',7,'days'],['Semaine',7,'week'],['Mois',31,'month']].forEach(([label,n,v])=>{
|
||||
const b=btn(label,()=>{
|
||||
const now=new Date(),start=(v==='week')?dayShift(atDay(now),-((now.getDay()+6)%7)):v==='month'?new Date(now.getFullYear(),now.getMonth(),1):atDay(now);
|
||||
const end=v==='month'?new Date(start.getFullYear(),start.getMonth()+1,1):dayShift(start,n);
|
||||
loadCalendar(start,end,v,getSelected()).catch(err=>showError(err,pane));
|
||||
},'view-pill'+(data.view===v?' active':''));modes.append(b);
|
||||
});toolbar.append(modes);
|
||||
const src=el('select','view-select');src.id='calendar-source';
|
||||
src.append(new Option('Tous les calendriers',''));
|
||||
(data.calendars||[]).forEach(c=>src.append(new Option(c.name,c.entity_id)));
|
||||
src.value=data.selected||'';
|
||||
src.addEventListener('change',()=>loadCalendar(data.start,data.end,data.view,src.value).catch(err=>showError(err,pane)));
|
||||
toolbar.append(src,btn('+ Rendez-vous',()=>openForm()));
|
||||
pane.append(toolbar);
|
||||
if(data.errors?.length)pane.append(el('p','view-error','Calendriers partiellement indisponibles : '+data.errors.join(' · ')));
|
||||
const grid=el('div','agenda-grid');
|
||||
const start=new Date(data.start),end=new Date(data.end);
|
||||
let days=[],cursor=atDay(start);
|
||||
const month=data.view==='month';
|
||||
if(month) cursor=dayShift(cursor,-((cursor.getDay()+6)%7));
|
||||
let limit=0;
|
||||
while(cursor<end && limit++<42){days.push(cursor);cursor=dayShift(cursor,1)}
|
||||
if(month) while(days.length%7)days.push(cursor),cursor=dayShift(cursor,1);
|
||||
grid.classList.add(month?'agenda-month':days.length===1?'agenda-one':days.length<=3?'agenda-three':'agenda-seven');
|
||||
const colors=new Map((data.calendars||[]).map((c,i)=>[c.entity_id, PALETTE[i%PALETTE.length]]));
|
||||
days.forEach(d=>{
|
||||
const cell=el('section','agenda-day');
|
||||
if(month && d.getMonth()!==start.getMonth())cell.classList.add('out-month');
|
||||
if(localKey(d)===localKey(new Date()))cell.classList.add('is-today');
|
||||
cell.append(el('div','agenda-date',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:month?undefined:'short'})));
|
||||
const events=(data.events||[]).filter(ev=>eventOnDay(ev,d));
|
||||
if(!events.length)cell.append(el('div','agenda-empty','—'));
|
||||
events.forEach(ev=>{
|
||||
const e=btn((ev.all_day?'':timeLabel(ev.start)+' ')+ev.title,()=>details(ev),'agenda-event');
|
||||
e.style.setProperty('--event-color',colors.get(ev.calendar_id)||'#7bbbe2');
|
||||
e.title=`${ev.title} · ${ev.calendar_name}`;cell.append(e);
|
||||
});
|
||||
grid.append(cell);
|
||||
});
|
||||
pane.append(grid);
|
||||
pane.append(el('p','view-footer',`${data.events?.length||0} événement(s) · ${data.calendars?.length||0} calendrier(s) Home Assistant`));
|
||||
}
|
||||
async function openForm(values){
|
||||
let calendars=[];
|
||||
try {const info=await api('/api/calendar/calendars');calendars=info.calendars||[];}
|
||||
catch(err){const {pane}=wrap('Nouveau rendez-vous');showError(err,pane);return;}
|
||||
render({type:'calendar_form',calendars,values:values||{}});
|
||||
}
|
||||
function drawForm(data){
|
||||
const {pane}=wrap('Ajouter un événement');
|
||||
const v=data.values||{};
|
||||
const form=el('form','agenda-form');form.autocomplete='off';
|
||||
const field=(label,node)=>{const row=el('label','form-field');row.append(el('span','',label),node);form.append(row);return node};
|
||||
const makeInput=(type,value)=>{const i=el('input');i.type=type;i.value=value||'';i.required=true;return i};
|
||||
const title=field('Titre',makeInput('text',v.title));title.maxLength=200;
|
||||
const src=el('select');src.required=true;src.append(new Option('Choisir un calendrier',''));
|
||||
data.calendars.filter(c=>c.can_create).forEach(c=>src.append(new Option(c.name,c.entity_id)));
|
||||
src.value=v.calendar_id||'';field('Calendrier',src);
|
||||
const day=field('Date',makeInput('date',v.date||localKey(new Date())));
|
||||
const startTime=field('Début',makeInput('time',v.time||'09:00'));
|
||||
const duration=field('Durée (minutes)',makeInput('number',v.duration_minutes||60));duration.min=15;duration.max=1440;
|
||||
const location=field('Lieu (facultatif)',makeInput('text',''));location.required=false;
|
||||
const description=field('Description (facultatif)',el('textarea'));description.required=false;description.rows=2;
|
||||
const allDay=el('input');allDay.type='checkbox';allDay.required=false;
|
||||
const allDayWrap=el('label','form-check');allDayWrap.append(allDay,el('span','','Toute la journée'));form.append(allDayWrap);
|
||||
allDay.addEventListener('change',()=>{startTime.disabled=allDay.checked;duration.disabled=allDay.checked});
|
||||
const msg=el('p','form-message');
|
||||
const actions=el('div','view-actions');actions.append(btn('Annuler',goHome));
|
||||
const submit=el('button','view-button primary','Enregistrer');submit.type='submit';actions.append(submit);
|
||||
form.append(actions,msg);pane.append(form);
|
||||
form.addEventListener('submit',async e=>{
|
||||
e.preventDefault();submit.disabled=true;msg.textContent='Enregistrement…';
|
||||
try{
|
||||
const from=allDay.checked?new Date(day.value+'T00:00'):new Date(day.value+'T'+startTime.value+':00');
|
||||
if(Number.isNaN(from.getTime()))throw new Error('Date invalide.');
|
||||
const end=allDay.checked?dayShift(from,1):new Date(from.getTime()+Number(duration.value)*60000);
|
||||
await post('/api/calendar/create',{calendar_id:src.value,title:title.value,start:from.toISOString(),end:end.toISOString(),all_day:allDay.checked,location:location.value,description:description.value});
|
||||
const first=atDay(from);await loadCalendar(first,dayShift(first,7),'days');
|
||||
}catch(err){msg.textContent=err.message;submit.disabled=false}
|
||||
});
|
||||
}
|
||||
function drawDeleteChoices(data){
|
||||
const {pane}=wrap('Choisir un événement à supprimer');
|
||||
const arr=el('div','delete-list');
|
||||
(data.events||[]).forEach(ev=>{
|
||||
const row=el('div','delete-row');row.append(el('span','',`${ev.title} • ${dateLabel(ev.start)} • ${ev.calendar_name}`));
|
||||
row.append(btn('Détails / Supprimer',()=>details(ev),'view-button'));arr.append(row);
|
||||
});pane.append(arr);
|
||||
if(data.awaiting_confirmation)pane.append(el('p','view-footer','Dis « confirme » pour supprimer, ou « annule ».'));
|
||||
}
|
||||
function drawTemps(data){
|
||||
const {pane}=wrap('Températures Home Assistant');
|
||||
if(!data.items?.length){pane.append(el('p','view-empty','Aucun capteur de température trouvé.'));return}
|
||||
const grid=el('div','temperature-grid');pane.append(grid);
|
||||
data.items.forEach(item=>{
|
||||
const c=btn(`${item.name}\n${item.value.toFixed(1)} ${item.unit}` ,async()=>{
|
||||
const q=new URLSearchParams({entity_id:item.entity_id,hours:'24'});
|
||||
try{const data=await api('/api/ha/temperature/history?'+q);showHistory(item,data.items||[],pane)}
|
||||
catch(err){showError(err,pane)}
|
||||
},'temperature-card');grid.append(c);
|
||||
});pane.append(el('p','view-footer','Touchez un capteur pour afficher son historique sur 24 h.'));
|
||||
}
|
||||
function showHistory(item,points,pane){
|
||||
pane.querySelector('.temperature-history')?.remove();
|
||||
const section=el('section','temperature-history');section.append(el('h2','',item.name+' · 24 dernières heures'));
|
||||
if(points.length<2){section.append(el('p','', 'Historique insuffisant.'));pane.append(section);return}
|
||||
const vals=points.map(p=>p.value);const lo=Math.min(...vals)-0.4,hi=Math.max(...vals)+0.4;
|
||||
const a=Date.parse(points[0].time),b=Date.parse(points[points.length-1].time);
|
||||
const coords=points.map((p,i)=>`${30+560*((Date.parse(p.time)-a)/Math.max(1,b-a))},${140-110*((p.value-lo)/Math.max(0.1,hi-lo))}`).join(' ');
|
||||
const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 620 170');svg.setAttribute('role','img');svg.setAttribute('aria-label','Courbe de température');
|
||||
const poly=document.createElementNS(ns,'polyline');poly.setAttribute('points',coords);poly.setAttribute('fill','none');poly.setAttribute('stroke','#83c5cc');poly.setAttribute('stroke-width','3');svg.append(poly);
|
||||
section.append(svg,el('p','',`Min ${Math.min(...vals).toFixed(1)}° · Max ${Math.max(...vals).toFixed(1)}°`));pane.append(section);
|
||||
}
|
||||
function drawCameras(data){
|
||||
const {pane}=wrap('Caméras Home Assistant');
|
||||
if(!data.items?.length){pane.append(el('p','view-empty','Aucune caméra Home Assistant disponible.'));return}
|
||||
const grid=el('div','ha-cameras');pane.append(grid);
|
||||
const imgs=[];
|
||||
data.items.forEach(item=>{
|
||||
const card=el('div','ha-camera-card');card.append(el('h2','',item.name));
|
||||
const img=el('img');img.alt='Instantané caméra '+item.name;img.loading='lazy';imgs.push([img,item.entity_id]);card.append(img);grid.append(card);
|
||||
});
|
||||
const reload=()=>{if(document.hidden)return; imgs.forEach(([img,eid])=>img.src='/api/ha/camera/'+encodeURIComponent(eid)+'/snapshot?t='+Date.now())};
|
||||
reload();camerasRefresh=setInterval(reload,15000);
|
||||
pane.append(el('p','view-footer','Instantanés réactualisés toutes les 15 s lorsque cette vue est ouverte.'));
|
||||
}
|
||||
async function goHome(){
|
||||
try{await post('/api/ui/home',{})}catch(err){render({type:'home'})}
|
||||
}
|
||||
async function openCalendar(){
|
||||
const d=atDay(new Date());try{await loadCalendar(d,dayShift(d,7),'days')}catch(err){const {pane}=wrap('Agenda');showError(err,pane)}
|
||||
}
|
||||
async function openTemperatures(){
|
||||
try{render({type:'temperatures',...await api('/api/ha/temperatures')})}catch(err){const {pane}=wrap('Températures');showError(err,pane)}
|
||||
}
|
||||
async function openCameras(){
|
||||
try{render({type:'cameras',...await api('/api/ha/cameras')})}catch(err){const {pane}=wrap('Caméras');showError(err,pane)}
|
||||
}
|
||||
document.getElementById('viewHome')?.addEventListener('click',goHome);
|
||||
document.getElementById('viewCalendar')?.addEventListener('click',openCalendar);
|
||||
document.getElementById('viewTemperatures')?.addEventListener('click',openTemperatures);
|
||||
document.getElementById('viewCameras')?.addEventListener('click',openCameras);
|
||||
const socket=window.alfredSocket;
|
||||
socket?.on('calendar_changed',()=>{if(current?.type==='calendar') loadCalendar(current.start,current.end,current.view,getSelected()).catch(console.warn)});
|
||||
window.AlfredViews={render,canRender,openCalendar,openTemperatures,openCameras,goHome};
|
||||
})();
|
||||
@@ -0,0 +1,126 @@
|
||||
/* Interface principale Alfred : statut, scène, onde et transcription Vosk. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const socket = (typeof io === 'function') ? io() : null;
|
||||
window.alfredSocket = socket;
|
||||
|
||||
const content = document.getElementById('content');
|
||||
const modeEl = document.getElementById('mode');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const transcriptText = document.getElementById('transcriptText');
|
||||
const transcriptState = document.getElementById('transcriptState');
|
||||
const transcriptPanel = document.querySelector('.transcript-panel');
|
||||
const canvas = document.getElementById('waveCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
let mode = 'idle', level = 0.02, phase = 0, previousContent = '';
|
||||
const labels = {
|
||||
idle:'En attente', waiting:'En attente', listening:'À l’écoute',
|
||||
thinking:'Réflexion en cours', speaking:'Alfred parle', error:'Erreur'
|
||||
};
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(canvas.clientWidth * dpr);
|
||||
canvas.height = Math.round(canvas.clientHeight * dpr);
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
function draw() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
phase += 0.035;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.strokeStyle = 'rgba(232,241,243,.76)';
|
||||
ctx.lineWidth = 1.6 * dpr;
|
||||
ctx.beginPath();
|
||||
const mid = canvas.height / 2;
|
||||
const amp = (mode === 'idle' ? 1.5 : mode === 'thinking' ? 7 : Math.max(5, level * 45)) * dpr;
|
||||
for (let x = 0; x < canvas.width; x += 4 * dpr) {
|
||||
const y = mid + Math.sin(x * 0.018 + phase * 4) * Math.sin(x * 0.006 + phase) * amp *
|
||||
(0.35 + 0.65 * Math.sin(x / canvas.width * Math.PI));
|
||||
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
window.requestAnimationFrame(draw);
|
||||
}
|
||||
window.requestAnimationFrame(draw);
|
||||
|
||||
function makeText(tag, value, className) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
el.textContent = String(value ?? '');
|
||||
return el;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const key = JSON.stringify(data);
|
||||
if (key === previousContent) return;
|
||||
previousContent = key;
|
||||
if (window.AlfredViews && window.AlfredViews.canRender(data)) {
|
||||
window.AlfredViews.render(data);
|
||||
return;
|
||||
}
|
||||
content.classList.remove('alfred-view');
|
||||
const fragment = document.createDocumentFragment();
|
||||
if (data.type === 'image' && typeof data.url === 'string') {
|
||||
// Refuser les URL javascript: et les schémas non prévus.
|
||||
if (/^(https?:\/\/|\/[^/])/i.test(data.url)) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'hero'; img.src = data.url; img.alt = String(data.alt || 'Image affichée par Alfred');
|
||||
fragment.appendChild(img);
|
||||
}
|
||||
if (data.caption) fragment.appendChild(makeText('p', data.caption));
|
||||
} else if (data.type === 'cards' && Array.isArray(data.items)) {
|
||||
const cards = document.createElement('div'); cards.className = 'cards';
|
||||
data.items.forEach(item => {
|
||||
const card = makeText('article', '', 'card');
|
||||
card.append(makeText('h2', item.title || ''), makeText('p', item.text || ''));
|
||||
cards.appendChild(card);
|
||||
});
|
||||
fragment.appendChild(cards);
|
||||
} else {
|
||||
if (data.title) fragment.appendChild(makeText('h1', data.title));
|
||||
fragment.appendChild(makeText('p', data.text || ''));
|
||||
}
|
||||
content.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function updateTranscript(s) {
|
||||
// Une phrase finale reste visible lors du retour en veille.
|
||||
const finalText = String(s.transcript || '').trim();
|
||||
const partial = String(s.partial || '').trim();
|
||||
if (finalText) {
|
||||
transcriptText.textContent = finalText;
|
||||
transcriptText.classList.remove('is-placeholder');
|
||||
transcriptState.textContent = 'Reconnu';
|
||||
} else if (s.mode === 'listening') {
|
||||
transcriptText.textContent = partial || 'Je vous écoute…';
|
||||
transcriptText.classList.toggle('is-placeholder', !partial);
|
||||
transcriptState.textContent = partial ? 'En cours' : 'Écoute';
|
||||
} else if (s.mode === 'error') {
|
||||
transcriptState.textContent = 'Erreur';
|
||||
}
|
||||
transcriptPanel.dataset.active = String(s.mode === 'listening');
|
||||
}
|
||||
|
||||
function updateState(s) {
|
||||
if (!s || typeof s !== 'object') return;
|
||||
mode = String(s.mode || 'idle').toLowerCase();
|
||||
modeEl.textContent = labels[mode] || mode;
|
||||
modeEl.dataset.mode = mode;
|
||||
statusDot.dataset.mode = mode;
|
||||
updateTranscript(s);
|
||||
if (s.content) render(s.content);
|
||||
}
|
||||
|
||||
if (socket) {
|
||||
socket.on('alfred_state', updateState);
|
||||
socket.on('alfred_render', render);
|
||||
socket.on('audio_level', data => { level = Math.max(0, Math.min(1, Number(data.level) || 0)); });
|
||||
socket.on('connect', () => {
|
||||
fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {});
|
||||
});
|
||||
}
|
||||
fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {});
|
||||
})();
|
||||
Reference in New Issue
Block a user