1(function () { 2 'use strict'; 3 4 const cfg = window.VisaAgendaConfig || {}; 5 6 function isSafariBrowser() { 7 const ua = navigator.userAgent || ''; 8 const vendor = navigator.vendor || ''; 9 const excluded = /CriOS|Chrome|Chromium|FxiOS|Firefox|EdgiOS|Edg\/|OPiOS|OPR\/|DuckDuckGo/i.test(ua); 10 return /Safari/i.test(ua) && /Apple/i.test(vendor) && !excluded; 11 } 12 13 function isMobileSafari() { 14 const ua = navigator.userAgent || ''; 15 const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); 16 return isIOS && isSafariBrowser(); 17 } 18 19 if (isSafariBrowser()) { 20 document.documentElement.classList.add('va-safari'); 21 } 22 if (isMobileSafari()) { 23 document.documentElement.classList.add('va-mobile-safari'); 24 } 25 26 function decodeEntities(value) { 27 const txt = document.createElement('textarea'); 28 txt.innerHTML = String(value == null ? '' : value); 29 return txt.value; 30 } 31 32 function esc(value) { 33 return decodeEntities(value) 34 .replace(/&/g, '&') 35 .replace(/</g, '<') 36 .replace(/>/g, '>') 37 .replace(/"/g, '"') 38 .replace(/'/g, '''); 39 } 40 41 function weekdayLabel(iso, lang) { 42 try { 43 const d = new Date(iso + 'T12:00:00'); 44 return new Intl.DateTimeFormat(lang === 'en' ? 'en-GB' : 'fr-FR', { weekday: 'short' }).format(d).replace('.', ''); 45 } catch (e) { 46 return ''; 47 } 48 } 49 50 function monthLabel(iso, lang) { 51 try { 52 const d = new Date(iso + 'T12:00:00'); 53 return new Intl.DateTimeFormat(lang === 'en' ? 'en-GB' : 'fr-FR', { month: 'short' }).format(d).replace('.', ''); 54 } catch (e) { 55 return ''; 56 } 57 } 58 59 function longDateLabel(iso, lang) { 60 try { 61 const d = new Date(iso + 'T12:00:00'); 62 return new Intl.DateTimeFormat(lang === 'en' ? 'en-GB' : 'fr-FR', { 63 weekday: 'long', day: 'numeric', month: 'long' 64 }).format(d); 65 } catch (e) { 66 return ''; 67 } 68 } 69 70 71 72 function clockIcon() { 73 return `<svg class="va-icon va-icon-clock" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3.5 2"></path></svg>`; 74 } 75 76 function pinIcon() { 77 return `<svg class="va-icon va-icon-pin" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s6-5.2 6-11a6 6 0 1 0-12 0c0 5.8 6 11 6 11Z"></path><circle cx="12" cy="10" r="2.2"></circle></svg>`; 78 } 79 80 function liveIcon() { 81 return `<span class="va-live-icon" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><circle cx="12" cy="12" r="2.2" fill="currentColor"></circle><path d="M7.7 7.7a6 6 0 0 0 0 8.6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path><path d="M16.3 7.7a6 6 0 0 1 0 8.6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path><path d="M4.8 4.8a10 10 0 0 0 0 14.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" opacity=".8"></path><path d="M19.2 4.8a10 10 0 0 1 0 14.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" opacity=".8"></path></svg></span>`; 82 } 83 84 function splitTimeLabel(label) { 85 if (!label) return ''; 86 const parts = decodeEntities(label).split(/\s+[ââ-]\s+/); 87 if (parts.length > 1) { 88 return `<span class="va-time-start">${esc(parts[0])}</span><span class="va-time-sep">â</span><span class="va-time-end">${esc(parts.slice(1).join(' â '))}</span>`; 89 } 90 return esc(label); 91 } 92 93 94 function contrastColor(hex) { 95 const value = String(hex || '').replace('#', ''); 96 if (!/^[0-9a-f]{6}$/i.test(value)) return '#111111'; 97 const r = parseInt(value.slice(0, 2), 16); 98 const g = parseInt(value.slice(2, 4), 16); 99 const b = parseInt(value.slice(4, 6), 16); 100 const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; 101 return lum > 0.58 ? '#111111' : '#ffffff'; 102 } 103 104 function logoPlaceholder(baseClass, visualLabel) { 105 return `<div class="${baseClass} va-event-placeholder" aria-hidden="true"><small>${esc(visualLabel)}</small></div>`; 106 } 107 108 function backgroundAttrs(e) { 109 const bg = String((e && e.backgroundColor) || ''); 110 if (!/^#[0-9a-f]{6}$/i.test(bg)) return { cls: '', style: '' }; 111 const fg = contrastColor(bg); 112 return { 113 cls: ' has-custom-bg', 114 style: ` style="--va-event-bg:${esc(bg)};--va-event-fg:${esc(fg)}"` 115 }; 116 } 117 118 class Agenda { 119 constructor(root) { 120 this.root = root; 121 this.lang = root.dataset.lang === 'en' ? 'en' : 'fr'; 122 this.str = (cfg.strings && cfg.strings[this.lang]) || {}; 123 this.state = { 124 mode: root.dataset.mode || 'auto', 125 date: '', 126 type: '', 127 place: '', 128 photographer: '', 129 exhibition: '' 130 };
131 this.showFilters = root.dataset.showFilters !== 'no'; 132 this.showNow = root.dataset.showNow !== 'no'; 133 this.updateUrl = root.dataset.updateUrl !== 'no'; 134 this.status = root.querySelector('.va-status'); 135 this.modeNav = root.querySelector('.va-mode-nav'); 136 this.dateNav = root.querySelector('.va-date-nav'); 137 this.filters = root.querySelector('.va-filters'); 138 this.results = root.querySelector('.va-results'); 139 this.timer = null; 140 this.clockTimer = null; 141 this.interacted = false; 142 this.hasUserState = false; 143 this.expandedSchedule = false; 144 this.expandedContinuous = false; 145 this.continuousSectionOpen = null; 146 this.initialEvents = Math.max(1, parseInt(root.dataset.initialEvents || '4', 10) || 4); 147 this.initialContinuous = Math.max(1, parseInt(root.dataset.initialContinuous || '3', 10) || 3); 148 this.visitorToken = this.getVisitorToken(); 149 this.readUrl(); 150 this.load(); 151 this.timer = window.setInterval(() => { 152 if (this.state.mode === 'now' || this.state.mode === 'day' || this.state.mode === 'today' || this.state.mode === 'auto') { 153 this.load(true); 154 } 155 }, 60000); 156 this.clockTimer = window.setInterval(() => this.updateProgressGauges(), 1000); 157 } 158 159 readUrl() { 160 if (!this.updateUrl) return; 161 const q = new URLSearchParams(window.location.search); 162 const mode = q.get('va_mode'); 163 const date = q.get('va_date'); 164 const type = q.get('va_type'); 165 const place = q.get('va_place'); 166 const photographer = q.get('va_photographer'); 167 const exhibition = q.get('va_exhibition'); 168 if (mode) { this.state.mode = mode; this.hasUserState = true; } 169 if (date) { this.state.date = date; this.hasUserState = true; } 170 if (type) { this.state.type = type; this.hasUserState = true; } 171 if (place) { this.state.place = place; this.hasUserState = true; } 172 if (photographer) { this.state.photographer = photographer; this.hasUserState = true; } 173 if (exhibition) { this.state.exhibition = exhibition; this.hasUserState = true; } 174 if ((photographer || type || place || exhibition) && !mode) this.state.mode = 'week'; 175 } 176 177 writeUrl() { 178 if (!this.updateUrl || (!this.interacted && !this.hasUserState) || !window.history || !window.history.replaceState) return; 179 const url = new URL(window.location.href); 180 ['va_mode', 'va_date', 'va_type', 'va_place', 'va_photographer', 'va_exhibition'].forEach(k => url.searchParams.delete(k)); 181 if (this.state.mode && this.state.mode !== 'auto') url.searchParams.set('va_mode', this.state.mode); 182 if (this.state.date && (this.state.mode === 'day' || this.state.mode === 'week')) url.searchParams.set('va_date', this.state.date); 183 if (this.state.type) url.searchParams.set('va_type', this.state.type); 184 if (this.state.place) url.searchParams.set('va_place', this.state.place); 185 if (this.state.photographer) url.searchParams.set('va_photographer', this.state.photographer); 186 if (this.state.exhibition) url.searchParams.set('va_exhibition', this.state.exhibition); 187 window.history.replaceState({}, '', url.toString()); 188 } 189 190 async load(silent) { 191 if (!silent) { 192 this.status.textContent = this.str.loading || 'Chargementâ¦'; 193 this.root.classList.add('is-loading'); 194 } 195 const url = new URL(cfg.restUrl, window.location.origin); 196 url.searchParams.set('mode', this.state.mode || 'auto'); 197 url.searchParams.set('lang', this.lang); 198 url.searchParams.set('_va_v', cfg.version || '1'); 199 if (this.visitorToken) url.searchParams.set('visitor_token', this.visitorToken); 200 if (this.state.date) url.searchParams.set('date', this.state.date); 201 if (this.state.type) url.searchParams.set('type', this.state.type); 202 if (this.state.place) url.searchParams.set('place', this.state.place); 203 if (this.state.photographer) url.searchParams.set('photographer', this.state.photographer); 204 if (this.state.exhibition) url.searchParams.set('exhibition', this.state.exhibition); 205
206 try { 207 const res = await fetch(url.toString(), { credentials: 'same-origin', cache: 'no-store', headers: { 'Accept': 'application/json' } }); 208 if (!res.ok) throw new Error('HTTP ' + res.status); 209 const data = await res.json(); 210 this.normalizeFilters(data); 211 this.state.mode = data.mode || this.state.mode; 212 this.state.date = data.selectedDate || this.state.date; 213 this.render(data); 214 this.writeUrl(); 215 this.status.textContent = ''; 216 } catch (e) { 217 if (!silent) this.status.textContent = this.str.error || 'Erreur de chargement.'; 218 } finally { 219 this.root.classList.remove('is-loading'); 220 } 221 } 222 223 normalizeFilters(data) { 224 const normalizeOne = (value, terms) => { 225 if (!value) return ''; 226 const found = (terms || []).find(t => String(t.id) === String(value) || String(t.slug) === String(value)); 227 return found ? String(found.slug) : ''; 228 }; 229 this.state.type = normalizeOne(this.state.type, data.types || []); 230 this.state.place = normalizeOne(this.state.place, data.places || []); 231 this.state.photographer = normalizeOne(this.state.photographer, data.photographers || []); 232 this.state.exhibition = normalizeOne(this.state.exhibition, data.exhibitions || []); 233 } 234 235 render(data) { 236 this.currentDate = data.currentDate || ''; 237 this.renderModes(data); 238 this.renderDates(data); 239 this.renderFilters(data); 240 this.renderEvents(data); 241 } 242 243 renderModes(data) { 244 if (!this.showNow) { 245 this.modeNav.innerHTML = ''; 246 return; 247 } 248 const currentDate = data.currentDate || ''; 249 const nowActive = this.state.mode === 'now'; 250 const todayActive = this.state.mode === 'day' && this.state.date === currentDate; 251 const liveCount = Math.max(0, parseInt(data.liveCount || '0', 10) || 0); 252 const liveSignal = liveCount > 0 253 ? `<span class="va-now-live-signal" aria-hidden="true"><span class="va-now-live-pill"><span class="va-now-live-icon"><svg viewBox="0 0 24 24" focusable="false"><circle cx="12" cy="12" r="2.4" fill="currentColor"></circle><path d="M8.2 8.2a5.4 5.4 0 0 0 0 7.6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path><path d="M15.8 8.2a5.4 5.4 0 0 1 0 7.6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path><path d="M5.2 5.2a9.5 9.5 0 0 0 0 13.6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".82"></path><path d="M18.8 5.2a9.5 9.5 0 0 1 0 13.6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".82"></path></svg></span><span class="va-now-live-text">LIVE</span></span></span>` 254 : ''; 255 this.modeNav.innerHTML = ` 256 <button type="button" class="va-mode va-mode-now ${nowActive ? 'is-active' : ''}${liveCount > 0 ? ' has-live' : ''}" data-mode="now">${liveSignal}<span class="va-mode-label">${esc(this.str.now || 'Maintenant')}</span></button> 257 <button type="button" class="va-mode ${todayActive ? 'is-active' : ''}" data-mode="today">${esc(this.str.today || "Aujourdâhui")}</button>`; 258 this.modeNav.querySelectorAll('button').forEach(btn => { 259 btn.addEventListener('click', () => { 260 this.interacted = true; 261 if (this.state.mode !== btn.dataset.mode) { 262 this.continuousSectionOpen = null; 263 } 264 this.state.mode = btn.dataset.mode; 265 this.state.date = ''; 266 this.expandedSchedule = false; 267 this.expandedContinuous = false; 268 this.continuousSectionOpen = null; 269 this.load(); 270 }); 271 }); 272 } 273 274 renderDates(data) { 275 if (this.state.mode === 'now') { 276 this.dateNav.innerHTML = ''; 277 return; 278 } 279 const dates = data.dates || []; 280 const counts = data.dayCounts || {}; 281 const transverseFilterActive = !!(this.state.type || this.state.place || this.state.photographer || this.state.exhibition); 282 const weekStart = data.weekStart || ''; 283 const weekEnd = data.weekEnd || ''; 284 this.dateNav.innerHTML = dates.map(d => { 285 const active = this.state.mode === 'day' && d.value === this.state.date; 286 const inWeek = this.state.mode === 'week' && weekStart && weekEnd && d.value >= weekStart && d.value <= weekEnd; 287 const day = d.value ? parseInt(d.value.slice(8, 10), 10) : ''; 288 const count = transverseFilterActive ? Math.max(0, parseInt(counts[d.value] || '0', 10) || 0) : 0; 289 const badge = count >
289 0 ? `<span class="va-date-badge" aria-label="${esc(String(count))} ${esc(this.lang === 'en' ? (count === 1 ? 'event' : 'events') : (count === 1 ? 'événement' : 'événements'))}">${esc(String(count))}</span>` : ''; 290 return `<button type="button" class="va-date ${active ? 'is-active' : ''}${inWeek ? ' is-week' : ''}" data-date="${esc(d.value)}"><span class="va-date-dow">${esc(weekdayLabel(d.value, this.lang))}</span><strong>${esc(day)}</strong><span class="va-date-month">${esc(monthLabel(d.value, this.lang))}</span>${badge}</button>`; 291 }).join(''); 292 this.dateNav.querySelectorAll('button').forEach(btn => { 293 btn.addEventListener('click', () => { 294 this.interacted = true; 295 this.state.mode = 'day'; 296 this.state.date = btn.dataset.date; 297 this.expandedSchedule = false; 298 this.expandedContinuous = false; 299 this.continuousSectionOpen = null; 300 this.load(); 301 }); 302 }); 303 const active = this.dateNav.querySelector('.is-active') || this.dateNav.querySelector('.is-week .va-date-badge')?.closest('.va-date'); 304 if (active) { 305 // Ne jamais utiliser scrollIntoView ici : Safari iOS peut faire défiler 306 // horizontalement la page entière au lieu du seul rail de dates. 307 // On déplace exclusivement .va-date-nav. 308 const nav = this.dateNav; 309 requestAnimationFrame(() => { 310 const maxLeft = Math.max(0, nav.scrollWidth - nav.clientWidth); 311 const wanted = active.offsetLeft - ((nav.clientWidth - active.offsetWidth) / 2); 312 nav.scrollLeft = Math.max(0, Math.min(maxLeft, wanted)); 313 // Le rail bouge, jamais la page entière. 314 requestAnimationFrame(resetPageHorizontalScroll); 315 }); 316 } 317 } 318 319 renderFilters(data) { 320 if (!this.showFilters) { 321 this.filters.innerHTML = ''; 322 return; 323 } 324 const collator = new Intl.Collator(this.lang === 'en' ? 'en' : 'fr', { sensitivity: 'base', numeric: true }); 325 const alpha = (items, labelKey = 'name') => [...(items || [])].sort((a, b) => collator.compare(String(a[labelKey] || ''), String(b[labelKey] || ''))); 326 const withCount = item => `${item.name || item.title || ''} (${Math.max(0, parseInt(item.count || '0', 10) || 0)})`; 327 const types = alpha(data.types || []); 328 const places = alpha(data.places || []); 329 const photographers = alpha(data.photographers || []); 330 const exhibitions = alpha(data.exhibitions || [], 'title'); 331 const typeOptions = [`<option value="">${esc(this.str.type || 'Tous les types')}</option>`] 332 .concat(types.map(t => `<option value="${esc(t.slug)}" ${String(this.state.type) === String(t.slug) ? 'selected' : ''}>${esc(withCount(t))}</option>`)).join(''); 333 const placeOptions = [`<option value="">${esc(this.str.place || 'Tous les lieux')}</option>`] 334 .concat(places.map(p => `<option value="${esc(p.slug)}" ${String(this.state.place) === String(p.slug) ? 'selected' : ''}>${esc(withCount(p))}</option>`)).join(''); 335 const photographerOptions = [`<option value="">${esc(this.str.photographer || 'Tous les photographes')}</option>`] 336 .concat(photographers.map(p => `<option value="${esc(p.slug)}" ${String(this.state.photographer) === String(p.slug) ? 'selected' : ''}>${esc(withCount(p))}</option>`)).join(''); 337 const exhibitionOptions = [`<option value="">${esc(this.str.exhibition || 'Toutes les expositions')}</option>`] 338 .concat(exhibitions.map(e => `<option value="${esc(e.slug)}" ${String(this.state.exhibition) === String(e.slug) ? 'selected' : ''}>${esc(withCount(e))}</option>`)).join(''); 339 const available = data.availableFilters || {}; 340 const showPhotographer = available.photographer !== false && photographers.length > 0; 341 const showExhibition = available.exhibition !== false && exhibitions.length > 0; 342 const controls = [ 343 `<label><span class="screen-reader-text">${esc(this.str.type || 'Type')}</span><select class="va-filter-type">${typeOptions}</select></label>`, 344 `<label><span class="screen-reader-text">${esc(this.str.place || 'Lieu')}</span><select class="va-filter-place">${placeOptions}</select></label>` 345 ]; 346 if (showPhotographer) { 347 controls.push(`<label><span class="screen-reader-text">${esc(this.str.photographer || 'Photographe')}</span><select class="va-filter-photographer">${photographerOptions}</select></label>`); 348 } 349 if (showExhibition) { 350 controls.push(`<label><span class="screen-reader-text">${esc(this.str.exhibition || 'Exposition')}</span><select class="va-filter-exhibition">${exhibitionOptions}</select></label>`); 351 } 352 this.filters.innerHTML = controls.join(''); 353 const type = this.filters.querySelector('.va-filter-type'); 354 const place = this.filters.querySelector('.va-filter-place'); 355 const photographer = this.filters.querySelector('.va-filter-photographer'); 356 const exhibition = this.filters.querySelector('.va-filter-exhibition'); 357 const applyTransverseFilters = () => { 358 this.expandedSchedule = false;
359 this.expandedContinuous = false; 360 this.continuousSectionOpen = null; 361 const hasFilter = !!(this.state.type || this.state.place || this.state.photographer || this.state.exhibition); 362 if (hasFilter) { 363 // Tous les filtres sont transversaux : on cherche automatiquement la 364 // première semaine contenant l'intersection active, puis on affiche 365 // tous les rendez-vous correspondants de cette semaine. 366 this.state.mode = 'week'; 367 this.state.date = ''; 368 } else if (this.state.mode === 'week') { 369 this.state.mode = 'day'; 370 } 371 this.load(); 372 }; 373 374 type.addEventListener('change', () => { 375 this.interacted = true; 376 this.state.type = type.value; 377 applyTransverseFilters(); 378 }); 379 place.addEventListener('change', () => { 380 this.interacted = true; 381 this.state.place = place.value; 382 applyTransverseFilters(); 383 }); 384 if (photographer) photographer.addEventListener('change', () => { 385 this.interacted = true; 386 this.state.photographer = photographer.value; 387 applyTransverseFilters(); 388 }); 389 if (exhibition) exhibition.addEventListener('change', () => { 390 this.interacted = true; 391 this.state.exhibition = exhibition.value; 392 applyTransverseFilters(); 393 }); 394 } 395 396 renderEvents(data) { 397 const events = data.events || []; 398 if (!events.length) { 399 const message = this.state.mode === 'now' 400 ? (this.str.emptyNow || (this.lang === 'en' ? 'No event is currently in progress.' : 'Aucun événement en cours.')) 401 : (this.str.empty || (this.lang === 'en' ? 'No event for this selection.' : 'Aucun événement.')); 402 this.results.innerHTML = `<div class="va-empty">${esc(message)}</div>`; 403 return; 404 } 405 406 if (this.state.mode === 'week' && (this.state.type || this.state.place || this.state.photographer || this.state.exhibition)) { 407 this.renderWeekEvents(data, events); 408 return; 409 } 410 411 const isNow = this.state.mode === 'now'; 412 const title = isNow 413 ? (this.str.current || 'EN COURS') 414 : longDateLabel(data.selectedDate || this.state.date, this.lang); 415 const kicker = isNow 416 ? (this.lang === 'en' ? 'Live programme' : 'Programme en direct') 417 : (this.lang === 'en' ? 'Programme' : 'Programme'); 418 const cancelledCount = events.filter(e => e.cancelled).length; 419 const baseCountLabel = this.lang === 'en' 420 ? `${events.length} event${events.length > 1 ? 's' : ''}` 421 : `${events.length} événement${events.length > 1 ? 's' : ''}`; 422 const countLabel = cancelledCount > 0 423 ? `${baseCountLabel} · ${cancelledCount} ${this.lang === 'en' ? (cancelledCount === 1 ? 'cancelled' : 'cancelled') : (cancelledCount === 1 ? 'annulé' : 'annulés')}` 424 : baseCountLabel; 425 426 const scheduled = events.filter(e => e.displayGroup !== 'continuous'); 427 const continuous = events.filter(e => e.displayGroup === 'continuous'); 428 429 const groupHeading = (label, count, kind, open = true) => { 430 if (kind === 'continuous') { 431 return `<div class="va-group-head va-group-head-continuous"> 432 <button type="button" class="va-continuous-toggle" aria-expanded="${open ? 'true' : 'false'}"> 433 <span class="va-continuous-head-title">${esc(label)}</span> 434 <span class="va-continuous-head-count">${esc(String(count))}</span> 435 <span class="va-continuous-chevron" aria-hidden="true"></span> 436 </button> 437 </div>`; 438 } 439 return `<div class="va-group-head va-group-head-${kind}"> 440 <h3>${esc(label)}</h3> 441 <span>${esc(String(count))}</span> 442 </div>`; 443 }; 444 445 let groups = ''; 446 if (scheduled.length) { 447 const label = isNow 448 ? (this.str.nowAppointments || (this.lang === 'en' ? 'Happening now' : 'Rendez-vous en cours')) 449 : (this.str.appointments || (this.lang === 'en' ? "Today's schedule" : 'Rendez-vous du jour')); 450 const hiddenCount = Math.max(0, scheduled.length - this.initialEvents); 451 const cards = scheduled.map((e, i) => { 452 const hidden = !this.expandedSchedule && i >= this.initialEvents; 453 return this.eventCard(e, hidden ? ' va-more-hidden' : ''); 454 }).join(''); 455 const more = hiddenCount > 0 ? this.moreButton('schedule', hiddenCount, this.expandedSchedule) : ''; 456 groups += `<section class="va-agenda-group va-group-schedule"> 457 ${groupHeading(label, scheduled.length, 'schedule')} 458 <div class="va-events">${cards}</div> 459 ${more} 460 </section>`; 461 } 462 463 if (continuous.length) { 464 const label = isNow 465 ? (this.str.nowContinuous || (this.lang === 'en' ? 'Open now' : 'Ouvert en continu')) 466 : (this.str.continuous || (this.lang === 'en' ? 'Open throughout the day' : 'En continu ce jour')); 467 // En mode « Maintenant », un événement en continu réellement actif 468 // doit être visible immédiatement. Pour les journées normales : 469 // 1â2 activités = repliées par défaut, 3+ = ouvertes par défaut. 470 const defaultSectionOpen = isNow || continuous.length >= 3; 471 const sectionOpen = this.continuousSectionOpen === null ? defaultSectionOpen : this.continuousSectionOpen; 472 const hiddenCount = Math.max(0, continuous.length - this.initialContinuous); 473 const cards = continuous.map((e, i) => { 474 const hidden = !this.expandedContinuous && i >= this.initialContinuous; 475 return this.continuousCard(e, hidden ? ' va-more-hidden' : ''); 476 }).join(''); 477 const more = hiddenCount > 0 ? this.moreButton('continuous', hiddenCount, this.expandedContinuous) : ''; 478 groups += `<section class="va-agenda-group va-group-continuous${sectionOpen ? ' is-open' : ' is-collapsed'}"> 479 ${groupHeading(label, continuous.length, 'continuous', sectionOpen)} 480 <div class="va-continuous-body"${sectionOpen ? '' : ' hidden'}>
481 <div class="va-continuous-grid${continuous.length === 1 ? ' is-single' : ''}">${cards}</div> 482 ${more} 483 </div> 484 </section>`; 485 } 486 487 this.results.innerHTML = ` 488 <div class="va-section-head"> 489 <div> 490 <span class="va-section-kicker">${esc(kicker)}</span> 491 <h2 class="va-section-title">${esc(title)}</h2> 492 </div> 493 <div class="va-section-count">${esc(countLabel)}</div> 494 </div> 495 ${groups}`; 496 497 this.results.querySelectorAll('.va-continuous-toggle').forEach(btn => { 498 btn.addEventListener('click', () => { 499 const group = btn.closest('.va-group-continuous'); 500 const body = group ? group.querySelector('.va-continuous-body') : null; 501 if (!group || !body) return; 502 const open = btn.getAttribute('aria-expanded') !== 'true'; 503 this.continuousSectionOpen = open; 504 btn.setAttribute('aria-expanded', open ? 'true' : 'false'); 505 body.hidden = !open; 506 group.classList.toggle('is-open', open); 507 group.classList.toggle('is-collapsed', !open); 508 }); 509 }); 510 511 this.results.querySelectorAll('.va-details-toggle').forEach(btn => { 512 btn.addEventListener('click', () => { 513 const card = btn.closest('.va-event, .va-continuous-card'); 514 const details = card.querySelector('.va-event-details'); 515 const open = card.classList.toggle('is-open'); 516 details.hidden = !open; 517 btn.textContent = open 518 ? (this.str.hideDetails || (this.lang === 'en' ? 'Close' : 'Réduire')) 519 : (this.str.details || (this.lang === 'en' ? 'Details' : 'Détails')); 520 btn.setAttribute('aria-expanded', open ? 'true' : 'false'); 521 }); 522 }); 523 524 this.results.querySelectorAll('.va-participate').forEach(btn => { 525 btn.addEventListener('click', () => this.toggleParticipation(btn)); 526 }); 527 528 this.results.querySelectorAll('.va-more-button').forEach(btn => { 529 btn.addEventListener('click', () => { 530 const kind = btn.dataset.group === 'continuous' ? 'continuous' : 'schedule'; 531 const group = btn.closest('.va-agenda-group'); 532 const isExpanded = kind === 'schedule' ? this.expandedSchedule : this.expandedContinuous; 533 const nextExpanded = !isExpanded; 534 if (kind === 'schedule') this.expandedSchedule = nextExpanded; 535 else this.expandedContinuous = nextExpanded; 536 537 group.querySelectorAll('.va-more-hidden').forEach(card => { 538 card.hidden = !nextExpanded; 539 }); 540 btn.classList.toggle('is-expanded', nextExpanded); 541 btn.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false'); 542 btn.innerHTML = this.moreButtonLabel(parseInt(btn.dataset.hiddenCount || '0', 10), nextExpanded); 543 544 if (!nextExpanded) { 545 const head = group.querySelector('.va-group-head'); 546 if (head && head.scrollIntoView) { 547 try { head.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) {} 548 } 549 } 550 }); 551 }); 552 } 553 554 renderWeekEvents(data, events) { 555 const photographer = (data.photographers || []).find(p => String(p.slug) === String(this.state.photographer) || String(p.id) === String(this.state.photographer)); 556 const type = (data.types || []).find(t => String(t.slug) === String(this.state.type) || String(t.id) === String(this.state.type)); 557 const place = (data.places || []).find(p => String(p.slug) === String(this.state.place) || String(p.id) === String(this.state.place)); 558 const exhibition = (data.exhibitions || []).find(e => String(e.slug) === String(this.state.exhibition) || String(e.id) === String(this.state.exhibition)); 559 const activeLabels = []; 560 if (type) activeLabels.push(type.name); 561 if (place) activeLabels.push(place.name); 562 if (photographer) activeLabels.push(photographer.name); 563 if (exhibition) activeLabels.push(exhibition.title); 564 const name = activeLabels.length ? activeLabels.join(' · ') : (this.lang === 'en' ? 'Weekly programme' : 'Programme de la semaine'); 565 const cancelledCount = events.filter(e => e.cancelled).length; 566 const baseCountLabel = this.lang === 'en' 567 ? `${events.length} event${events.length > 1 ? 's' : ''}` 568 : `${events.length} événement${events.length > 1 ? 's' : ''}`; 569 const countLabel = cancelledCount > 0 570 ? `${baseCountLabel} · ${cancelledCount} ${this.lang === 'en' ? (cancelledCount === 1 ? 'cancelled' : 'cancelled') : (cancelledCount === 1 ? 'annulé' : 'annulés')}` 571 : baseCountLabel;
572 const kicker = this.lang === 'en' ? 'Weekly programme' : 'Programme de la semaine'; 573 574 const grouped = {}; 575 events.forEach(e => { 576 const key = e.date || (e.start ? String(e.start).slice(0, 10) : ''); 577 if (!key) return; 578 if (!grouped[key]) grouped[key] = []; 579 grouped[key].push(e); 580 }); 581 582 const days = Object.keys(grouped).sort(); 583 const blocks = days.map(day => { 584 const items = grouped[day]; 585 const cards = items.map(e => this.eventCard(e)).join(''); 586 return `<section class="va-agenda-group va-week-day" data-week-date="${esc(day)}"> 587 <div class="va-group-head va-group-head-week"> 588 <h3>${esc(longDateLabel(day, this.lang))}</h3> 589 <span>${esc(String(items.length))}</span> 590 </div> 591 <div class="va-events">${cards}</div> 592 </section>`; 593 }).join(''); 594 595 this.results.innerHTML = ` 596 <div class="va-section-head va-week-head"> 597 <div> 598 <span class="va-section-kicker">${esc(kicker)}</span> 599 <h2 class="va-section-title">${esc(name)}</h2> 600 </div> 601 <div class="va-section-count">${esc(countLabel)}</div> 602 </div> 603 ${blocks}`; 604 605 this.results.querySelectorAll('.va-details-toggle').forEach(btn => { 606 btn.addEventListener('click', () => { 607 const card = btn.closest('.va-event, .va-continuous-card'); 608 const details = card ? card.querySelector('.va-event-details') : null; 609 if (!card || !details) return; 610 const open = card.classList.toggle('is-open'); 611 details.hidden = !open; 612 btn.textContent = open 613 ? (this.str.hideDetails || (this.lang === 'en' ? 'Close' : 'Réduire')) 614 : (this.str.details || (this.lang === 'en' ? 'Details' : 'Détails')); 615 btn.setAttribute('aria-expanded', open ? 'true' : 'false'); 616 }); 617 }); 618 this.results.querySelectorAll('.va-participate').forEach(btn => { 619 btn.addEventListener('click', () => this.toggleParticipation(btn)); 620 }); 621 } 622
623 moreButtonLabel(count, expanded) { 624 if (expanded) { 625 return `<span class="va-more-symbol">â</span><span>${esc(this.str.showLess || (this.lang === 'en' ? 'Show less' : 'Afficher moins'))}</span>`; 626 } 627 const noun = this.lang === 'en' 628 ? (count === 1 ? 'more event' : 'more events') 629 : (count === 1 ? 'événement' : 'événements'); 630 return `<span class="va-more-symbol">+</span><span>${esc(String(count))} ${esc(noun)}</span>`; 631 } 632 633 moreButton(kind, hiddenCount, expanded) { 634 return `<div class="va-more-wrap"><button type="button" class="va-more-button${expanded ? ' is-expanded' : ''}" data-group="${esc(kind)}" data-hidden-count="${esc(String(hiddenCount))}" aria-expanded="${expanded ? 'true' : 'false'}">${this.moreButtonLabel(hiddenCount, expanded)}</button></div>`; 635 } 636 637 formatCountdown(ms) { 638 const totalSeconds = Math.max(0, Math.floor(ms / 1000)); 639 const hours = Math.floor(totalSeconds / 3600); 640 const minutes = Math.floor((totalSeconds % 3600) / 60); 641 const seconds = totalSeconds % 60; 642 const pad = n => String(n).padStart(2, '0'); 643 return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; 644 } 645 646 countdownLabel(ms) { 647 const value = this.formatCountdown(ms); 648 return this.lang === 'en' ? `${value} remaining` : `${value} restantes`; 649 } 650 651 progressGauge(e) { 652 if (!e.current || e.allDay || !e.start || !e.end) return ''; 653 const start = new Date(e.start).getTime(); 654 const end = new Date(e.end).getTime(); 655 const now = Date.now(); 656 const duration = end - start; 657 if (!Number.isFinite(start) || !Number.isFinite(end) || duration <= 0) return ''; 658 const percent = Math.max(0, Math.min(100, ((now - start) / duration) * 100)); 659 const remaining = Math.max(0, end - now); 660 const label = this.countdownLabel(remaining); 661 const aria = this.lang === 'en' 662 ? `${Math.round(percent)}% elapsed, ${label}` 663 : `${Math.round(percent)} % écoulés, ${label}`; 664 665 return `<div class="va-progress" data-va-start="${esc(e.start)}" data-va-end="${esc(e.end)}" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(percent)}" aria-label="${esc(aria)}"> 666 <div class="va-progress-head"><span class="va-progress-live">${liveIcon()}${esc(this.str.current || 'EN COURS')}</span></div> 667 <div class="va-progress-track"> 668 <span class="va-progress-fill" style="width:${percent.toFixed(2)}%"></span> 669 <span class="va-progress-dot" style="left:${percent.toFixed(2)}%" aria-hidden="true"></span> 670 </div> 671 <div class="va-progress-countdown">${esc(label)}</div> 672 </div>`; 673 } 674 675 updateProgressGauges() { 676 const gauges = this.results ? this.results.querySelectorAll('.va-progress[data-va-start][data-va-end]') : []; 677 gauges.forEach(gauge => { 678 const start = new Date(gauge.dataset.vaStart).getTime(); 679 const end = new Date(gauge.dataset.vaEnd).getTime(); 680 const now = Date.now(); 681 const duration = end - start; 682 if (!Number.isFinite(start) || !Number.isFinite(end) || duration <= 0) return; 683 const percent = Math.max(0, Math.min(100, ((now - start) / duration) * 100)); 684 const remaining = Math.max(0, end - now); 685 const label = this.countdownLabel(remaining); 686 const fill = gauge.querySelector('.va-progress-fill'); 687 const dot = gauge.querySelector('.va-progress-dot'); 688 const countdown = gauge.querySelector('.va-progress-countdown'); 689 if (fill) fill.style.width = `${percent.toFixed(2)}%`; 690 if (dot) dot.style.left = `${percent.toFixed(2)}%`; 691 if (countdown) countdown.textContent = label; 692 gauge.setAttribute('aria-valuenow', String(Math.round(percent))); 693 gauge.setAttribute('aria-label', this.lang === 'en' 694 ? `${Math.round(percent)}% elapsed, ${label}` 695 : `${Math.round(percent)} % écoulés, ${label}`); 696 if (remaining <= 0 && gauge.dataset.vaFinished !== '1') { 697 gauge.dataset.vaFinished = '1'; 698 window.setTimeout(() => this.load(true), 250); 699 } 700 }); 701 } 702 703 getVisitorToken() {
704 const key = 'visa_agenda_visitor_v1'; 705 try { 706 let token = window.localStorage ? window.localStorage.getItem(key) : ''; 707 if (token && /^[A-Za-z0-9_-]{16,128}$/.test(token)) return token; 708 token = (window.crypto && typeof window.crypto.randomUUID === 'function') 709 ? window.crypto.randomUUID().replace(/[^A-Za-z0-9_-]/g, '') 710 : 'va_' + Math.random().toString(36).slice(2) + Date.now().toString(36) + Math.random().toString(36).slice(2); 711 if (window.localStorage) window.localStorage.setItem(key, token); 712 return token; 713 } catch (e) { 714 return 'va_' + Math.random().toString(36).slice(2) + Date.now().toString(36) + Math.random().toString(36).slice(2); 715 } 716 } 717 718 participationButton(e) { 719 if (!e.participationEnabled) return ''; 720 const active = !!e.participating; 721 const count = Math.max(0, parseInt(e.participantCount || '0', 10) || 0); 722 const label = active ? (this.str.participating || 'Je participe') : (this.str.participate || 'Je participe'); 723 const countLabel = this.lang === 'en' ? `${count} potential attendee${count === 1 ? '' : 's'}` : `${count} participant${count === 1 ? '' : 's'} potentiel${count === 1 ? '' : 's'}`; 724 const mobileLabel = this.lang === 'en' ? 'Join' : 'Participer'; 725 const countHtml = count >
725 0 ? `<span class="va-participation-count" aria-hidden="true">${esc(String(count))}</span>` : ''; 726 return `<button type="button" class="va-participate ${active ? 'is-participating' : ''} ${count === 0 ? 'is-zero' : ''}" data-event-id="${esc(e.id)}" data-occurrence-key="${esc(e.key)}" data-participating="${active ? '1' : '0'}" aria-pressed="${active ? 'true' : 'false'}" aria-label="${esc(label + ' â ' + countLabel)}" title="${esc(countLabel)}"><span class="va-participate-inner"><span class="va-participate-check" aria-hidden="true">${active ? 'â' : '+'}</span><span class="va-label-desktop">${esc(label)}</span><span class="va-label-mobile">${esc(mobileLabel)}</span></span>${countHtml}</button>`; 727 } 728 729 async toggleParticipation(btn) { 730 if (!cfg.participationUrl || !this.visitorToken || btn.disabled) return; 731 const joining = btn.dataset.participating !== '1'; 732 btn.disabled = true; 733 btn.classList.add('is-saving'); 734 try { 735 const res = await fetch(cfg.participationUrl, { 736 method: 'POST', 737 credentials: 'same-origin', 738 cache: 'no-store', 739 headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, 740 body: JSON.stringify({ 741 event_id: parseInt(btn.dataset.eventId || '0', 10), 742 occurrence_key: btn.dataset.occurrenceKey || '', 743 visitor_token: this.visitorToken, 744 action: joining ? 'join' : 'leave' 745 }) 746 }); 747 if (!res.ok) throw new Error('HTTP ' + res.status); 748 const data = await res.json(); 749 const active = !!data.participating; 750 const count = Math.max(0, parseInt(data.count || '0', 10) || 0); 751 this.results.querySelectorAll('.va-participate').forEach(control => { 752 if (String(control.dataset.eventId || '') !== String(btn.dataset.eventId || '') || String(control.dataset.occurrenceKey || '') !== String(btn.dataset.occurrenceKey || '')) return; 753 control.dataset.participating = active ? '1' : '0'; 754 control.setAttribute('aria-pressed', active ? 'true' : 'false'); 755 control.classList.toggle('is-participating', active); 756 control.classList.toggle('is-zero', count === 0); 757 const check = control.querySelector('.va-participate-check'); 758 let countEl = control.querySelector('.va-participation-count'); 759 if (check) check.textContent = active ? 'â' : '+'; 760 if (count > 0) { 761 if (!countEl) { 762 countEl = document.createElement('span'); 763 countEl.className = 'va-participation-count'; 764 countEl.setAttribute('aria-hidden', 'true'); 765 control.appendChild(countEl); 766 } 767 countEl.textContent = String(count); 768 } else if (countEl) { 769 countEl.remove(); 770 } 771 const countLabel = this.lang === 'en' ? `${count} potential attendee${count === 1 ? '' : 's'}` : `${count} participant${count === 1 ? '' : 's'} potentiel${count === 1 ? '' : 's'}`; 772 const label = active ? (this.str.participating || 'Je participe') : (this.str.participate || 'Je participe'); 773 control.setAttribute('aria-label', `${label} â ${countLabel}`); 774 control.setAttribute('title', countLabel); 775 }); 776 } catch (e) { 777 this.status.textContent = this.str.participationError || (this.lang === 'en' ? 'Unable to save your participation.' : 'Impossible dâenregistrer votre participation.'); 778 window.setTimeout(() => { if (this.status) this.status.textContent = ''; }, 3000); 779 } finally { 780 btn.disabled = false; 781 btn.classList.remove('is-saving'); 782 } 783 } 784 785 continuousCard(e, extraClass = '') { 786 const type = e.types && e.types.length ? e.types[0].name : ''; 787 const place = e.places && e.places.length ? e.places.map(p => p.name).join(' · ') : ''; 788 const current = e.current ? `<span class="va-live">${liveIcon()}<span>${esc(this.str.current || 'EN COURS')}</span></span>` : ""; 789 const cancelled = !!e.cancelled; 790 const cancelledClass = cancelled ? ' is-cancelled' : ''; 791 const cancelledBadge = cancelled ? `<span class="va-cancelled-badge">${esc(this.str.cancelled || (this.lang === 'en' ? 'CANCELLED' : 'ANNULÃ'))}</span>` : ''; 792 const cancelledMessage = cancelled ? `<div class="va-cancelled-message">${esc(e.cancellationMessage || this.str.cancelledMessage || (this.lang === 'en' ? 'This event has been cancelled.' : 'Cet événement est annulé.'))}</div>` : ''; 793 const progress = cancelled ? '' : this.progressGauge(e); 794 const bg = backgroundAttrs(e); 795 const endedToday = e.ended && e.date === this.currentDate ? ' is-e
795nded-today' : ''; 796 const visualLabel = type || "Visa pour lâImage"; 797 const image = e.image 798 ? `<div class="va-continuous-image"><span class="va-photo-frame"><img class="va-event-photo" src="${esc(e.image)}" alt="${esc(e.title)}" loading="eager" decoding="async"></span></div>` 799 : logoPlaceholder('va-continuous-image', visualLabel); 800 const time = e.timeLabel ? `${clockIcon()}<span class="va-time-text">${splitTimeLabel(e.timeLabel)}</span>` : `<span class="va-time-text">${this.lang === 'en' ? 'All day' : 'Toute la journée'}</span>`; 801 const meta = [ 802 cancelledBadge, 803 type ? `<span class="va-meta va-type">${esc(type)}</span>` : '', 804 place ? `<span class="va-meta va-place">${pinIcon()}<span>${esc(place)}</span></span>` : '', 805 e.access ? `<span class="va-meta va-access">${esc(e.access)}</span>` : '' 806 ].filter(Boolean).join(''); 807 const hasDetails = !!(e.content && e.content.replace(/<[^>]*>/g, '').trim()); 808 const detailsUrl = (e.exposition && e.exposition.url) ? e.exposition.url : (e.photographerUrl || e.photographerExpositionUrl || ''); 809 const detailsButton = hasDetails 810 ? `<button type="button" class="va-details-toggle" aria-expanded="false">${esc(this.str.details || (this.lang === 'en' ? 'Details' : 'Détails'))}</button>` 811 : (detailsUrl ? `<a class="va-details-link" href="${esc(detailsUrl)}">${esc(this.str.details || (this.lang === 'en' ? 'Details' : 'Détails'))}</a>` : ''); 812 const external = !cancelled && e.url ? `<a class="va-action" href="${esc(e.url)}" target="_blank" rel="noopener">${esc(e.urlLabel || (this.lang === 'en' ? 'More information' : 'Plus dâinformations'))}</a>` : ''; 813 const participation = cancelled ? '' : this.participationButton(e); 814 const directions = !cancelled && e.mapsUrl ? `<a class="va-directions" href="${esc(e.mapsUrl)}" target="_blank" rel="noopener">${pinIcon()}<span>${esc(this.str.directions || (this.lang === 'en' ? 'Directions' : 'Itinéraire'))}</span></a>` : ''; 815 const calendarLabel = this.str.calendar || (this.lang === 'en' ? 'Add to calendar' : 'Ajouter à mon agenda'); 816 const calendarMobileLabel = this.lang === 'en' ? 'Calendar' : 'Agenda'; 817 const ics = cancelled ? '' : `<a class="va-calendar" href="${esc(e.ics)}"><span class="va-label-desktop">${esc(calendarLabel)}</span><span class="va-label-mobile">${esc(calendarMobileLabel)}</span></a>`; 818 819 return `<article class="va-continuous-card${cancelledClass}${endedToday}${bg.cls}${extraClass}"${bg.style}${extraClass.includes('va-more-hidden') ? ' hidden' : ''}> 820 ${image} 821 <div class="va-continuous-content"> 822 <div class="va-continuous-top"><span class="va-continuous-time">${time}</span>${current}</div> 823 ${progress} 824 <h3>${esc(e.title)}</h3> 825 <div class="va-event-meta">${meta}</div> 826 ${cancelledMessage} 827 <div class="va-event-actions">${detailsButton}${participation}${directions}${external}${ics}</div> 828 ${hasDetails ? `<div class="va-event-details" hidden>${e.content}</div>` : ''} 829 </div> 830 </article>`; 831 } 832 833 eventCard(e, extraClass = '') { 834 const type = e.types && e.types.length ? e.types[0].name : ''; 835 const place = e.places && e.places.length ? e.places.map(p => p.name).join(' · ') : ''; 836 const current = e.current ? `<span class="va-live">${liveIcon()}<span>${esc(this.str.current || 'EN COURS')}</span></span>` : ""; 837 const cancelled = !!e.cancelled; 838 const cancelledClass = cancelled ? ' is-cancelled' : ''; 839 const cancelledBadge = cancelled ? `<span class="va-cancelled-badge">${esc(this.str.cancelled || (this.lang === 'en' ? 'CANCELLED' : 'ANNULÃ'))}</span>` : ''; 840 const cancelledMessage = cancelled ? `<div class="va-cancelled-message">${esc(e.cancellationMessage || this.str.cancelledMessage || (this.lang === 'en' ? 'This event has been cancelled.' : 'Cet événement est annulé.'))}</div>` : ''; 841 const progress = cancelled ? '' : this.progressGauge(e); 842 const bg = backgroundAttrs(e); 843 const endedToday = e.ended && e.date === this.currentDate ? ' is-e
843nded-today' : ''; 844 const featured = e.featured ? ' is-featured' : ''; 845 const hasImage = e.image ? ' has-image' : ' no-image'; 846 const visualLabel = type || (this.lang === 'en' ? 'Visa pour lâImage' : 'Visa pour lâImage'); 847 const image = e.image 848 ? `<div class="va-event-image"><span class="va-photo-frame"><img class="va-event-photo" src="${esc(e.image)}" alt="${esc(e.title)}" loading="eager" decoding="async"></span></div>` 849 : logoPlaceholder('va-event-image', visualLabel); 850 const time = e.timeLabel ? `<div class="va-event-time">${clockIcon()}<span class="va-time-text">${splitTimeLabel(e.timeLabel)}</span></div>` : `<div class="va-event-time"><span class="va-time-text">${this.lang === 'en' ? 'All day' : 'Toute la journée'}</span></div>`; 851 const meta = [ 852 cancelledBadge, 853 type ? `<span class="va-meta va-type">${esc(type)}</span>` : '', 854 place ? `<span class="va-meta va-place">${pinIcon()}<span>${esc(place)}</span></span>` : '', 855 e.access ? `<span class="va-meta va-access">${esc(e.access)}</span>` : '' 856 ].filter(Boolean).join(''); 857 const hasDetails = !!(e.content && e.content.replace(/<[^>]*>/g, '').trim()); 858 const detailsUrl = (e.exposition && e.exposition.url) ? e.exposition.url : (e.photographerUrl || e.photographerExpositionUrl || ''); 859 const detailsButton = hasDetails 860 ? `<button type="button" class="va-details-toggle" aria-expanded="false">${esc(this.str.details || (this.lang === 'en' ? 'Details' : 'Détails'))}</button>` 861 : (detailsUrl ? `<a class="va-details-link" href="${esc(detailsUrl)}">${esc(this.str.details || (this.lang === 'en' ? 'Details' : 'Détails'))}</a>` : ''); 862 const external = !cancelled && e.url ? `<a class="va-action" href="${esc(e.url)}" target="_blank" rel="noopener">${esc(e.urlLabel || (this.lang === 'en' ? 'More information' : 'Plus dâinformations'))}</a>` : ''; 863 const participation = cancelled ? '' : this.participationButton(e); 864 const directions = !cancelled && e.mapsUrl ? `<a class="va-directions" href="${esc(e.mapsUrl)}" target="_blank" rel="noopener">${pinIcon()}<span>${esc(this.str.directions || (this.lang === 'en' ? 'Directions' : 'Itinéraire'))}</span></a>` : ''; 865 const calendarLabel = this.str.calendar || (this.lang === 'en' ? 'Add to calendar' : 'Ajouter à mon agenda'); 866 const calendarMobileLabel = this.lang === 'en' ? 'Calendar' : 'Agenda'; 867 const ics = cancelled ? '' : `<a class="va-calendar" href="${esc(e.ics)}"><span class="va-label-desktop">${esc(calendarLabel)}</span><span class="va-label-mobile">${esc(calendarMobileLabel)}</span></a>`; 868 869 return `<article class="va-event${cancelledClass}${endedToday}${featured}${hasImage}${bg.cls}${extraClass}"${bg.style}${extraClass.includes('va-more-hidden') ? ' hidden' : ''}> 870 <div class="va-event-timecol">${time}${current}</div> 871 <div class="va-event-body"> 872 <div class="va-event-layout"> 873 ${image} 874 <div class="va-event-content"> 875 <h3>${esc(e.title)}</h3> 876 <div class="va-event-meta">${meta}</div> 877 ${cancelledMessage} 878 ${progress} 879 <div class="va-event-actions">${detailsButton}${participation}${directions}${external}${ics}</div> 880 ${hasDetails ? `<div class="va-event-details" hidden>${e.content}</div>` : ''} 881 </div> 882 </div> 883 </div> 884 </article>`; 885 } 886 } 887 888 function markAgendaHostColumn(root) { 889 let node = root ? root.parentElement : null; 890 while (node && node !== document.body && node !== document.documentElement) { 891 const classes = typeof node.className === 'string' ? node.className : ''; 892 const isColumn = node.classList && ( 893 node.classList.contains('wpb_column') || 894 node.classList.contains('vc_column_container') || 895 node.classList.contains('column_parent') || 896 /(?:^|\s)(?:vc_)?col-(?:xs|sm|md|lg)-\d+(?:\s|$)/.test(classes) 897 ); 898 if (isColumn) { 899 node.classList.add('va-agenda-host-column'); 900 return node; 901 } 902 node = node.parentElement; 903 } 904 return null; 905 } 906 907 function resetPageHorizontalScroll() { 908 if (window.innerWidth > 700) return; 909 // Safari iOS peut conserver un scroll horizontal de la page après 910 // le déplacement du rail des dates. On ne touche jamais au scroll Y. 911 if (window.scrollX !== 0) {
912 try { window.scrollTo(0, window.scrollY); } catch (e) {} 913 } 914 } 915 916 function fitAgendaToMobileViewport(root) { 917 if (!root) return; 918 919 // Depuis 0.18.9 la largeur mobile est gérée uniquement par CSS et par 920 // la colonne hôte Uncode. Aucun décalage left/transform n'est appliqué 921 // à l'agenda : c'était une source de dérive horizontale sur Safari iOS. 922 root.style.removeProperty('position'); 923 root.style.removeProperty('left'); 924 925 if (window.innerWidth > 700) { 926 root.classList.remove('va-mobile-viewport-fit'); 927 root.style.removeProperty('width'); 928 root.style.removeProperty('max-width'); 929 return; 930 } 931 932 root.classList.add('va-mobile-viewport-fit'); 933 root.style.width = '100%'; 934 root.style.maxWidth = '100%'; 935 936 requestAnimationFrame(resetPageHorizontalScroll); 937 } 938 939 function fitAllAgendaToMobileViewport() { 940 document.querySelectorAll('.visa-agenda').forEach(fitAgendaToMobileViewport); 941 } 942 943 document.addEventListener('DOMContentLoaded', function () { 944 const roots = document.querySelectorAll('.visa-agenda'); 945 if (roots.length) { 946 document.body.classList.add('va-has-agenda'); 947 document.documentElement.classList.add('va-has-agenda'); 948 requestAnimationFrame(resetPageHorizontalScroll); 949 } 950 951 roots.forEach(root => { 952 markAgendaHostColumn(root); 953 fitAgendaToMobileViewport(root); 954 new Agenda(root); 955 }); 956 957 let resizeFrame = 0; 958 window.addEventListener('resize', function () { 959 if (resizeFrame) cancelAnimationFrame(resizeFrame); 960 resizeFrame = requestAnimationFrame(() => { 961 fitAllAgendaToMobileViewport(); 962 resetPageHorizontalScroll(); 963 }); 964 }, { passive: true }); 965 966 window.addEventListener('orientationchange', function () { 967 setTimeout(fitAllAgendaToMobileViewport, 120); 968 }, { passive: true }); 969 }); 970})();
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.