| <!DOCTYPE html> |
| <html lang="fr"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, viewport-fit=cover"> |
| <title>Carte Météo - Architecture Modulaire</title> |
| <meta name="description" content="Carte interactive météo pour le sud de la France avec spots personnalisés"> |
| |
| |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/> |
| <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" integrity="sha384-lPzjPsFqXGR4j69hiC9fEY25qUp5wxLmEoI80K1Bz5P18D9XtSPs464N3wF8aLWlC8cXp" crossorigin=""/> |
| <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" integrity="sha384-5kSRZBJjZ+W2kZ/+huW4O7mQmDFTsM4VhO7sCGXkQs9j6iQeOJKsQYkXhJZSfS9S5X" crossorigin=""/> |
| |
| |
| <script src="https://cdn.tailwindcss.com"></script> |
| |
| |
| <link rel="prefetch" href="sw-enhanced.js"> |
| |
| |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> |
|
|
| <style> |
| body { |
| font-family: 'Inter', sans-serif; |
| overflow: hidden; |
| touch-action: pan-x pan-y; |
| -webkit-user-select: none; |
| -moz-user-select: none; |
| -ms-user-select: none; |
| user-select: none; |
| } |
| |
| |
| :focus { |
| outline: 2px solid #10b981; |
| outline-offset: 2px; |
| } |
| |
| |
| @media (prefers-reduced-motion: reduce) { |
| *, *::before, *::after { |
| animation-duration: 0.01ms !important; |
| animation-iteration-count: 1 !important; |
| transition-duration: 0.01ms !important; |
| } |
| } |
| |
| #map { |
| height: 100vh; |
| width: 100vw; |
| z-index: 10; |
| position: fixed; |
| top: 0; |
| left: 0; |
| } |
| |
| |
| .mobile-panel { |
| position: fixed; |
| z-index: 20; |
| background: rgba(255, 255, 255, 0.95); |
| backdrop-filter: blur(10px); |
| border-radius: 16px; |
| box-shadow: 0 8px 32px rgba(0,0,0,0.2); |
| border: 1px solid rgba(255,255,255,0.3); |
| } |
| |
| .mobile-header { |
| top: env(safe-area-inset-top, 12px); |
| left: 12px; |
| right: 12px; |
| padding: 16px; |
| max-height: 30vh; |
| overflow-y: auto; |
| } |
| |
| .mobile-controls { |
| bottom: env(safe-area-inset-bottom, 12px); |
| left: 12px; |
| right: 12px; |
| padding: 16px; |
| } |
| |
| |
| .touch-button { |
| min-height: 48px; |
| min-width: 48px; |
| padding: 12px 16px; |
| border-radius: 12px; |
| font-weight: 600; |
| font-size: 15px; |
| transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); |
| touch-action: manipulation; |
| -webkit-tap-highlight-color: transparent; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| } |
| |
| .touch-button:active { |
| transform: scale(0.96); |
| } |
| |
| |
| .carte-meteo { |
| min-height: 48px; |
| padding: 16px; |
| margin: 12px 0; |
| border-radius: 12px; |
| transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); |
| touch-action: manipulation; |
| } |
| |
| .carte-meteo:active { |
| transform: scale(0.98); |
| background-color: #f3f4f6; |
| } |
| |
| |
| .mobile-side-panel { |
| position: fixed; |
| top: 0; |
| right: -100%; |
| width: 90vw; |
| max-width: 420px; |
| height: 100vh; |
| background: white; |
| z-index: 30; |
| transition: right 0.4s cubic-bezier(0.4, 0, 0.2, 1); |
| overflow-y: auto; |
| padding: env(safe-area-inset-top, 24px) 24px 24px; |
| box-shadow: -10px 0 30px rgba(0,0,0,0.1); |
| } |
| |
| .mobile-side-panel.open { |
| right: 0; |
| } |
| |
| .mobile-overlay { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100vw; |
| height: 100vh; |
| background: rgba(0,0,0,0.6); |
| z-index: 25; |
| opacity: 0; |
| visibility: hidden; |
| transition: all 0.3s ease; |
| } |
| |
| .mobile-overlay.show { |
| opacity: 1; |
| visibility: visible; |
| } |
| |
| |
| .data-warning { |
| background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); |
| border: 2px solid #f59e0b; |
| color: #92400e; |
| padding: 12px 16px; |
| border-radius: 12px; |
| font-size: 14px; |
| font-weight: 600; |
| margin-bottom: 12px; |
| animation: pulse 2s infinite; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .data-error { |
| background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%); |
| border: 2px solid #ef4444; |
| color: #dc2626; |
| } |
| |
| .data-success { |
| background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); |
| border: 2px solid #10b981; |
| color: #059669; |
| } |
| |
| |
| .loading-overlay { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100vw; |
| height: 100vh; |
| background: rgba(0,0,0,0.9); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: 50; |
| color: white; |
| backdrop-filter: blur(10px); |
| } |
| |
| |
| .connection-status { |
| position: fixed; |
| top: env(safe-area-inset-top, 12px); |
| left: 12px; |
| padding: 6px 12px; |
| border-radius: 8px; |
| font-size: 12px; |
| font-weight: 600; |
| z-index: 35; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| } |
| |
| .connection-status.online { |
| background: #10b981; |
| color: white; |
| } |
| |
| .connection-status.offline { |
| background: #ef4444; |
| color: white; |
| animation: pulse 1s infinite; |
| } |
| |
| |
| .module-status { |
| position: fixed; |
| bottom: 12px; |
| right: 12px; |
| background: rgba(0,0,0,0.8); |
| color: white; |
| padding: 8px 16px; |
| border-radius: 8px; |
| font-size: 12px; |
| z-index: 60; |
| backdrop-filter: blur(4px); |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| box-shadow: 0 4px 12px rgba(0,0,0,0.3); |
| } |
| |
| .module-status.ready { |
| background: rgba(16, 185, 129, 0.9); |
| } |
| |
| |
| .custom-div-icon { |
| background: transparent !important; |
| border: none !important; |
| } |
| |
| .custom-spot-marker { |
| transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
| position: relative; |
| font-size: 24px; |
| filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); |
| } |
| |
| .custom-spot-marker:hover { |
| transform: rotate(-15deg) scale(1.2) !important; |
| } |
| |
| |
| #add-spot-btn { |
| box-shadow: 0 6px 20px rgba(0,0,0,0.25); |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
| } |
| |
| #add-spot-btn:hover { |
| box-shadow: 0 8px 25px rgba(0,0,0,0.3); |
| transform: translateY(-2px); |
| } |
| |
| #add-spot-btn:active { |
| transform: translateY(0) scale(0.96); |
| } |
| |
| |
| #spot-modal, #import-export-modal { |
| backdrop-filter: blur(8px); |
| } |
| |
| |
| .spot-type-option { |
| cursor: pointer; |
| transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); |
| padding: 12px; |
| border: 2px solid transparent; |
| border-radius: 12px; |
| text-align: center; |
| background: rgba(0,0,0,0.03); |
| } |
| |
| .spot-type-option:hover { |
| transform: translateY(-2px); |
| background: rgba(0,0,0,0.08); |
| } |
| |
| .spot-type-option input[type="radio"] { |
| display: none; |
| } |
| |
| .spot-type-option input:checked + label { |
| border-color: #10b981; |
| background: rgba(16, 185, 129, 0.15); |
| transform: translateY(-3px); |
| box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2); |
| } |
| |
| |
| .custom-spot-popup .leaflet-popup-content-wrapper { |
| border-radius: 16px; |
| box-shadow: 0 8px 32px rgba(0,0,0,0.2); |
| } |
| |
| .custom-spot-popup .leaflet-popup-content { |
| margin: 0; |
| } |
| |
| |
| .spot-controls { |
| position: fixed; |
| bottom: 24px; |
| left: 12px; |
| z-index: 30; |
| } |
| |
| |
| @keyframes pulse { |
| 0% { opacity: 1; } |
| 50% { opacity: 0.7; } |
| 100% { opacity: 1; } |
| } |
| |
| @keyframes fadeIn { |
| from { opacity: 0; transform: translateY(10px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| .fade-in { |
| animation: fadeIn 0.3s ease-out forwards; |
| } |
| |
| |
| .stat-card { |
| background: linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(249,250,251,0.9) 100%); |
| border-radius: 12px; |
| padding: 16px; |
| text-align: center; |
| box-shadow: 0 4px 12px rgba(0,0,0,0.05); |
| transition: transform 0.2s ease; |
| } |
| |
| .stat-card:hover { |
| transform: translateY(-2px); |
| } |
| |
| |
| @media (max-width: 375px) { |
| .touch-button { |
| font-size: 13px; |
| padding: 10px 12px; |
| } |
| |
| .mobile-header h1 { |
| font-size: 19px; |
| } |
| } |
| |
| |
| @media (max-height: 500px) and (orientation: landscape) { |
| .mobile-header { |
| max-height: 25vh; |
| } |
| |
| .mobile-controls { |
| padding: 12px; |
| } |
| } |
| |
| |
| @media (min-width: 768px) { |
| .mobile-panel { |
| position: absolute; |
| border-radius: 20px; |
| box-shadow: 0 12px 40px rgba(0,0,0,0.25); |
| } |
| |
| .mobile-header { |
| top: 24px; |
| left: 24px; |
| right: 24px; |
| max-height: none; |
| padding: 20px; |
| } |
| |
| .mobile-controls { |
| bottom: 24px; |
| left: 24px; |
| right: 24px; |
| padding: 20px; |
| } |
| |
| .mobile-side-panel { |
| position: absolute; |
| right: 24px; |
| top: 140px; |
| width: 380px; |
| height: auto; |
| max-height: 75vh; |
| padding: 24px; |
| border-radius: 20px; |
| box-shadow: -15px 0 40px rgba(0,0,0,0.15); |
| } |
| |
| .touch-button { |
| min-height: 52px; |
| padding: 14px 18px; |
| font-size: 16px; |
| border-radius: 14px; |
| } |
| } |
| |
| |
| #recherche-spots { |
| transition: all 0.3s ease; |
| box-shadow: 0 2px 8px rgba(0,0,0,0.05); |
| } |
| |
| #recherche-spots:focus { |
| box-shadow: 0 4px 16px rgba(16, 185, 129, 0.2); |
| border-color: #10b981; |
| } |
| |
| |
| .filtre-type { |
| transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); |
| box-shadow: 0 2px 6px rgba(0,0,0,0.05); |
| } |
| |
| .filtre-type:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 12px rgba(0,0,0,0.1); |
| } |
| |
| |
| .modal-content { |
| animation: modalSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards; |
| } |
| |
| @keyframes modalSlideIn { |
| from { opacity: 0; transform: translateY(30px) scale(0.95); } |
| to { opacity: 1; transform: translateY(0) scale(1); } |
| } |
| |
| |
| #day-slider { |
| height: 6px; |
| border-radius: 3px; |
| background: #e5e7eb; |
| } |
| |
| #day-slider::-webkit-slider-thumb { |
| appearance: none; |
| height: 20px; |
| width: 20px; |
| border-radius: 50%; |
| background: #10b981; |
| cursor: pointer; |
| box-shadow: 0 2px 8px rgba(0,0,0,0.2); |
| } |
| |
| |
| .btn-primary { |
| background: linear-gradient(135deg, #10b981 0%, #059669 100%); |
| color: white; |
| } |
| |
| .btn-primary:hover { |
| background: linear-gradient(135deg, #059669 0%, #047857 100%); |
| } |
| |
| .btn-secondary { |
| background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); |
| color: white; |
| } |
| |
| .btn-secondary:hover { |
| background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); |
| } |
| |
| .btn-warning { |
| background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); |
| color: white; |
| } |
| |
| .btn-warning:hover { |
| background: linear-gradient(135deg, #d97706 0%, #b45309 100%); |
| } |
| |
| .btn-danger { |
| background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); |
| color: white; |
| } |
| |
| .btn-danger:hover { |
| background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); |
| } |
| |
| .btn-purple { |
| background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); |
| color: white; |
| } |
| |
| .btn-purple:hover { |
| background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); |
| } |
| |
| .btn-emerald { |
| background: linear-gradient(135deg, #10b981 0%, #059669 100%); |
| color: white; |
| } |
| |
| .btn-emerald:hover { |
| background: linear-gradient(135deg, #059669 0%, #047857 100%); |
| } |
| |
| .btn-gray { |
| background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); |
| color: white; |
| } |
| |
| .btn-gray:hover { |
| background: linear-gradient(135deg, #4b5563 0%, #374151 100%); |
| } |
| </style> |
| </head> |
| <body class="bg-gray-100" role="application" aria-label="Carte Météo Interactive"> |
|
|
| <div id="map" role="region" aria-label="Carte interactive" tabindex="-1"></div> |
| |
| |
| <div id="connection-status" class="connection-status online" role="status" aria-live="polite"> |
| <span aria-hidden="true">●</span> |
| <span id="connection-text">En ligne</span> |
| </div> |
| |
| |
| <div id="module-status" class="module-status" role="status" aria-live="polite"> |
| <span aria-hidden="true">⚡</span> |
| <span id="module-status-text">Initializing...</span> |
| </div> |
|
|
| |
| <div class="mobile-panel mobile-header fade-in" role="banner"> |
| <h1 class="text-xl md:text-2xl font-bold text-gray-800 text-center mb-3" tabindex="-1"> |
| 🌦️ Météo Sud France - Modulaire |
| </h1> |
| |
| |
| <div id="data-status" class="data-warning hidden"> |
| ⚠️ Vérification des données météo... |
| </div> |
| |
| |
| <div class="grid grid-cols-3 gap-3 mb-4"> |
| <div class="stat-card"> |
| <div class="text-2xl font-bold text-green-600" id="villes-sans-pluie" aria-label="Nombre de villes sans pluie">0</div> |
| <div class="text-xs text-gray-600 mt-1">au sec</div> |
| </div> |
| <div class="stat-card"> |
| <div class="text-2xl font-bold text-amber-600" id="temperature-max" aria-label="Température maximale">--°</div> |
| <div class="text-xs text-gray-600 mt-1">max</div> |
| </div> |
| <div class="stat-card"> |
| <div class="text-2xl font-bold text-cyan-600" id="activites-ideales" aria-label="Nombre d'activités idéales">0</div> |
| <div class="text-xs text-gray-600 mt-1">idéal</div> |
| </div> |
| </div> |
| |
| |
| <div class="mb-3"> |
| <label for="day-slider" class="text-sm font-medium text-gray-700 block text-center mb-2" id="date-label">Chargement...</label> |
| <input type="range" min="0" max="6" value="0" class="w-full cursor-pointer" id="day-slider" disabled aria-describedby="date-label"> |
| </div> |
| </div> |
|
|
| |
| <div class="mobile-panel mobile-controls fade-in" role="navigation" aria-label="Navigation principale"> |
| <div class="grid grid-cols-2 gap-3"> |
| <button id="btn-sans-pluie" class="touch-button btn-primary" aria-label="Afficher les villes sans pluie" type="button"> |
| ☀️ Sec |
| </button> |
| <button id="btn-activites" class="touch-button btn-secondary" aria-label="Afficher les sites naturels" type="button"> |
| 📍 Sites |
| </button> |
| <button id="btn-semaine" class="touch-button btn-purple" aria-label="Afficher la météo sur 7 jours" type="button"> |
| 📅 Semaine |
| </button> |
| <button id="btn-itineraire" class="touch-button btn-warning" aria-label="Planifier un itinéraire" type="button"> |
| 🗺️ Route |
| </button> |
| <button id="btn-mes-spots" class="touch-button btn-emerald" aria-label="Afficher mes spots personnalisés" type="button"> |
| 🏚️ Mes Spots |
| </button> |
| <button id="btn-import-export" class="touch-button btn-gray" aria-label="Importer ou exporter des spots" type="button"> |
| 📤 Import/Export |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div id="mobile-overlay" class="mobile-overlay"></div> |
|
|
| |
| <div id="panneau-activites" class="mobile-side-panel" role="dialog" aria-labelledby="sites-panel-title" aria-modal="true" aria-hidden="true"> |
| <div class="flex justify-between items-center mb-4"> |
| <h3 id="sites-panel-title" class="font-bold">🗺️ Sites Naturels</h3> |
| <button id="fermer-activites" class="touch-button bg-gray-200 text-gray-700 p-2 rounded-full w-8 h-8 flex items-center justify-center text-sm" aria-label="Fermer le panneau des sites" type="button">×</button> |
| </div> |
| |
| <div class="mb-3"> |
| <input type="text" id="recherche-spots" placeholder="🔍 Rechercher..." |
| class="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-blue-500" aria-label="Rechercher des sites"> |
| </div> |
| |
| <div id="filtres-activites" class="flex flex-wrap gap-1 mb-3"> |
| <button class="filtre-type touch-button bg-blue-100 text-blue-700 text-xs px-2 py-1" data-type="cascade" type="button">💧 Cascade</button> |
| <button class="filtre-type touch-button bg-red-100 text-red-700 text-xs px-2 py-1" data-type="thermes" type="button">♨️ Thermes</button> |
| <button class="filtre-type touch-button bg-cyan-100 text-cyan-700 text-xs px-2 py-1" data-type="lac" type="button">🏊 Lac</button> |
| <button class="filtre-type touch-button bg-amber-100 text-amber-700 text-xs px-2 py-1" data-type="plage" type="button">🏖️ Plage</button> |
| <button class="filtre-type touch-button bg-purple-100 text-purple-700 text-xs px-2 py-1" data-type="gorges" type="button">🏔️ Gorges</button> |
| <button class="filtre-type touch-button bg-gray-100 text-gray-700 text-xs px-2 py-1" data-type="grotte" type="button">🕳️ Grotte</button> |
| <button class="filtre-type touch-button bg-teal-100 text-teal-700 text-xs px-2 py-1" data-type="piscine" type="button">💎 Piscine</button> |
| <button class="filtre-type touch-button bg-pink-100 text-pink-700 text-xs px-2 py-1" data-type="vue" type="button">👁️ Vue</button> |
| <button class="filtre-type touch-button bg-orange-100 text-orange-700 text-xs px-2 py-1" data-type="canyon" type="button">🪂 Canyon</button> |
| </div> |
| |
| <div id="liste-activites" class="space-y-2"></div> |
| </div> |
|
|
| |
| <div id="overlay-chargement" class="loading-overlay"> |
| <div class="text-center max-w-sm mx-auto p-6"> |
| <div class="animate-spin w-12 h-12 border-4 border-white border-t-transparent rounded-full mx-auto mb-6"></div> |
| <div id="texte-chargement" class="font-semibold text-lg mb-2">Initialisation des modules...</div> |
| <div class="text-sm opacity-80">Préparation de la carte météo</div> |
| </div> |
| </div> |
| |
| |
| <button id="add-spot-btn" class="fixed bottom-28 right-5 z-30 bg-gradient-to-r from-green-500 to-emerald-600 |
| text-white rounded-full w-16 h-16 shadow-xl flex items-center justify-center |
| transition-all duration-300 hover:from-green-600 hover:to-emerald-700"> |
| <span class="text-3xl">+</span> |
| </button> |
| |
| |
| <div id="spot-modal" class="hidden fixed inset-0 z-40 bg-black bg-opacity-60 |
| flex items-center justify-center p-4" role="dialog" aria-labelledby="spot-modal-title" aria-modal="true" aria-hidden="true"> |
| <div class="modal-content bg-white rounded-2xl shadow-2xl max-w-md w-full max-h-[90vh] |
| overflow-y-auto"> |
| <div class="p-6"> |
| <h2 id="spot-modal-title" class="text-2xl font-bold mb-5 flex items-center"> |
| <span class="mr-3 text-2xl">📍</span> |
| Ajouter un spot secret |
| </h2> |
| |
| |
| <form id="spot-form" class="space-y-5"> |
| |
| <div> |
| <label class="block text-sm font-semibold text-gray-700 mb-2" for="spot-name"> |
| Nom du spot* |
| </label> |
| <input type="text" id="spot-name" required |
| class="w-full px-4 py-3 border rounded-xl focus:outline-none |
| focus:border-green-500 focus:ring-2 focus:ring-green-200 transition-all" |
| placeholder="Ex: Toit de l'ancienne usine"> |
| </div> |
| |
| |
| <div> |
| <label class="block text-sm font-semibold text-gray-700 mb-3" id="spot-type-label"> |
| Type de spot* |
| </label> |
| <div id="spot-type-grid" class="grid grid-cols-4 gap-3" role="radiogroup" aria-labelledby="spot-type-label"> |
| |
| </div> |
| </div> |
| |
| |
| <div> |
| <label class="block text-sm font-semibold text-gray-700 mb-2" for="spot-description"> |
| Description |
| </label> |
| <textarea id="spot-description" rows="3" |
| class="w-full px-4 py-3 border rounded-xl focus:outline-none |
| focus:border-green-500 focus:ring-2 focus:ring-green-200 transition-all" |
| placeholder="Décris ce spot... Vue incroyable, bon pour les photos, etc."></textarea> |
| </div> |
| |
| |
| <div> |
| <label class="block text-sm font-semibold text-gray-700 mb-2" for="spot-access"> |
| Comment y accéder? |
| </label> |
| <textarea id="spot-access" rows="2" |
| class="w-full px-4 py-3 border rounded-xl focus:outline-none |
| focus:border-green-500 focus:ring-2 focus:ring-green-200 transition-all" |
| placeholder="Ex: Entrée par derrière, trou dans la clôture près du container bleu"></textarea> |
| </div> |
| |
| |
| <div class="flex items-center"> |
| <input type="checkbox" id="weather-sensitive" |
| class="mr-3 h-5 w-5 text-green-600 rounded focus:ring-green-500"> |
| <label for="weather-sensitive" class="text-sm font-medium"> |
| ⚠️ Éviter en cas de pluie |
| </label> |
| </div> |
| |
| |
| <div> |
| <label class="block text-sm font-semibold text-gray-700 mb-2" for="best-time"> |
| Meilleur moment |
| </label> |
| <select id="best-time" class="w-full px-4 py-3 border rounded-xl |
| focus:outline-none focus:border-green-500 focus:ring-2 focus:ring-green-200 transition-all"> |
| <option value="anytime">N'importe quand</option> |
| <option value="sunrise">Lever du soleil</option> |
| <option value="morning">Matin</option> |
| <option value="afternoon">Après-midi</option> |
| <option value="sunset">Coucher du soleil</option> |
| <option value="night">Nuit</option> |
| </select> |
| </div> |
| |
| |
| <div> |
| <label class="block text-sm font-semibold text-gray-700 mb-2" for="spot-tags"> |
| Tags (séparés par des virgules) |
| </label> |
| <input type="text" id="spot-tags" |
| class="w-full px-4 py-3 border rounded-xl focus:outline-none |
| focus:border-green-500 focus:ring-2 focus:ring-green-200 transition-all" |
| placeholder="urbex, photo, rooftop, tranquille"> |
| </div> |
| |
| |
| <div class="flex justify-end space-x-3 pt-6 border-t"> |
| <button type="button" id="cancel-spot" |
| class="px-5 py-3 text-gray-600 hover:text-gray-800 transition-colors font-medium rounded-xl hover:bg-gray-100"> |
| Annuler |
| </button> |
| <button type="submit" |
| class="px-5 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-xl |
| hover:from-green-600 hover:to-emerald-700 transition-all font-medium shadow-md hover:shadow-lg"> |
| Créer le spot |
| </button> |
| </div> |
| </form> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div id="import-export-modal" class="hidden fixed inset-0 z-40 bg-black bg-opacity-60 |
| flex items-center justify-center p-4" role="dialog" aria-labelledby="import-export-title" aria-modal="true" aria-hidden="true"> |
| <div class="modal-content bg-white rounded-2xl shadow-2xl max-w-md w-full"> |
| <div class="p-6"> |
| <h2 id="import-export-title" class="text-2xl font-bold mb-5">📤 Import/Export des spots</h2> |
| |
| <div class="space-y-5"> |
| |
| <div class="border rounded-xl p-5 bg-gradient-to-br from-blue-50 to-indigo-50"> |
| <h3 class="font-bold text-lg mb-2">Exporter mes spots</h3> |
| <p class="text-sm text-gray-600 mb-4"> |
| Télécharge tous tes spots secrets en fichier JSON |
| </p> |
| <button id="export-spots-btn" |
| class="w-full bg-gradient-to-r from-blue-500 to-indigo-600 text-white py-3 rounded-xl hover:from-blue-600 hover:to-indigo-700 font-medium shadow-md hover:shadow-lg transition-all"> |
| 📥 Télécharger mes spots |
| </button> |
| </div> |
| |
| |
| <div class="border rounded-xl p-5 bg-gradient-to-br from-green-50 to-emerald-50"> |
| <h3 class="font-bold text-lg mb-2">Importer des spots</h3> |
| <p class="text-sm text-gray-600 mb-4"> |
| Charge des spots depuis un fichier JSON |
| </p> |
| <input type="file" id="import-file" accept=".json" |
| class="mb-3 text-sm w-full" aria-label="Sélectionner un fichier JSON"> |
| <button id="import-spots-btn" |
| class="w-full bg-gradient-to-r from-green-500 to-emerald-600 text-white py-3 rounded-xl hover:from-green-600 hover:to-emerald-700 font-medium shadow-md hover:shadow-lg transition-all" type="button"> |
| 📤 Importer des spots |
| </button> |
| </div> |
| </div> |
| |
| <button type="button" id="close-import-export" |
| class="mt-5 w-full px-4 py-3 text-gray-600 hover:text-gray-800 font-medium rounded-xl hover:bg-gray-100 transition-colors"> |
| Fermer |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script> |
| <script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script> |
|
|
| <script> |
| |
| class CDNFallbackManager { |
| constructor() { |
| this.cdnStatus = new Map(); |
| } |
| |
| async initialize() { |
| console.log('CDN Fallback Manager initialized'); |
| return Promise.resolve(); |
| } |
| } |
| |
| |
| const registerServiceWorker = () => { |
| if ('serviceWorker' in navigator) { |
| navigator.serviceWorker.register('sw-enhanced.js') |
| .then(registration => { |
| console.log('Enhanced SW registered:', registration); |
| }) |
| .catch(error => console.log('SW registration failed:', error)); |
| } |
| }; |
| |
| |
| class WeatherApp { |
| constructor(config) { |
| this.config = config; |
| this.isInitialized = false; |
| this.map = null; |
| this.markers = []; |
| this.activities = []; |
| this.customSpots = JSON.parse(localStorage.getItem('customSpots') || '[]'); |
| this.eventListeners = {}; |
| |
| |
| this.mockCities = [ |
| { nom: "Montpellier", lat: 43.6108, lon: 3.8767, pluie: false, temperature: 28 }, |
| { nom: "Nîmes", lat: 43.8367, lon: 4.3601, pluie: false, temperature: 30 }, |
| { nom: "Perpignan", lat: 42.6887, lon: 2.8948, pluie: true, temperature: 25 }, |
| { nom: "Béziers", lat: 43.3447, lon: 3.2167, pluie: false, temperature: 29 }, |
| { nom: "Sète", lat: 43.4068, lon: 3.6972, pluie: false, temperature: 27 }, |
| { nom: "Carcassonne", lat: 43.2125, lon: 2.3533, pluie: true, temperature: 24 }, |
| { nom: "Narbonne", lat: 43.1833, lon: 3.0000, pluie: false, temperature: 28 } |
| ]; |
| |
| this.mockActivities = [ |
| { id: 1, nom: "Cascade de la Vis", type: "cascade", lat: 43.8, lon: 3.5, description: "Belle cascade dans les gorges de l'Hérault" }, |
| { id: 2, nom: "Thermes de Bagnères", type: "thermes", lat: 42.9, lon: 1.6, description: "Sources thermales naturelles" }, |
| { id: 3, nom: "Lac de Thau", type: "lac", lat: 43.4, lon: 3.6, description: "Lac méditerranéen avec îles" }, |
| { id: 4, nom: "Plage de Palavas", type: "plage", lat: 43.5, lon: 3.9, description: "Plage de sable fin" }, |
| { id: 5, nom: "Gorges du Tarn", type: "gorges", lat: 44.0, lon: 3.2, description: "Canyon spectaculaire" }, |
| { id: 6, nom: "Grotte de Clamouse", type: "grotte", lat: 43.7, lon: 3.3, description: "Grotte ornée de concrétions" }, |
| { id: 7, nom: "Piscine naturelle", type: "piscine", lat: 43.6, lon: 3.8, description: "Piscine de roche naturelle" }, |
| { id: 8, nom: "Point de vue du Pic", type: "vue", lat: 43.9, lon: 3.1, description: "Panorama sur la vallée" }, |
| { id: 9, nom: "Canyon de l'Ardèche", type: "canyon", lat: 44.1, lon: 4.3, description: "Descente en eau vive" } |
| ]; |
| |
| this.spotTypes = [ |
| { id: 'cascade', emoji: '💧', label: 'Cascade' }, |
| { id: 'thermes', emoji: '♨️', label: 'Thermes' }, |
| { id: 'lac', emoji: '🏊', label: 'Lac' }, |
| { id: 'plage', emoji: '🏖️', label: 'Plage' }, |
| { id: 'gorges', emoji: '🏔️', label: 'Gorges' }, |
| { id: 'grotte', emoji: '🕳️', label: 'Grotte' }, |
| { id: 'piscine', emoji: '💎', label: 'Piscine' }, |
| { id: 'vue', emoji: '👁️', label: 'Point de vue' }, |
| { id: 'canyon', emoji: '🪂', label: 'Canyon' }, |
| { id: 'urbex', emoji: '🏙️', label: 'Urbex' }, |
| { id: 'foret', emoji: '🌲', label: 'Forêt' }, |
| { id: 'montagne', emoji: '⛰️', label: 'Montagne' } |
| ]; |
| |
| this.init(); |
| } |
| |
| on(event, callback) { |
| if (!this.eventListeners[event]) { |
| this.eventListeners[event] = []; |
| } |
| this.eventListeners[event].push(callback); |
| } |
| |
| emit(event, data) { |
| if (this.eventListeners[event]) { |
| this.eventListeners[event].forEach(callback => callback({ detail: data })); |
| } |
| } |
| |
| async init() { |
| try { |
| await this.initMap(); |
| this.initUI(); |
| this.loadMockData(); |
| this.setupEventListeners(); |
| this.isInitialized = true; |
| this.emit('appReady'); |
| } catch (error) { |
| console.error('Initialization failed:', error); |
| this.emit('initializationFailed', error); |
| } |
| } |
| |
| async initMap() { |
| return new Promise((resolve) => { |
| this.map = L.map('map').setView(this.config.mapOptions.center, this.config.mapOptions.zoom); |
| |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { |
| attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' |
| }).addTo(this.map); |
| |
| resolve(); |
| }); |
| } |
| |
| initUI() { |
| |
| const spotTypeGrid = document.getElementById('spot-type-grid'); |
| if (spotTypeGrid) { |
| spotTypeGrid.innerHTML = this.spotTypes.map(type => ` |
| <div class="spot-type-option"> |
| <input type="radio" id="type-${type.id}" name="spot-type" value="${type.id}"> |
| <label for="type-${type.id}" class="block cursor-pointer"> |
| <div class="text-2xl mb-1">${type.emoji}</div> |
| <div class="text-xs font-medium">${type.label}</div> |
| </label> |
| </div> |
| `).join(''); |
| } |
| |
| |
| this.updateStats(); |
| |
| |
| const dateLabel = document.getElementById('date-label'); |
| const daySlider = document.getElementById('day-slider'); |
| if (dateLabel && daySlider) { |
| const today = new Date(); |
| dateLabel.textContent = today.toLocaleDateString('fr-FR', { |
| weekday: 'long', |
| day: 'numeric', |
| month: 'long' |
| }); |
| daySlider.disabled = false; |
| } |
| } |
| |
| loadMockData() { |
| |
| this.mockCities.forEach(city => { |
| const marker = L.marker([city.lat, city.lon]).addTo(this.map); |
| marker.bindPopup(` |
| <div class="font-bold text-lg">${city.nom}</div> |
| <div class="mt-1">Température: ${city.temperature}°C</div> |
| <div class="mt-1">${city.pluie ? '🌧️ Pluie' : '☀️ Ensoleillé'}</div> |
| `); |
| this.markers.push(marker); |
| }); |
| |
| |
| this.mockActivities.forEach(activity => { |
| const marker = L.marker([activity.lat, activity.lon]).addTo(this.map); |
| marker.bindPopup(` |
| <div class="font-bold text-lg">${activity.nom}</div> |
| <div class="mt-1 capitalize">Type: ${activity.type}</div> |
| <div class="mt-2 text-sm">${activity.description}</div> |
| `); |
| this.activities.push(marker); |
| }); |
| |
| |
| this.customSpots.forEach(spot => { |
| const marker = L.marker([spot.lat, spot.lon], { |
| icon: L.divIcon({ |
| className: 'custom-div-icon', |
| html: '<div class="custom-spot-marker">📍</div>', |
| iconSize: [30, 30] |
| }) |
| }).addTo(this.map); |
| marker.bindPopup(` |
| <div class="font-bold text-lg">${spot.name}</div> |
| <div class="mt-1 capitalize">Type: ${spot.type}</div> |
| <div class="mt-2 text-sm">${spot.description || ''}</div> |
| `); |
| }); |
| } |
| |
| updateStats() { |
| const citiesWithoutRain = this.mockCities.filter(city => !city.pluie).length; |
| const maxTemp = Math.max(...this.mockCities.map(city => city.temperature)); |
| const idealActivities = this.mockActivities.length; |
| |
| document.getElementById('villes-sans-pluie').textContent = citiesWithoutRain; |
| document.getElementById('temperature-max').textContent = `${maxTemp}°`; |
| document.getElementById('activites-ideales').textContent = idealActivities; |
| } |
| |
| setupEventListeners() { |
| |
| document.getElementById('btn-sans-pluie')?.addEventListener('click', () => { |
| this.showMessage('Affichage des villes sans pluie', 'info'); |
| }); |
| |
| document.getElementById('btn-activites')?.addEventListener('click', () => { |
| const panel = document.getElementById('panneau-activites'); |
| const overlay = document.getElementById('mobile-overlay'); |
| panel.classList.add('open'); |
| overlay.classList.add('show'); |
| panel.setAttribute('aria-hidden', 'false'); |
| }); |
| |
| document.getElementById('btn-semaine')?.addEventListener('click', () => { |
| this.showMessage('Affichage de la météo sur 7 jours', 'info'); |
| }); |
| |
| document.getElementById('btn-itineraire')?.addEventListener('click', () => { |
| this.showMessage('Planification d\'itinéraire', 'info'); |
| }); |
| |
| document.getElementById('btn-mes-spots')?.addEventListener('click', () => { |
| this.showMessage('Affichage de vos spots personnalisés', 'info'); |
| }); |
| |
| document.getElementById('btn-import-export')?.addEventListener('click', () => { |
| const modal = document.getElementById('import-export-modal'); |
| modal.classList.remove('hidden'); |
| modal.setAttribute('aria-hidden', 'false'); |
| }); |
| |
| |
| document.getElementById('fermer-activites')?.addEventListener('click', () => { |
| const panel = document.getElementById('panneau-activites'); |
| const overlay = document.getElementById('mobile-overlay'); |
| panel.classList.remove('open'); |
| overlay.classList.remove('show'); |
| panel.setAttribute('aria-hidden', 'true'); |
| }); |
| |
| document.getElementById('mobile-overlay')?.addEventListener('click', () => { |
| const panel = document.getElementById('panneau-activites'); |
| const overlay = document.getElementById('mobile-overlay'); |
| panel.classList.remove('open'); |
| overlay.classList.remove('show'); |
| panel.setAttribute('aria-hidden', 'true'); |
| }); |
| |
| |
| document.getElementById('add-spot-btn')?.addEventListener('click', () => { |
| const modal = document.getElementById('spot-modal'); |
| modal.classList.remove('hidden'); |
| modal.setAttribute('aria-hidden', 'false'); |
| }); |
| |
| document.getElementById('cancel-spot')?.addEventListener('click', () => { |
| const modal = document.getElementById('spot-modal'); |
| modal.classList.add('hidden'); |
| modal.setAttribute('aria-hidden', 'true'); |
| }); |
| |
| document.getElementById('close-import-export')?.addEventListener('click', () => { |
| const modal = document.getElementById('import-export-modal'); |
| modal.classList.add('hidden'); |
| modal.setAttribute('aria-hidden', 'true'); |
| }); |
| |
| |
| document.getElementById('spot-form')?.addEventListener('submit', (e) => { |
| e.preventDefault(); |
| this.createCustomSpot(); |
| }); |
| |
| |
| document.getElementById('export-spots-btn')?.addEventListener('click', () => { |
| this.exportSpots(); |
| }); |
| |
| |
| document.getElementById('import-spots-btn')?.addEventListener('click', () => { |
| this.importSpots(); |
| }); |
| |
| |
| document.querySelectorAll('.filtre-type').forEach(button => { |
| button.addEventListener('click', (e) => { |
| const type = e.target.dataset.type || e.target.parentElement.dataset.type; |
| this.showMessage(`Filtrage par type: ${type}`, 'info'); |
| }); |
| }); |
| } |
| |
| createCustomSpot() { |
| const name = document.getElementById('spot-name').value; |
| const type = document.querySelector('input[name="spot-type"]:checked')?.value; |
| |
| if (!name || !type) { |
| this.showMessage('Veuillez remplir tous les champs obligatoires', 'error'); |
| return; |
| } |
| |
| const spot = { |
| id: Date.now(), |
| name, |
| type, |
| lat: 43.6108, |
| lon: 3.8767, |
| description: document.getElementById('spot-description').value, |
| access: document.getElementById('spot-access').value, |
| weatherSensitive: document.getElementById('weather-sensitive').checked, |
| bestTime: document.getElementById('best-time').value, |
| tags: document.getElementById('spot-tags').value.split(',').map(tag => tag.trim()).filter(tag => tag) |
| }; |
| |
| this.customSpots.push(spot); |
| localStorage.setItem('customSpots', JSON.stringify(this.customSpots)); |
| |
| |
| document.getElementById('spot-modal').classList.add('hidden'); |
| document.getElementById('spot-form').reset(); |
| |
| this.showMessage('Spot créé avec succès!', 'success'); |
| } |
| |
| exportSpots() { |
| const dataStr = JSON.stringify(this.customSpots, null, 2); |
| const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr); |
| |
| const exportFileDefaultName = 'mes-spots-meteo.json'; |
| |
| const linkElement = document.createElement('a'); |
| linkElement.setAttribute('href', dataUri); |
| linkElement.setAttribute('download', exportFileDefaultName); |
| linkElement.click(); |
| |
| this.showMessage('Export réussi!', 'success'); |
| } |
| |
| importSpots() { |
| const fileInput = document.getElementById('import-file'); |
| const file = fileInput.files[0]; |
| |
| if (!file) { |
| this.showMessage('Veuillez sélectionner un fichier', 'error'); |
| return; |
| } |
| |
| |
| if (file.type !== 'application/json' && !file.name.endsWith('.json')) { |
| this.showMessage('Veuillez sélectionner un fichier JSON valide', 'error'); |
| return; |
| } |
| |
| const reader = new FileReader(); |
| reader.onload = (e) => { |
| try { |
| const importedSpots = JSON.parse(e.target.result); |
| if (Array.isArray(importedSpots)) { |
| |
| const validSpots = importedSpots.filter(spot => |
| spot.name && spot.type && spot.lat && spot.lon |
| ); |
| |
| if (validSpots.length !== importedSpots.length) { |
| console.warn('Some spots were invalid and skipped'); |
| } |
| |
| this.customSpots = [...this.customSpots, ...validSpots]; |
| localStorage.setItem('customSpots', JSON.stringify(this.customSpots)); |
| this.showMessage(`Import réussi: ${validSpots.length} spots ajoutés`, 'success'); |
| document.getElementById('import-export-modal').classList.add('hidden'); |
| fileInput.value = ''; |
| } else { |
| this.showMessage('Format de fichier invalide', 'error'); |
| } |
| } catch (error) { |
| console.error('Import error:', error); |
| this.showMessage('Erreur lors de l\'import: ' + (error.message || 'Fichier invalide'), 'error'); |
| } |
| }; |
| |
| reader.onerror = () => { |
| this.showMessage('Erreur lors de la lecture du fichier', 'error'); |
| }; |
| |
| reader.readAsText(file); |
| } |
| |
| showMessage(message, type = 'info') { |
| |
| const notification = document.createElement('div'); |
| notification.className = `fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 px-6 py-3 rounded-xl shadow-lg text-white font-medium max-w-md text-center fade-in`; |
| |
| switch(type) { |
| case 'success': |
| notification.classList.add('bg-green-500'); |
| break; |
| case 'error': |
| notification.classList.add('bg-red-500'); |
| break; |
| case 'info': |
| default: |
| notification.classList.add('bg-blue-500'); |
| } |
| |
| notification.textContent = message; |
| document.body.appendChild(notification); |
| |
| |
| setTimeout(() => { |
| notification.style.opacity = '0'; |
| setTimeout(() => { |
| document.body.removeChild(notification); |
| }, 300); |
| }, 3000); |
| } |
| |
| getPerformanceMetrics() { |
| return { |
| initializationTime: Date.now() - performance.timing.navigationStart, |
| markerCount: this.markers.length, |
| activityCount: this.activities.length, |
| customSpotCount: this.customSpots.length |
| }; |
| } |
| } |
| |
| |
| document.addEventListener('DOMContentLoaded', async () => { |
| |
| const moduleStatus = document.getElementById('module-status'); |
| const moduleStatusText = document.getElementById('module-status-text'); |
| const updateModuleStatus = (message, isReady = false) => { |
| moduleStatusText.textContent = message; |
| if (isReady) { |
| moduleStatus.classList.add('ready'); |
| } |
| }; |
| |
| try { |
| updateModuleStatus('🔧 Initializing modules...'); |
| |
| |
| const appConfig = { |
| isMobile: window.innerWidth < 768, |
| |
| |
| weatherOptions: { |
| timeout: /Mobi|Android/i.test(navigator.userAgent) ? 5000 : 8000, |
| maxRetries: 1, |
| cacheDuration: 3600000 |
| }, |
| |
| mapOptions: { |
| center: [43.7, 3.5], |
| zoom: /Mobi|Android/i.test(navigator.userAgent) ? 6 : 7, |
| clusterRadius: /Mobi|Android/i.test(navigator.userAgent) ? 40 : 50 |
| }, |
| |
| features: { |
| offlineMode: true, |
| weeklyStats: true, |
| routePlanning: true, |
| backgroundSync: 'serviceWorker' in navigator |
| } |
| }; |
| |
| |
| const app = new WeatherApp(appConfig); |
| |
| |
| window.weatherApp = app; |
| |
| |
| app.on('initializationFailed', (e) => { |
| console.error('App initialization failed:', e.detail); |
| updateModuleStatus('❌ Initialization failed'); |
| }); |
| |
| app.on('citySelected', (e) => { |
| console.log('City selected:', e.detail.city.nom); |
| }); |
| |
| app.on('activitySelected', (e) => { |
| console.log('Activity selected:', e.detail.activity.nom); |
| }); |
| |
| app.on('routeUpdated', (e) => { |
| console.log('Route updated, length:', e.detail.route.length); |
| }); |
| |
| app.on('connectionChanged', (e) => { |
| const status = e.detail.isOnline ? 'online' : 'offline'; |
| console.log('Connection status:', status); |
| }); |
| |
| |
| const checkInitialization = () => { |
| if (app.isInitialized) { |
| updateModuleStatus('✅ Modules ready', true); |
| |
| |
| const overlay = document.getElementById('overlay-chargement'); |
| if (overlay) { |
| overlay.style.opacity = '0'; |
| setTimeout(() => overlay.style.display = 'none', 500); |
| } |
| |
| |
| const metrics = app.getPerformanceMetrics(); |
| console.log('WeatherApp Performance:', metrics); |
| |
| |
| setTimeout(() => { |
| if (moduleStatus) { |
| moduleStatus.style.opacity = '0'; |
| setTimeout(() => moduleStatus.style.display = 'none', 500); |
| } |
| }, 3000); |
| |
| } else { |
| setTimeout(checkInitialization, 50); |
| } |
| }; |
| |
| |
| checkInitialization(); |
| |
| |
| setTimeout(() => { |
| const overlay = document.getElementById('overlay-chargement'); |
| if (overlay && overlay.style.display !== 'none') { |
| console.warn('Force hiding loading overlay after timeout'); |
| overlay.style.display = 'none'; |
| updateModuleStatus('⚠️ Loaded with timeout'); |
| } |
| }, 10000); |
| |
| } catch (error) { |
| console.error('Failed to initialize weather app:', error); |
| updateModuleStatus('❌ Module error'); |
| |
| |
| const dataStatus = document.getElementById('data-status'); |
| if (dataStatus) { |
| dataStatus.textContent = `❌ Erreur d'initialisation: ${error.message}`; |
| dataStatus.classList.remove('hidden', 'data-warning', 'data-success'); |
| dataStatus.classList.add('data-error'); |
| } |
| |
| |
| document.getElementById('overlay-chargement').style.display = 'none'; |
| } |
| }); |
| |
| |
| window.addEventListener('orientationchange', () => { |
| setTimeout(() => { |
| if (window.weatherApp && window.weatherApp.map) { |
| window.weatherApp.map.invalidateSize(); |
| } |
| }, 100); |
| }); |
| |
| |
| window.addEventListener('load', () => { |
| console.log('Page loaded at:', Date.now()); |
| }); |
| </script> |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=SAINTHALF/arpentage" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> |
| </html> |