PageSourceSearch

https://attrapez-les-tous-alt.attrapezlestous-alt.workers.dev/events.js?v=alt-events-1

js attrapez-les-tous-alt.attrapezlestous-alt.workers.dev collected 2026-10-03 05:50:42 UTC 28,909 bytes, 226 lines download raw bytes

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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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.