1// modules/citymanager.js 2// Gestion des villes : détection, branding, logos, UI du sélecteur 3 4;(function(win) { 5 'use strict'; 6 7 const CityManager = { 8 VALID_CITIES: new Set(), 9 CITY_BRANDING_CACHE: new Map(), 10 _branding: null, 11 _activeCity: '', 12 _cityMenuOpen: false, 13 _cityMenuDocHandler: null, 14 15 // Helpers de validation 16 17 isValidCity(city) { 18 try { 19 return this.VALID_CITIES.has(String(city || '').toLowerCase()); 20 } catch (e) { 21 console.debug('[citymanager] isValidCity failed:', e); 22 return false; 23 } 24 }, 25 26 // Détection de ville 27 28 getRawCityFromPath(pathname) { 29 try { 30 const path = String(pathname || location.pathname || '').toLowerCase(); 31 return path.split('?')[0].split('#')[0].split('/').filter(Boolean)[0] || ''; 32 } catch (e) { 33 console.debug('[citymanager] getRawCityFromPath failed:', e); 34 return ''; 35 } 36 }, 37 38 parseCityFromPath(pathname) { 39 const raw = this.getRawCityFromPath(pathname); 40 return this.isValidCity(raw) ? raw : ''; 41 }, 42 43 getCityFromQuery(_defaultCity = '') { 44 try { 45 const sp = new URLSearchParams(location.search); 46 const raw = String(sp.get('city') || '').toLowerCase().trim(); 47 return this.isValidCity(raw) ? raw : ''; 48 } catch (e) { 49 console.debug('[citymanager] getCityFromQuery failed:', e); 50 return ''; 51 } 52 }, 53 54 getRawCityFromQueryParam() { 55 try { 56 const sp = new URLSearchParams(location.search); 57 const raw = String(sp.get('city') || '').toLowerCase().trim(); 58 return raw; 59 } catch (e) { 60 console.debug('[citymanager] getRawCityFromQueryParam failed:', e); 61 return ''; 62 } 63 }, 64 65 // Persistance 66 67 persistCity(city) { 68 try { 69 if (this.isValidCity(city)) localStorage.setItem('activeCity', city); 70 } catch (e) { console.debug('[citymanager] persistCity failed:', e); } 71 }, 72 73 restoreCity() { 74 try { 75 const v = localStorage.getItem('activeCity'); 76 return this.isValidCity(v) ? v : ''; 77 } catch (e) { 78 console.debug('[citymanager] restoreCity failed:', e); 79 return ''; 80 } 81 }, 82 83 clearPersistedCity() { 84 try { 85 localStorage.removeItem('activeCity'); 86 } catch (e) { console.debug('[citymanager] clearPersistedCity failed:', e); } 87 }, 88 89 // Chargement des villes valides 90 91 async loadValidCities() { 92 try { 93 if (!win.supabaseService || typeof win.supabaseService.getValidCities !== 'function') return; 94 const list = await win.supabaseService.getValidCities(); 95 if (Array.isArray(list) && list.length) { 96 this.VALID_CITIES = new Set(list.map(v => String(v || '').toLowerCase().trim()).filter(Boolean)); 97 } 98 } catch (e) { console.debug('[citymanager] loadValidCities failed:', e); } 99 }, 100 101 getDefaultCity() { 102 // Ville par défaut : metropole-lyon 103 return 'metropole-lyon'; 104 }, 105 106 resolveActiveCity() { 107 const routeCity = this.parseCityFromPath(location.pathname); 108 if (routeCity) return routeCity; 109 const queryCity = this.getCityFromQuery(''); 110 if (queryCity) return queryCity; 111 const saved = this.restoreCity(); 112 if (saved) return saved; 113 // Aucune ville trouvée : retourner metropole-lyon par défaut 114 return 'metropole-lyon'; 115 }, 116 117 // Branding (logos, favicon) 118 119 /** 120 * Retourne le branding complet stocké (chargé en Phase 2) 121 * @returns {Object|null} 122 */ 123 getBranding() { 124 return this._branding; 125 }, 126 127 /** 128 * Stocke le branding (appelé par main.js Phase 2) 129 * @param {Object|null} branding 130 */ 131 setBranding(branding) { 132 this._branding = branding || null; 133 }, 134 135 selectLogoForTheme(branding, theme) { 136 if (!branding) return null; 137 if (theme === 'dark' && branding.dark_logo_url) return branding.dark_logo_url; 138 return branding.logo_url || null; 139 }, 140 141 /** 142 * Charge le branding depuis Supabase et met à jour les logos dans le DOM. 143 * Le favicon est délégué à CityBrandingModule. 144 * @param {string} city - Code de la ville 145 */ 146 async updateLogoForCity(city) {
147 try { 148 const targets = [ 149 document.querySelector('#nav-panel .logo img'), 150 document.querySelector('.mobile-fixed-logo img'), 151 document.querySelector('#dock-logo img'), 152 document.querySelector('.gp-sidebar__logo img') 153 ].filter(Boolean); 154 155 // Fetch branding une seule fois (stocké pour toute l'app) 156 let branding = null; 157 if (win.supabaseService && typeof win.supabaseService.getCityBranding === 'function' && city) { 158 branding = await win.supabaseService.getCityBranding(city); 159 } 160 this._branding = branding || null; 161 162 if (!branding) { 163 console.debug('[CityManager] Pas de branding trouvé pour la ville:', city); 164 return; 165 } 166 167 const theme = (document.documentElement.getAttribute('data-theme') || 'light').toLowerCase(); 168 const picked = this.selectLogoForTheme(branding, theme); 169 const altText = branding.brand_name || city.charAt(0).toUpperCase() + city.slice(1); 170 171 // Appliquer le logo du branding 172 targets.forEach((img) => { 173 if (!img || !picked) return; 174 img.style.opacity = '0'; 175 img.src = picked; 176 img.alt = altText; 177 img.style.display = ''; 178 img.onerror = null; 179 img.onload = () => { img.style.transition = 'opacity 0.2s ease'; img.style.opacity = '1'; }; 180 if (img.complete && img.naturalWidth > 0) { 181 img.style.transition = 'opacity 0.2s ease'; 182 img.style.opacity = '1'; 183 } 184 }); 185 } catch (e) { console.debug('[citymanager] updateLogoForCity failed:', e); } 186 }, 187 188 applyCityInitialView(_city) { 189 try { 190 const branding = this._branding || null; 191 const hasCoords = branding && typeof branding.center_lat === 'number' && typeof branding.center_lng === 'number'; 192 const hasZoom = branding && typeof branding.zoom === 'number'; 193 if (!hasCoords || !hasZoom) return; 194 195 const center = [branding.center_lat, branding.center_lng]; 196 const zoom = branding.zoom; 197 if (window.MapModule?.map?.setView) { 198 window.MapModule.map.setView(center, zoom); 199 } 200 } catch (e) { console.debug('[citymanager] applyCityInitialView failed:', e); } 201 }, 202 203 // UI du sélecteur de ville 204 205 async getCityBrandingSafe(ville) { 206 const key = String(ville || '').toLowerCase(); 207 if (!key) return null; 208 if (this.CITY_BRANDING_CACHE.has(key)) return this.CITY_BRANDING_CACHE.get(key); 209 210 try { 211 const data = await (win.supabaseService?.getCityBranding ? win.supabaseService.getCityBranding(key
211) : null); 212 const minimal = data ? { 213 ville: data.ville || key, 214 brand_name: data.brand_name || '', 215 logo_url: data.logo_url || '', 216 dark_logo_url: data.dark_logo_url || '' 217 } : null; 218 this.CITY_BRANDING_CACHE.set(key, minimal); 219 return minimal; 220 } catch (e) { 221 console.debug('[citymanager] getCityBrandingSafe failed:', e); 222 this.CITY_BRANDING_CACHE.set(key, null); 223 return null; 224 } 225 }, 226 227 /** 228 * Récupère le branding complet d'une ville (avec enabled_cities, enabled_toggles, etc.) 229 * @param {string} ville - Code de la ville 230 * @returns {Promise<Object|null>} Branding complet ou null 231 */ 232 async getFullBrandingSafe(ville) { 233 const key = String(ville || '').toLowerCase(); 234 if (!key) return null; 235 236 try { 237 const data = await (win.supabaseService?.getCityBranding ? win.supabaseService.getCityBranding(key) : null); 238 return data || null; 239 } catch (e) { 240 console.debug('[citymanager] getFullBrandingSafe failed:', e); 241 return null; 242 } 243 }, 244 245 // Menu de sélection d'espace 246 247 /** 248 * Peuple le menu de sélection d'espace avec les villes de enabled_cities 249 * @param {string} activeCity - Ville actuellement active 250 */ 251 async renderCityMenu(activeCity) { 252 try { 253 const menu = document.getElementById('city-menu'); 254 if (!menu) return; 255 256 // Récupérer le branding de la ville active pour avoir enabled_cities 257 const branding = await this.getFullBrandingSafe(activeCity); 258 const enabledCities = branding?.enabled_cities; 259 260 // Si pas d'espaces configurés, menu vide 261 if (!Array.isArray(enabledCities) || enabledCities.length === 0) { 262 menu.innerHTML = '<div class="dock-panel__header"><span class="dock-panel__title">Espace</span></div><div class="dock-panel__body" style="padding:12px;color:var(--text-tertiary);font-size:0.78rem;">Aucun espace configuré</div>'; 263 return; 264 } 265 266 // Récupérer les brandings de toutes les villes 267 const brandings = await Promise.all( 268 enabledCities.map(c => this.getCityBrandingSafe(c)) 269 ); 270 271 const _esc = win.SecurityUtils ? win.SecurityUtils.escapeHtml : (s => String(s || '')); 272 const itemsHtml = enabledCities.map((cityCode, idx) => { 273 const isActive = String(cityCode).toLowerCase() === String(activeCity).toLowerCase(); 274 const b = brandings[idx] || {}; 275 const displayName = _esc(b.brand_name?.trim() || cityCode.charAt(0).toUpperCase() + cityCode.slice(1)); 276 const logo = (document.documentElement.getAttribute('data-theme') === 'dark' && b.dark_logo_url) 277 ? b.dark_logo_url 278 : (b.logo_url || ''); 279 const logoHtml = logo 280 ? `<img src="${_esc(logo)}" alt="${displayName}" class="city-menu-logo" />` 281 : `<i class="fas fa-building"></i>`; 282 283 return ` 284 <button class="dock-panel__item city-menu-item${isActive ? ' is-active' : ''}" data-city="${_esc(cityCode)}" role="menuitem"> 285 <span class="dock-panel__item-icon">${logoHtml}</span> 286 <span class="dock-panel__item-label">${displayName}</span> 287 </button> 288 `; 289 }).join(''); 290 291 menu.innerHTML = ` 292 <div class="dock-panel__header"> 293 <span class="dock-panel__title">Espace</span> 294 <button class="dock-panel__close" aria-label="Fermer le menu espace"><i class="fas fa-times"></i></button> 295 </div> 296 <div class="dock-panel__body">${itemsHtml}</div> 297 `; 298 299 // Bind close button 300 menu.querySelector('.dock-panel__close')?.addEventListener('click', (e) => { 301 e.stopPropagation(); 302 win.toggleManager?.setState('city', false); 303 }); 304 305 // Bind click events 306 menu.querySelectorAll('.city-menu-item').forEach(item => { 307 item.addEventListener('click', (e) => { 308 e.preventDefault(); 309 e.stopPropagation(); 310 const city = item.dataset.city; 311 if (city) this.selectCity(city); 312 }); 313 }); 314 } catch (err) { 315 console.debug('[CityManager] Error rendering city menu:', err); 316 } 317 }, 318 319 /** 320 * Sélectionne une ville et navigue vers elle 321 * @param {string} city - Code de la ville 322 */ 323 selectCity(city) {
324 try { 325 const cityCode = String(city || '').toLowerCase(); 326 if (!cityCode) return; 327 328 // Fermer le menu 329 if (win.toggleManager) { 330 win.toggleManager.setState('city', false); 331 } 332 333 // Si c'est déjà la ville active, ne rien faire 334 if (cityCode === String(this._activeCity).toLowerCase()) return; 335 336 // Persister et naviguer 337 this.persistCity(cityCode); 338 339 // Construire l'URL avec le paramètre city 340 const sp = new URLSearchParams(location.search); 341 sp.set('city', cityCode); 342 const target = location.pathname + '?' + sp.toString(); 343 location.href = target; 344 } catch (err) { 345 console.debug('[CityManager] Error selecting city:', err); 346 } 347 }, 348 349 /** 350 * Initialise le menu d'espace 351 * @param {string} activeCity - Ville active 352 */ 353 async initCityMenu(activeCity) { 354 await this.renderCityMenu(activeCity); 355 }, 356 357 /** 358 * Retourne la ville active en utilisant toutes les sources disponibles 359 * @returns {string} Ville active ou chaîne vide 360 */ 361 getActiveCity() { 362 return this.parseCityFromPath(location.pathname) || 363 this.getCityFromQuery('') || 364 this.restoreCity() || 365 this._activeCity || 366 this.getDefaultCity(); 367 }, 368 369 /** 370 * Initialise et résout la ville active avec gestion de la persistance 371 * Utilise metropole-lyon par défaut si aucune ville valide 372 * @returns {string} Ville active résolue (toujours une ville, jamais null) 373 */ 374 initializeActiveCity() { 375 const rawQueryCity = this.getRawCityFromQueryParam(); 376 const rawPathCity = this.getRawCityFromPath(); 377 378 // Si pas de villes valides chargées, utiliser metropole-lyon par défaut 379 if (!this.VALID_CITIES || this.VALID_CITIES.size === 0) { 380 console.debug('[CityManager] Aucune ville valide chargée â Utilisation de metropole-lyon'); 381 this._activeCity = 'metropole-lyon'; 382 return this._activeCity; 383 } 384 385 // Si ville invalide dans l'URL, utiliser metropole-lyon 386 if (rawQueryCity && !this.isValidCity(rawQueryCity)) { 387 console.debug('[CityManager] Ville invalide détectée:', rawQueryCity, 'â Utilisation de metropole-lyon'); 388 this.clearPersistedCity(); 389 this._activeCity = 'metropole-lyon'; 390 return this._activeCity; 391 } 392 393 if (rawPathCity && !this.isValidCity(rawPathCity)) { 394 console.debug('[CityManager] Ville invalide dans le path:', rawPathCity, 'â Utilisation de metropole-lyon'); 395 this.clearPersistedCity(); 396 this._activeCity = 'metropole-lyon'; 397 return this._activeCity; 398 } 399 400 // Résolution normale 401 const city = this.resolveActiveCity(); 402 403 // Si ville résolue mais invalide, utiliser metropole-lyon 404 if (city && !this.isValidCity(city)) { 405 console.debug('[CityManager] Ville résolue invalide:', city, 'â Utilisation de metropole-lyon'); 406 this.clearPersistedCity(); 407 this._activeCity = 'metropole-lyon'; 408 return this._activeCity; 409 } 410 411 this._activeCity = city; 412 413 // Gérer la persistance 414 if (city && this.isValidCity(city)) { 415 if (this.restoreCity() !== city) this.persistCity(city); 416 } else { 417 this.clearPersistedCity(); 418 } 419 420 return city; 421 }, 422 423 /** 424 * Récupère les informations d'une ville (version synchrone depuis le cache) 425 * @param {string} cityCode - Code de la ville 426 * @returns {Object|null} - Informations de la ville ou null 427 */ 428 getCityInfo(cityCode) { 429 if (!cityCode) return null; 430 if (!this.isValidCity(cityCode)) return null; 431 432 const key = String(cityCode).toLowerCase(); 433 // Retourner depuis le cache si disponible 434 if (this.CITY_BRANDING_CACHE.has(key)) { 435 return this.CITY_BRANDING_CACHE.get(key); 436 } 437 438 // Sinon retourner un objet minimal 439 return { 440 ville: key, 441 brand_name: key.charAt(0).toUpperCase() + key.slice(1), 442 logo_url: '/img/logos/default.svg', 443 dark_logo_url: null 444 }; 445 }, 446 447 /** 448 * Récupère les informations d'une ville (version async avec fetch) 449 * @param {string} cityCode - Code de la ville 450 * @returns {Promise<Object|null>} - Informations de la ville ou null 451 */ 452 async getCityInfoAsync(cityCode) { 453 if (!cityCode) return null; 454 if (!this.isValidCity(cityCode)) return null; 455 456 // Utiliser getCityBrandingSafe qui gère le cache et fetch si nécessaire 457 return await this.getCityBrandingSafe(cityCode); 458 } 459 }; 460 461 // Exposer le module globalement 462 win.CityManager = CityManager; 463 464})(window);
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.