236 lines
16 KiB
JavaScript
236 lines
16 KiB
JavaScript
/* 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};
|
||
})();
|