1(() => { 2 'use strict'; 3 const agenda=document.querySelector('#agenda-list'); 4 const dialog=document.querySelector('#events-dialog'); 5 if(!agenda||!dialog)return; 6 const dialogBody=dialog.querySelector('#events-dialog-content'); 7 const dialogTitle=dialog.querySelector('#events-dialog-title'); 8 const account=document.querySelector('#account-dialog'); 9 const discord='https://discord.gg/EepdaHFXrE'; 10 const pictures={'echanges.webp':'Bourse dâéchange','tournoi.webp':'Tournoi','rencontres.webp':'Expo & rencontres'}; 11 const statuses={draft:'Brouillon',published:'En ligne',cancelled:'Annulé'}; 12 const escape=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); 13 const dates=new Intl.DateTimeFormat('fr-FR',{timeZone:'Europe/Paris',weekday:'long',day:'numeric',month:'long',year:'numeric'}); 14 const months=new Intl.DateTimeFormat('fr-FR',{timeZone:'Europe/Paris',month:'short'}); 15 const parisClock=new Intl.DateTimeFormat('en-CA',{timeZone:'Europe/Paris',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hourCycle:'h23'}); 16 let events=[],publicState='loading',publicError='',publicRequest=0,clockOffset=0,publicFingerprint=''; 17 let dialogView='list',selectedId=null,dialogOpener=null,dialogFingerprint=''; 18 let organizerSlot=null,organizerEvents=[],mountVersion=0,editorId=null,editorRevision=null,conflict=null; 19 let adminBusy=false,adminRequest=0,imageDraft=undefined,imagePreview=null,imageVersion=0,imageBusy=false; 20 21 async function api(path,body){ 22 const response=await fetch(path,{method:body===undefined?'GET':'POST',credentials:'same-origin',cache:'no-store',headers:body===undefined?{}:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body)}); 23 let result;try{result=await response.json()}catch{throw Error('Lâagenda est momentanément indisponible. Réessaie dans un instant.')} 24 if(!response.ok){const error=Error(result.error||'La demande nâa pas pu être enregistrée.');error.status=response.status;throw error} 25 if(Number.isFinite(result.serverTime))clockOffset=result.serverTime*1000-Date.now(); 26 return result; 27 } 28 function parisNow(){const parts=parisClock.formatToParts(new Date(Date.now()+clockOffset));const value=key=>parts.find(part=>part.type===key)?.value;return {date:['year','month','day'].map(value).join('-'),time:['hour','minute'].map(value).join(':')}} 29 function upcoming(){const now=parisNow();return events.filter(event=>event.status!=='draft'&&(event.date>now.date||(event.date===now.date&&(!event.end||event.end>now.time)))).sort((a,b)=>(a.date+a.start+a.id).localeCompare(b.date+b.start+b.id))} 30 function dayLabel(event){return dates.format(new Date(event.date+'T12:00:00Z'))} 31 function timeLabel(event){const start=event.start?.replace(':',' h ')||'Horaire à confirmer';return event.end?start+' â '+event.end.replace(':',' h '):'à partir de '+start} 32 function bookingUrl(value){try{const url=new URL(value);return url.protocol==='https:'&&!url.username&&!url.password?url.href:null}catch{return null}} 33 function imageUrl(event){try{const url=new URL(event.imageUrl||'/assets/'+(pictures[event.imageKey]?event.imageKey:'echanges.webp'),location.origin);return url.origin===location.origin&&['https:','http:'].includes(url.protocol)?url.href:'/assets/echanges.webp'}catch{return '/assets/echanges.webp'}} 34 const discordLink=()=>`<a class="btn btn-outline" href="${discord}" target="_blank" rel="noopener noreferrer">Consulter le Discord ALT</a>`; 35 function unavailable(){return `<div class="agenda-empty"><h3>Lâagenda se fait attendre.</h3><p>${escape(publicError||'Les rendez-vous ne peuvent pas être affichés pour le moment.')}</p><button class="btn btn-outline" data-events-retry>Réessayer</button>${discordLink()}</div>`} 36 function empty(){return '<div class="agenda-empty"><h3>La prochaine rencontre se prépare.</h3><p>
36Aucun prochain événement nâest actuellement publié. Retrouve lâéquipe et les annonces ALT sur Discord.</p>'+discordLink()+'</div>'} 37 function card(event){const month=months.format(new Date(event.date+'T12:00:00Z')).replace('.','');return `<article class="event-card"><div class="event-media"><img src="${escape(imageUrl(event))}" width="1000" height="550" alt="Ambiance de lâassociation ALT" loading="lazy"></div><time class="event-date" datetime="${escape(event.date)}"><strong>${event.date.slice(8)}</strong><span>${escape(month)} ${event.date.slice(0,4)}</span></time><div class="event-copy"><p class="event-time">${escape(timeLabel(event))}</p><h3>${escape(event.title)}</h3><p class="event-location">${escape(event.location)}</p><p class="event-note">${escape(event.type)}</p><button class="text-link event-details" data-event-detail="${escape(event.id)}">Voir les détails</button></div></article>`} 38 function renderPublic(){ 39 const next=upcoming().filter(event=>event.status==='published').slice(0,3); 40 const fingerprint=JSON.stringify([publicState,publicError,next.map(event=>[event.id,event.revision])]); 41 if(fingerprint!==publicFingerprint){ 42 publicFingerprint=fingerprint; 43 agenda.innerHTML=publicState==='error'?unavailable():publicState==='loading'?'<div class="agenda-empty"><h3>Les prochaines rencontres.</h3><p role="status">Chargement de lâagenda ALTâ¦</p></div>':next.length?next.map(card).join(''):empty(); 44 document.dispatchEvent(new Event('alt-events-rendered')); 45 } 46 if(dialog.open)renderDialog(); 47 } 48 async function refresh(){ 49 const sequence=++publicRequest; 50 try{const result=await api('/api/events');if(sequence!==publicRequest)return;if(!Array.isArray(result.events))throw Error('Lâagenda ne peut pas être affiché pour le moment.');events=result.events;publicState='ready';publicError=''} 51 catch(error){if(sequence!==publicRequest)return;events=[];publicState='error';publicError=error.message} 52 renderPublic(); 53 } 54 function listMarkup(){ 55 const list=upcoming(); 56 return `<p class="events-intro">Les rendez-vous publiés par lâéquipe ALT. Les annulations restent signalées ici.</p>${list.length?'<div class="agenda-dates events-public-list">'+list.map(event=>`<article class="${event.status==='cancelled'?'event-is-cancelled':''}"><time datetime="${escape(event.date)}">${escape(dayLabel(event))}</time><span class="events-status ${event.status}">${event.status==='cancelled'?'Annulé':escape(event.type)}</span><h3>${escape(event.title)}</h3><p>${escape(timeLabel(event))} · ${escape(event.location)}</p>${event.status==='cancelled'?'<p class="events-cancel-notice">Ce rendez-vous est annulé. Il nâest plus possible de sây inscrire.</p>':''}<button class="text-link" data-event-detail="${escape(event.id)}">Informations pratiques</button></article>`).join('')+'</div>':empty()}`; 57 } 58 function detailMarkup(event){const booking=bookingUrl(event.bookingUrl);return `<button class="text-link events-back" data-events-back>â Toutes les dates</button><p class="events-detail-type">${escape(event.type)}${event.status==='cancelled'?' · Annulé':''}</p>${event.status==='cancelled'?'<p class="events-cancel-notice" role="status">Ce rendez-vous est annulé. Ne te déplace pas pour cet événement.</p>':''}<img class="events-detail-image" src="${escape(imageUrl(event))}" alt="Ambiance de lâassociation ALT" width="1000" height="550"><dl class="event-facts"><div><dt>Quand ?</dt><dd>${escape(dayLabel(event))}<br>${escape(timeLabel(event))}${!event.end?'<br><small>Lâheure de fin nâest pas annoncée.</small>':''}</dd></div><div><dt>Où ?</dt><dd>${escape(event.location)}${event.address?'<br>'+escape(event.address):'<br><small>Adresse précise à confirmer auprès dâALT.</small>'}</dd></div><div><dt>Tarif</dt><dd>${escape(event.price||'à confirmer auprès dâALT')}</dd></div></dl>${event.description?'<div class="events-description">'+escape(event.description)+'</div>':'<p>Le programme détaillé sera précisé par lâéquipe ALT.</p>'}${booking&&event.status==='published'?`<a class="btn btn-primary" href="${escape(booking)}" target="_blank" rel="noopener noreferrer">Informations et inscription</a>`:''}<p class="events-practical-note">Les modalités dépendent de chaque événement. Contacte ALT si tu as une question sur lâaccueil, le matériel ou lâinscription.</p>${discordLink()}`} 59 function renderDialog(){ 60 const focused=document.activeElement; 61 const focusId=focused?.dataset.eventDetail; 62 const event=selectedId?upcoming().find(item=>item.id===selectedId):null; 63 const fingerprint=JSON.stringify([publicState,publicError,dialogView,selectedId,event?[event.i
63d,event.revision]:upcoming().map(item=>[item.id,item.revision])]); 64 if(fingerprint===dialogFingerprint)return;dialogFingerprint=fingerprint; 65 if(publicState==='error'){dialogTitle.textContent='Lâagenda ALT';dialogBody.innerHTML=unavailable();return} 66 if(publicState==='loading'){dialogTitle.textContent='Lâagenda ALT';dialogBody.innerHTML='<p role="status">Chargement des rendez-vousâ¦</p>';return} 67 if(dialogView==='detail'&&event){dialogTitle.textContent=event.title;dialogBody.innerHTML=detailMarkup(event)} 68 else{dialogView='list';selectedId=null;dialogTitle.textContent='Les prochaines dates ALT.';dialogBody.innerHTML=listMarkup()} 69 if(focused&&dialogBody.contains(document.activeElement))return; 70 if(focusId){const replacement=[...dialogBody.querySelectorAll('[data-event-detail]')].find(button=>button.dataset.eventDetail===focusId);replacement?.focus({preventScroll:true})} 71 else if(focused?.hasAttribute('data-events-back'))dialogBody.querySelector('[data-events-back]')?.focus({preventScroll:true}); 72 } 73 function openPublic(view='list',id=null,opener=null){ 74 dialogView=view;selectedId=id;if(!dialog.open){dialogOpener=opener;dialog.showModal();document.body.classList.add('modal-open')} 75 renderDialog();dialog.querySelector('.dialog-close').focus({preventScroll:true}); 76 } 77 agenda.addEventListener('click',event=>{const button=event.target.closest('[data-event-detail]');if(button)openPublic('detail',button.dataset.eventDetail,button);if(event.target.closest('[data-events-retry]'))refresh()}); 78 document.querySelector('[data-open="agenda"]')?.addEventListener('click',event=>openPublic('list',null,event.currentTarget)); 79 dialogBody.addEventListener('click',event=>{const button=event.target.closest('[data-event-detail]');if(button){dialogView='detail';selectedId=button.dataset.eventDetail;renderDialog();dialogTitle.tabIndex=-1;dialogTitle.focus({preventScroll:true})}if(event.target.closest('[data-events-back]')){dialogView='list';selectedId=null;renderDialog();dialogTitle.tabIndex=-1;dialogTitle.focus({preventScroll:true})}if(event.target.closest('[data-events-retry]'))refresh()}); 80 dialog.addEventListener('close',()=>{const target=dialogOpener?.isConnected&&!dialogOpener.closest('[inert]')?dialogOpener:document.querySelector('[data-open="agenda"]');if(target?.getClientRects().length&&!target.closest('[inert]'))target.focus({preventScroll:true})}); 81 82 function connected(){return organizerSlot?.isConnected&&organizerSlot===document.querySelector('#events-organizer')} 83 function adminFeedback(message){const target=organizerSlot?.querySelector('.events-admin-feedback');if(target)target.textContent=message} 84 function editorFeedback(message){const target=organizerSlot?.querySelector('.events-editor-feedback');if(target)target.textContent=message} 85 function clearImage(){imageVersion++;imageBusy=false;imageDraft=undefined;imagePreview=null} 86 function adminList(){ 87 const target=organizerSlot?.querySelector('#events-admin-list');if(!target)return; 88 const focus=document.activeElement,id=focus?.dataset.eventEdit,statusId=focus?.dataset.eventStatus; 89 const list=[...organizerEvents].sort((a,b)=>(b.date+b.start).localeCompare(a.date+a.start)); 90 target.innerHTML=list.length?'<ul class="events-admin-list">'+list.map(event=>`<li><div><span class="events-status ${escape(event.status)}">${escape(statuses[event.status]||event.status)}</span><h4>${escape(event.title)}</h4><p>${escape(dayLabel(event))} · ${escape(timeLabel(event))}<br>${escape(event.location)}</p></div><div class="events-admin-actions"><button class="text-link" data-event-edit="${escape(event.id)}">Modifier</button>${event.status!=='published'?`<button class="text-link" data-event-status="${escape(event.id)}" data-status="published">${event.status==='cancelled'?'Remettre en ligne':'Publier'}</button>`:''}${event.status!=='cancelled'?`<button class="text-link" data-event-status="${escape(event.id)}" data-status="cancelled">Annuler lâévénement</button>`:''}${event.status!=='draft'?`<button class="text-link" data-event-status="${escape(event.id)}" data-status="draft">Garder en brouillon</button>`:''}</div></li>`).join('')+'</ul>':'<p class="events-notice">Aucun événement enregistré. Prépare le premier rendez-vous avec le bouton ci-dessus.</p>'; 91 if(id||statusId){const candidates=target.querySelectorAll(id?'[data-event-edit]':'[data-event-status]');const replacement=[...candidates].find(button=>(id?button.dataset.eventEdit===id:button.dataset.eventStatus===statusId&&button.dataset.status===focus.dataset.status))||[...target.querySelectorAll('[data-event-edit]')].find(button=>button.dataset.eventEdit===(id||statusId));replacement?.focus({preventScroll:true})} 92 } 93 async function loadAdmin({initial=false}={}){ 94 const version=mountVersion,sequence=++adminRequest; 95 try{ 96 const result=await api('/api/events/organizer');if(version!==mountVersion||sequence!==adminRequest||!connected())return false;
97 organizerEvents=result.events||[]; 98 if(initial||!organizerSlot.querySelector('#events-admin-list')){ 99 organizerSlot.hidden=false; 100 organizerSlot.innerHTML='<details class="events-admin"><summary>Espace organisateur · Ãvénements</summary><div class="events-admin-content"><p>Prépare les rendez-vous ALT, publie leurs informations et signale les annulations. Les horaires sont ceux de Paris.</p><div class="events-admin-toolbar"><button class="btn btn-outline" data-event-new>Créer un événement</button><button class="text-link" data-events-admin-refresh>Actualiser la liste</button></div><p class="events-admin-feedback" role="status" aria-live="polite"></p><div id="events-editor"></div><h3>Les rendez-vous ALT</h3><div id="events-admin-list"></div></div></details>'; 101 } 102 adminList();return true; 103 }catch(error){ 104 if(version!==mountVersion||sequence!==adminRequest||!connected())return false; 105 if(error.status===401||error.status===403){organizerSlot.hidden=true;organizerSlot.innerHTML='';clearImage();return false} 106 if(initial){organizerSlot.hidden=false;organizerSlot.innerHTML='<p class="events-notice" role="status">La gestion des rendez-vous ne peut pas être vérifiée pour le moment.</p><button class="text-link" data-events-admin-refresh>Réessayer la vérification</button>'} 107 else adminFeedback(error.message); 108 return false; 109 } 110 } 111 function renderEditor(event=null){ 112 clearImage();editorId=event?.id||null;editorRevision=event?.revision??null;conflict=null; 113 const target=organizerSlot.querySelector('#events-editor'); 114 const values=event||{title:'',type:'',date:parisNow().date,start:'',end:'',location:'',address:'',description:'',price:'',bookingUrl:'',imageKey:'echanges.webp',status:'draft'}; 115 target.innerHTML=`<form id="alt-event-form" class="events-form"><h3>${event?'Modifier le rendez-vous':'Un nouveau rendez-vous ALT'}</h3><p class="events-notice">Une fiche en brouillon reste privée. Publie-la lorsque les informations sont confirmées.</p><div class="events-form-grid"><label>Nom de lâévénement<input name="title" required minlength="3" maxlength="120" value="${escape(values.title)}" placeholder="Ex. Bourse dâéchange ALT"></label><label>Type dâactivité<input name="type" required maxlength="60" value="${escape(values.type)}" placeholder="Ex. Bourse dâéchange, tournoi, initiation"></label><label>Jour<input name="date" type="date" required value="${escape(values.date)}"></label><label>Heure de début<input name="start" type="time" required value="${escape(values.start)}"></label><label>Heure de fin, si connue<input name="end" type="time" value="${escape(values.end)}"><small>Laisse vide si elle nâest pas annoncée.</small></label><label>Lieu de lâévénement<input name="location" required maxlength="180" value="${escape(values.location)}" placeholder="Ex. Nom du magasin ou de la salle"></label><label class="events-wide">Adresse publique de lâévénement<input name="address" maxlength="240" value="${escape(values.address)}" aria-describedby="event-address-help" placeholder="Rue, code postal et ville du lieu"><small id="event-address-help">Adresse du lieu de lâévénement, visible sur le site. Nâindique pas un domicile personnel.</small></label><label class="events-wide">Programme et informations pratiques<textarea name="description" maxlength="4000" rows="5" placeholder="Activités, matériel, accueil, modalitésâ¦">${escape(values.description)}</textarea></label><label>Tarif, si confirmé<input name="price" maxlength="120" value="${escape(values.price)}" aria-describedby="event-price-help" placeholder="Ex. Entrée libre, 5 â
115¬, tarif selon session"><small id="event-price-help">Vide : le site affiche « à confirmer auprès dâALT ».</small></label><label>Lien dâinscription ou dâinformation<input name="bookingUrl" type="url" maxlength="2048" value="${escape(values.bookingUrl)}" placeholder="https://â¦"><small>Facultatif, HTTPS uniquement.</small></label><label>Visuel de lâassociation<select name="imageKey">${Object.entries(pictures).map(([key,label])=>`<option value="${key}"${key===values.imageKey?' selected':''}>${label}</option>`).join('')}</select></label><label>Photo de ce rendez-vous<input name="photo" type="file" accept="image/jpeg,image/png,image/webp"><small>JPEG, PNG ou WebP. La photo sera réduite pour le site.</small></label><div class="events-image-preview events-wide"><img src="${escape(imageUrl(values))}" alt="Aperçu du visuel de lâévénement" width="640" height="360"><button type="button" class="text-link" data-event-image-remove>Utiliser seulement le visuel de lâassociation</button><p class="events-image-feedback" role="status" aria-live="polite"></p></div><label>Visibilité<select name="status">${Object.entries(statuses).map(([key,label])=>`<option value="${key}"${key===values.status?' selected':''}>${label}</option>`).join('')}</select></label></div><div class="events-conflict" hidden></div><p class="events-editor-feedback" role="status" aria-live="polite"></p><div class="events-form-actions"><button class="btn btn-primary" type="submit">Enregistrer ${event?'les modifications':'le rendez-vous'}</button><button class="text-link" type="button" data-event-editor-close>Fermer le formulaire</button></div></form>`; 116 target.querySelector('input[name=title]').focus({preventScroll:true}); 117 } 118 function updatePreview(){ 119 const form=organizerSlot?.querySelector('#alt-event-form');if(!form)return; 120 const original=organizerEvents.find(event=>event.id===editorId); 121 const hasUpload=original?.imageUrl?.startsWith('/api/events/'); 122 form.querySelector('.events-image-preview img').src=imagePreview||(imageDraft!==null&&hasUpload?imageUrl(original):'/assets/'+form.elements.imageKey.value); 123 } 124 async function prepareImage(file){ 125 const version=++imageVersion,mount=mountVersion; 126 const feedback=organizerSlot.querySelector('.events-image-feedback'); 127 if(!file)return; 128 if(!['image/jpeg','image/png','image/webp'].includes(file.type)||file.size>10*1024*1024){feedback.textContent='Choisis une photo JPEG, PNG ou WebP de moins de 10 Mo.';return} 129 imageBusy=true;feedback.textContent='Préparation de ta photoâ¦'; 130 let bitmap; 131 try{ 132 bitmap=await createImageBitmap(file); 133 if(bitmap.width*bitmap.height>40000000)throw Error('Cette photo est trop grande. Choisis une version plus petite.'); 134 const scale=Math.min(1,1280/Math.max(bitmap.width,bitmap.height)); 135 const canvas=document.createElement('canvas');canvas.width=Math.max(1,Math.round(bitmap.width*scale));canvas.height=Math.max(1,Math.round(bitmap.height*scale)); 136 const context=canvas.getContext('2d');if(!context)throw Error('La photo ne peut pas être préparée dans ce navigateur.'); 137 let blob=null; 138 for(let attempt=0;attempt<8;attempt++){ 139 context.clearRect(0,0,canvas.width,canvas.height);context.drawImage(bitmap,0,0,canvas.width,canvas.height); 140 blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/webp',Math.max(.5,.88-attempt*.07))); 141 if(blob&&blob.size<=250*1024)break; 142 if(attempt>=3){canvas.width=Math.max(1,Math.round(canvas.width*.8));canvas.height=Math.max(1,Math.round(canvas.height*.8))} 143 } 144 if(!blob||blob.size>250*1024)throw Error('La photo reste trop lourde. Essaie une image plus petite.'); 145 const data=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.onerror=()=>reject(Error('La photo nâa pas pu être lue.'));reader.readAsDataURL(blob)}); 146 if(version!==imageVersion||mount!==mountVersion||!connected())return; 147 imagePreview='data:'+blob.type+';base64,'+data;imageDraft={mime:blob.type,data};updatePreview();feedback.textContent='Photo prête · '+Math.ceil(blob.size/1024)+' Ko. Vérifie que tu peux publier cette image.'; 148 }catch(error){if(version===imageVersion&&mount===mountVersion&&connected())feedback.textContent=error.message} 149 finally{bitmap?.close();if(version===imageVersion)imageBusy=false} 150 } 151 function showConflict(){ 152 const target=organizerSlot?.querySelector('.events-conflict');
152if(!target||!conflict)return; 153 target.hidden=false;target.innerHTML='<p>Ce rendez-vous a été modifié ailleurs. Tes champs sont conservés. Vérifie la dernière fiche avant dâenregistrer à nouveau.</p><button type="button" class="text-link" data-event-conflict-rebase>Conserver mes champs avec la dernière version</button><button type="button" class="text-link" data-event-conflict-load>Charger la fiche actuellement enregistrée</button>'; 154 } 155 async function save(form){ 156 if(adminBusy)return;if(imageBusy){editorFeedback('Attends que la photo soit prête avant dâenregistrer.');return} 157 const values=Object.fromEntries(new FormData(form));delete values.photo; 158 if(values.end&&values.end<=values.start){editorFeedback('Lâheure de fin doit être après lâheure de début, le même jour.');form.elements.end.focus();return} 159 if(values.bookingUrl&&!bookingUrl(values.bookingUrl)){editorFeedback('Le lien doit commencer par https:// et ne doit pas contenir dâidentifiants.');form.elements.bookingUrl.focus();return} 160 if(imageDraft!==undefined)values.image=imageDraft; 161 const id=editorId,version=mountVersion; 162 if(id)values.revision=editorRevision; 163 const focused=document.activeElement; 164 adminBusy=true;form.querySelectorAll('input,textarea,select,button').forEach(control=>control.disabled=true);editorFeedback('Enregistrement du rendez-vousâ¦'); 165 try{ 166 const result=await api(id?'/api/events/'+encodeURIComponent(id)+'/update':'/api/events/create',values); 167 if(version!==mountVersion||!connected())return; 168 await loadAdmin();if(version!==mountVersion||!connected())return; 169 renderEditor(result.event);editorFeedback('Rendez-vous enregistré · '+(statuses[result.event.status]||''));organizerSlot.querySelector('#alt-event-form [type=submit]')?.focus({preventScroll:true});refresh();document.dispatchEvent(new Event('alt-events-changed')); 170 }catch(error){ 171 if(version!==mountVersion||!connected())return; 172 if(error.status===409&&id){await loadAdmin();const current=organizerEvents.find(event=>event.id===id);if(version!==mountVersion||!connected())return;conflict=current?{id,revision:current.revision}:null;showConflict()} 173 editorFeedback(error.message+' Tes champs restent dans le formulaire.'); 174 }finally{if(version===mountVersion){adminBusy=false;if(form.isConnected){form.querySelectorAll('input,textarea,select,button').forEach(control=>control.disabled=false);if(focused?.isConnected&&form.contains(focused))focused.focus({preventScroll:true})}}} 175 } 176 async function changeStatus(button){ 177 if(adminBusy)return;const event=organizerEvents.find(item=>item.id===button.dataset.eventStatus);if(!event)return; 178 const version=mountVersion;adminBusy=true;button.disabled=true;adminFeedback('Mise à jour de la visibilitéâ¦'); 179 try{ 180 const result=await api('/api/events/'+encodeURIComponent(event.id)+'/status',{status:button.dataset.status,revision:event.revision}); 181 if(version!==mountVersion||!connected())return; 182 organizerEvents=organizerEvents.map(item=>item.id===event.id?result.event:item); 183 await loadAdmin();if(version!==mountVersion||!connected())return; 184 const form=organizerSlot.querySelector('#alt-event-form'); 185 if(editorId===event.id&&form){ 186 editorRevision=result.event.revision;form.elements.status.value=result.event.status;conflict=null; 187 const notice=form.querySelector('.events-conflict');if(notice){notice.hidden=true;notice.innerHTML=''} 188 updatePreview(); 189 } 190 [...organizerSlot.querySelectorAll('[data-event-edit]')].find(item=>item.dataset.eventEdit===event.id)?.focus({preventScroll:true}); 191 adminFeedback('Visibilité mise à jour.');refresh();document.dispatchEvent(new Event('alt-events-changed')); 192 } 193 catch(error){if(version!==mountVersion||!connected())return;if(error.status===409)await loadAdmin();adminFeedback(error.message+(error.status===409?' La liste a été actualisée ; vérifie le rendez-vous avant de réessayer.':''))} 194 finally{if(version===mountVersion){adminBusy=false;if(button.isConnected)button.disabled=false}} 195 } 196 function mount(user){ 197 mountVersion++;adminRequest++;clearImage();adminBusy=false;editorId=null;editorRevision=null;conflict=null;organizerEvents=[]; 198 organizerSlot=null;document.querySelector('#events-organizer')?.remove();if(!user)return; 199 const content=document.querySelector('#account-content');if(!content)return; 200 organizerSlot=document.createElement('section');organizerSlot.id='events-organizer';organizerSlot.hidden=true;organizerSlot.setAttribute('aria-label','Gestion des événements ALT'); 201 (content.querySelector('.account-summary')||content.lastElementChild)?.before(organizerSlot); 202 organizerSlot.addEventListener('click',event=>{ 203 if(adminBusy)return; 204 const edit=event.target.closest('[data-event-edit]'),status=event.target.closest('[data-event-status]'); 205 if(event.target.closest('[data-event-new]'))renderEditor(); 206 if(edit){const item=organizerEvents.find(item=>item.id===edit.dataset.eventEdit);
206if(item)renderEditor(item)} 207 if(status)changeStatus(status); 208 if(event.target.closest('[data-events-admin-refresh]'))loadAdmin(); 209 if(event.target.closest('[data-event-editor-close]')){clearImage();organizerSlot.querySelector('#events-editor').innerHTML='';editorId=null;conflict=null;organizerSlot.querySelector('[data-event-new]').focus({preventScroll:true})} 210 if(event.target.closest('[data-event-image-remove]')){clearImage();imageDraft=null;organizerSlot.querySelector('input[name=photo]').value='';updatePreview();organizerSlot.querySelector('.events-image-feedback').textContent='Le visuel de lâassociation sera utilisé à lâenregistrement.'} 211 if(event.target.closest('[data-event-conflict-rebase]')&&conflict){editorRevision=conflict.revision;conflict=null;organizerSlot.querySelector('.events-conflict').hidden=true;editorFeedback('Tes champs sont conservés. Enregistre pour appliquer tes modifications à la dernière version.')} 212 if(event.target.closest('[data-event-conflict-load]')&&conflict){const item=organizerEvents.find(item=>item.id===conflict.id);if(item)renderEditor(item)} 213 }); 214 organizerSlot.addEventListener('change',event=>{if(event.target.name==='photo')prepareImage(event.target.files[0]);if(event.target.name==='imageKey')updatePreview()}); 215 organizerSlot.addEventListener('submit',event=>{if(event.target.id==='alt-event-form'){event.preventDefault();save(event.target)}}); 216 loadAdmin({initial:true}); 217 } 218 document.addEventListener('alt-account-rendered',event=>mount(event.detail?.user)); 219 if(document.querySelector('#account-welcome'))mount(true); 220 account?.addEventListener('close',()=>{imageVersion++;imageBusy=false}); 221 document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()}); 222 setInterval(()=>{if(!document.hidden)refresh()},60000); 223 window.ALTEvents=Object.freeze({refresh}); 224 renderPublic(); 225 refresh(); 226})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.