SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%

Frontend v2 « guide culturel » : refonte majeure mobile-first — header compact (ticker desktop, hamburger+auth ·Ka), hero éditorial avec mosaïque d'images réelles + compteurs en phrase, recherche centrale + accès rapides (Ce soir/Fin de semaine/Gratuit/Près de moi) + timeline de jours, accueil par sections dédoublonnées (À la une vedette, carrousels, grille, liste compacte), fiches image-first (date magenta/titre/lieu/prix), résultats Liste|Carte (Leaflet à la demande, marqueurs framboise), pilules de filtres actifs, bottom sheet de filtres, fiche événement avec CTA sticky mobile ; API : filtre near=lat,lng + tri distance ; corrections cascade header mobile et padding container/detail

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 5549abd

2 changed files +1,041 −247

modified frontend/index.html +1018 −245
@@ -1,12 +1,15 @@
1 1 <!--
2 2 Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 3 File: index.html
4 − Desc: Frontend Sorti-Ka — recherche + fiches événement (SPA sans build,
5 − routeur History API, consomme /api). Design system Groupe KA
6 − « éditorial sharp » via ka-ui (src/ka/tokens.css, chargé en premier) :
7 − papier #f5f3ee · encre #141814 · vert #1c5c41 · ACCENT FRAMBOISE
8 − #d6336c · Space Grotesk / Inter / JetBrains Mono · bordures encre +
9 − ombres décalées + grain de film. Footer commun KaFooter et page
4 + Desc: Frontend Sorti-Ka v2 « guide culturel » — SPA sans build, routeur
5 + History API, consomme /api. Accueil éditorial par sections (À la une,
6 + Ce soir, Fin de semaine, Gratuit, catégories, À découvrir), recherche
7 + centrale + filtres en bottom sheet, timeline de jours, vue Liste|Carte
8 + (Leaflet chargé à la demande), fiches image-first, CTA sticky mobile.
9 + Design system Groupe KA via ka-ui (src/ka/tokens.css, chargé en
10 + premier) : papier #f5f3ee · encre #141814 · ACCENT FRAMBOISE #d6336c
11 + (signal culturel : dates, états actifs, CTA) · Space Grotesk / Inter /
12 + JetBrains Mono · grain de film. Footer commun KaFooter et page
10 13 /contact : contenu issu de src/ka/ecosystem.json (source de vérité —
11 14 resynchroniser le HTML si le JSON change). SSR : sortika/seo.py
12 15 injecte <head> et contenu dans les placeholders SSR_HEAD/SSR_BODY.
@@ -58,41 +61,43 @@ button{font-family:inherit}
58 61 .btn-ghost{background:var(--surface);color:var(--ink);box-shadow:4px 4px 0 rgba(20,24,20,.12)}
59 62 .btn-ghost:hover{background:var(--accent-soft)}
60 63
61 −/* ---------- header / ticker ---------- */
64 +/* ---------- header / ticker (compact 2026-08-25) ---------- */
62 65 .ticker{background:var(--ink);color:rgba(245,243,238,.85);overflow:hidden;
63 − font-family:var(--font-mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;
64 − padding:7px 0;white-space:nowrap}
66 + font-family:var(--font-mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;
67 + padding:6px 0;white-space:nowrap}
65 68 .ticker-track{display:inline-flex;animation:ticker 48s linear infinite;will-change:transform}
66 69 .ticker span{padding:0 26px;position:relative}
67 70 .ticker span::after{content:"◆";position:absolute;right:-6px;color:var(--lime);font-size:8px;top:3px}
68 71 @keyframes ticker{from{transform:translateX(0)}to{transform:translateX(-50%)}}
69 72 @media(prefers-reduced-motion:reduce){.ticker-track{animation:none}*{transition-duration:.01ms!important}}
70 −/* header opaque, échelle --z-* (standard header/menu KA 2026-08-22) */
73 +/* header opaque, échelle --z-* (standard header/menu KA 2026-08-22) —
74 + v2 : une seule rangée mince ; sur mobile le contenu événementiel doit
75 + apparaître tout de suite → ticker masqué, badge/tag repliés dans le menu */
71 76 .header{position:sticky;top:0;z-index:var(--z-header,500);background:var(--paper);
72 77 border-bottom:1.5px solid var(--ink)}
73 −.header-inner{display:flex;align-items:center;flex-wrap:wrap;gap:6px 18px;height:auto;min-height:64px;padding:6px 0}
78 +.header-inner{display:flex;align-items:center;gap:14px;min-height:58px;padding:4px 0}
74 79 .header-inner .gk-badge{flex:none}
80 +@media(max-width:980px){.header-inner .gk-badge{display:none}}
75 81 @media(max-width:760px){
76 − .header-inner{min-height:56px;padding:8px 0;gap:6px 10px}
77 − /* KA Nav v2 (2026-08-25) : la nav inline cède la place au hamburger + panneau
78 − plein écran fixed — menu visible peu importe la position de scroll */
79 − .nav{display:none}
80 − #auth{margin-left:auto}
81 − .ka-mnav-btn{display:flex}
82 + .ticker{display:none}
83 + .header-inner{min-height:52px;padding:4px 0;gap:10px}
82 84 }
83 −.brand{font-family:var(--font-display);font-weight:700;font-size:24px;letter-spacing:-.03em;
84 − display:flex;align-items:baseline;line-height:1;cursor:pointer;position:relative}
85 +/* le reste des règles mobiles du header est déclaré APRÈS toutes les règles de
86 + base (nav, mnav, btn-ka, user-chip) — voir bloc « header mobile » plus bas :
87 + à spécificité égale, c'est la dernière déclaration qui gagne. */
88 +.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;
89 + display:flex;align-items:baseline;line-height:1;cursor:pointer;position:relative;flex:none}
85 90 /* extension tactile : le logo (98×26) atteint >=44px de cible sans changer le rendu */
86 91 .brand::after{content:"";position:absolute;inset:-8px}
87 92 .brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 2px;margin-left:2px;transform:rotate(-1deg)}
88 93 .brand-tag{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;
89 − letter-spacing:.08em;color:var(--ink-3);margin-left:8px}
90 −@media(max-width:900px){.brand-tag{display:none}}
91 −.nav{margin-left:auto;display:flex;gap:6px}
92 −.nav a{display:inline-flex;align-items:center;min-height:44px;padding:8px 14px;
93 − border:1.5px solid transparent;border-radius:var(--r-ctl);
94 − font-family:var(--font-display);font-weight:700;font-size:13.5px;transition:all .15s ease}
95 −.nav a:hover{border-color:var(--ink);background:var(--accent-soft)}
94 + letter-spacing:.08em;color:var(--ink-3)}
95 +@media(max-width:1100px){.brand-tag{display:none}}
96 +.nav{margin-left:auto;display:flex;gap:2px}
97 +.nav a{display:inline-flex;align-items:center;min-height:44px;padding:8px 13px;
98 + border-radius:999px;font-family:var(--font-display);font-weight:700;font-size:13.5px;
99 + color:var(--ink-2);transition:all .15s ease}
100 +.nav a:hover{background:var(--accent-soft);color:var(--ink)}
96 101
97 102 /* ===== KA Nav v2 — menu mobile plein écran (standard 2026-08-25) =====
98 103 Panneau position:fixed inset:0 (z-modal) → visible peu importe le scroll. */
@@ -155,142 +160,344 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
155 160 font-size:9.5px;font-weight:700;text-transform:uppercase;color:var(--danger);padding:2px}
156 161 .user-chip .out:hover{text-decoration:underline}
157 162 @media(max-width:640px){.user-chip .uname{max-width:80px}}
158 −
159 −/* ---------- hero ---------- */
160 −.hero{padding:56px 0 20px}
161 −@media(max-width:640px){.hero{padding:34px 0 12px}}
162 −.hero h1{font-size:clamp(34px,5.2vw,58px);font-weight:700;line-height:1.05;max-width:940px;margin-top:16px}
163 −.hero p.lede{color:var(--ink-2);font-size:16.5px;max-width:660px;margin:18px 0 0}
164 −.stat-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:26px}
165 −.stat-chip{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);
166 − padding:8px 14px;font-family:var(--font-mono);font-size:11.5px;color:var(--ink-2);
167 − display:flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.12)}
168 −.stat-chip b{color:var(--ink);font-weight:700}
169 −.stat-chip .pulse{width:8px;height:8px;border-radius:50%;background:var(--green);
170 − box-shadow:0 0 0 4px var(--lime-soft);animation:pulse 2.4s ease infinite}
171 −@keyframes pulse{50%{box-shadow:0 0 0 7px rgba(28,92,65,.16)}}
172 −
173 −/* ---------- filtres ---------- */
174 −.filterbar{padding:14px;margin:28px 0 6px}
175 −.f-primary{display:flex;gap:10px;align-items:stretch;flex-wrap:wrap}
176 −.f-search{flex:1 1 240px;min-width:0;max-width:460px;display:flex;align-items:center;gap:8px;
177 − border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface-2);
178 − padding:0 14px;min-height:46px;transition:background .15s ease,box-shadow .15s ease}
179 −.f-search:focus-within{background:var(--surface);box-shadow:3px 3px 0 var(--lime)}
180 −.f-search input{border:none;background:none;outline:none;flex:1;min-width:0;
181 − font-size:14px;color:var(--ink);font-family:inherit}
182 −.f-ctl{display:flex;flex-direction:column;justify-content:center;gap:2px;
183 − border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface);
184 − padding:6px 14px 5px;min-height:46px;cursor:pointer;transition:box-shadow .15s ease}
185 −.f-ctl:focus-within{box-shadow:3px 3px 0 var(--lime)}
186 −.f-ctl select{border:none;background:none;outline:none;font-family:var(--font-display);
187 − font-size:13.5px;font-weight:700;color:var(--ink);padding:0;margin-left:-2px;max-width:220px}
188 −/* champs >=16px sur mobile : évite le zoom automatique d'iOS au focus */
163 +/* ---- header mobile (KA Nav v2) — déclaré après TOUTES les règles de base ---- */
189 164 @media(max-width:760px){
190 − .f-search input{font-size:16px}
191 − .f-ctl select{font-size:16px}
165 + .nav{display:none}
166 + #auth{margin-left:auto}
167 + .ka-mnav-btn{display:flex}
168 + .ka-signup{display:none}
169 + .btn-ka .tx{display:none}
170 + .btn-ka{padding:8px 11px;min-height:36px}
171 + .user-chip .kaid{display:none}
172 + .user-chip .out{display:none}
173 +}
174 +
175 +/* ---------- hero éditorial (mosaïque d'images réelles en fond) ---------- */
176 +.hero{position:relative;padding:clamp(30px,6vw,72px) 0 8px;overflow:clip}
177 +@media(max-width:640px){
178 + .hero h1{font-size:clamp(30px,8.6vw,38px)}
179 + .hero-cats{font-size:10px;line-height:1.9;margin-top:12px}
180 + .hero-stats{font-size:13px;margin-top:10px;line-height:1.7}
192 181 }
182 +.hero-mosaic{position:absolute;inset:-24px -30px auto;height:130%;z-index:0;
183 + display:grid;grid-template-columns:repeat(6,1fr);grid-auto-rows:1fr;gap:8px;
184 + transform:rotate(-2deg);opacity:.3;pointer-events:none;filter:saturate(.85)}
185 +@media(max-width:640px){.hero-mosaic{grid-template-columns:repeat(3,1fr)}}
186 +.hero-mosaic .tile{border-radius:12px;overflow:hidden;background:var(--accent-soft)}
187 +.hero-mosaic img{width:100%;height:100%;object-fit:cover}
188 +.hero::after{content:"";position:absolute;inset:0;z-index:1;
189 + background:linear-gradient(180deg,rgba(245,243,238,.55) 0%,rgba(245,243,238,.86) 46%,var(--paper) 96%)}
190 +.hero .container{position:relative;z-index:2}
191 +.hero h1{font-size:clamp(34px,5.4vw,60px);font-weight:700;line-height:1.04;max-width:940px;margin-top:14px}
192 +.hero-cats{font-family:var(--font-mono);font-size:11.5px;font-weight:500;text-transform:uppercase;
193 + letter-spacing:.13em;color:var(--ink-2);margin:16px 0 0;line-height:2}
194 +.hero-cats i{font-style:normal;color:var(--accent);font-weight:700;padding:0 3px}
195 +.hero-stats{font-size:14.5px;color:var(--ink-2);margin:14px 0 0;max-width:720px;line-height:1.8}
196 +.hero-stats b{font-family:var(--font-display);font-size:1.35em;font-weight:700;
197 + letter-spacing:-.02em;color:var(--ink);padding-right:1px}
198 +.hero-stats i{font-style:normal;color:var(--accent);font-weight:700;padding:0 4px}
199 +
200 +/* ---------- recherche au centre + accès rapides + timeline de jours ---------- */
201 +.searchzone{margin-top:14px}
202 +.searchbar{display:flex;align-items:center;gap:11px;background:var(--surface);
203 + border:1.5px solid var(--ink);border-radius:16px;min-height:56px;padding:0 8px 0 17px;
204 + box-shadow:var(--shadow-off-soft);transition:box-shadow .18s ease,transform .18s ease}
205 +.searchbar:focus-within{box-shadow:4px 4px 0 var(--accent);transform:translate(-1px,-1px)}
206 +.searchbar .sico{flex:none;width:19px;height:19px;color:var(--ink-3)}
207 +.searchbar input{flex:1;min-width:0;border:0;background:none;outline:none;
208 + font-size:16px;font-family:inherit;color:var(--ink);padding:14px 0}
209 +.searchbar input::placeholder{color:var(--ink-3)}
210 +.searchbar input::-webkit-search-cancel-button{display:none}
211 +.sclear{flex:none;width:38px;height:38px;border-radius:50%;border:0;background:var(--surface-2);
212 + color:var(--ink-2);font-size:13px;cursor:pointer;line-height:1}
213 +.sclear:hover{background:var(--accent-soft);color:var(--ink)}
214 +.quick{display:flex;gap:8px;margin-top:12px;overflow-x:auto;scrollbar-width:none;padding:2px}
215 +.quick::-webkit-scrollbar{display:none}
216 +@media(max-width:640px){.quick{margin-left:-16px;margin-right:-16px;padding:2px 16px}}
217 +.qchip{flex:none;display:inline-flex;align-items:center;gap:7px;min-height:42px;padding:9px 16px;
218 + border:1.5px solid rgba(20,24,20,.35);border-radius:999px;background:var(--surface);
219 + font-family:var(--font-display);font-weight:700;font-size:13.5px;color:var(--ink);
220 + cursor:pointer;transition:all .14s ease;white-space:nowrap}
221 +.qchip svg{width:15px;height:15px;flex:none}
222 +.qchip:hover{border-color:var(--ink);background:var(--accent-soft)}
223 +.qchip.on{background:var(--accent);border-color:var(--accent-deep);color:#fff;
224 + box-shadow:2px 2px 0 rgba(20,24,20,.22)}
225 +.qchip .fb{min-width:19px;height:19px;border-radius:10px;background:var(--accent);color:#fff;
226 + font-family:var(--font-mono);font-size:10.5px;font-weight:700;
227 + display:inline-flex;align-items:center;justify-content:center;padding:0 5px}
228 +.qchip.on .fb{background:#fff;color:var(--accent-deep)}
229 +.days{display:flex;gap:8px;overflow-x:auto;margin-top:14px;padding:3px 2px 10px;
230 + scroll-snap-type:x proximity;scrollbar-width:none}
231 +.days::-webkit-scrollbar{display:none}
232 +@media(max-width:640px){.days{margin-left:-16px;margin-right:-16px;padding:3px 16px 10px}}
233 +.day{flex:none;scroll-snap-align:start;min-width:60px;padding:8px 6px 7px;
234 + border:1.5px solid var(--line);border-radius:13px;background:var(--surface);
235 + text-align:center;cursor:pointer;transition:all .14s ease}
236 +.day small{display:block;font-family:var(--font-mono);font-size:9px;font-weight:700;
237 + text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3)}
238 +.day b{display:block;font-family:var(--font-display);font-size:19px;line-height:1.2;color:var(--ink)}
239 +.day i{display:block;font-style:normal;font-family:var(--font-mono);font-size:8.5px;
240 + text-transform:uppercase;letter-spacing:.05em;color:var(--ink-3)}
241 +.day:hover{border-color:var(--ink)}
242 +.day.on{background:var(--accent);border-color:var(--accent-deep);
243 + box-shadow:2px 2px 0 rgba(20,24,20,.22)}
244 +.day.on small,.day.on i{color:rgba(255,255,255,.85)}
245 +.day.on b{color:#fff}
246 +.day.all b{font-size:13px;padding:6px 4px 5px;letter-spacing:0}
247 +
248 +/* chips génériques (filtres du sheet + périodes de /stats) */
193 249 .chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
194 −.chip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);
195 − padding:6px 12px;font-family:var(--font-display);font-size:12.5px;font-weight:700;
196 − text-transform:none;letter-spacing:0;min-height:34px;
250 +.chip{border:1.5px solid rgba(20,24,20,.4);background:var(--surface);border-radius:999px;
251 + padding:8px 14px;font-family:var(--font-display);font-size:12.5px;font-weight:700;
252 + text-transform:none;letter-spacing:0;min-height:38px;display:inline-flex;align-items:center;gap:6px;
197 253 cursor:pointer;color:var(--ink);line-height:1;transition:all .12s ease}
198 −.chip:hover{background:var(--accent-soft)}
199 −.chip.on{background:var(--ink);color:var(--accent);box-shadow:3px 3px 0 rgba(20,24,20,.25)}
254 +.chip:hover{background:var(--accent-soft);border-color:var(--ink)}
255 +.chip.on{background:var(--ink);border-color:var(--ink);color:var(--accent);box-shadow:2px 2px 0 rgba(20,24,20,.25)}
200 256 .chip.free.on{background:var(--green);border-color:var(--green);color:#fff}
201 257
202 −/* ---------- résultats ---------- */
203 −.meta-line{display:flex;align-items:center;gap:10px;margin:20px 2px 10px;
258 +/* ---------- sections éditoriales de l'accueil ---------- */
259 +.sec{margin:36px 0 6px}
260 +.sec:first-child{margin-top:26px}
261 +.sec-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;margin:0 0 15px}
262 +.sec-head .sk{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;
263 + text-transform:uppercase;letter-spacing:.12em;color:var(--accent-deep);margin-bottom:5px}
264 +.sec-head h2{font-size:clamp(21px,2.7vw,31px);letter-spacing:-.025em;line-height:1.1;margin:0}
265 +.sec-all{flex:none;font-family:var(--font-mono);font-size:11px;font-weight:700;text-transform:uppercase;
266 + letter-spacing:.06em;color:var(--accent-deep);cursor:pointer;background:none;border:0;padding:0;
267 + min-height:44px;display:inline-flex;align-items:center;gap:5px}
268 +.sec-all:hover{text-decoration:underline;text-underline-offset:4px}
269 +
270 +/* ---------- fiches événement v2 — l'image domine ---------- */
271 +.ev{display:flex;flex-direction:column;gap:10px;cursor:pointer;min-width:0}
272 +.ev-img{position:relative;aspect-ratio:4/3;border-radius:15px;overflow:hidden;
273 + background:linear-gradient(135deg,var(--accent-soft) 0%,var(--surface-2) 70%)}
274 +.ev-img::after{content:"";position:absolute;inset:0;border-radius:inherit;
275 + border:1px solid rgba(20,24,20,.16);pointer-events:none}
276 +.ev-img img{width:100%;height:100%;object-fit:cover;transition:transform .4s ease}
277 +.ev-img.noimg::before{content:"Sorti·Ka";position:absolute;inset:auto 10px 8px auto;
278 + font-family:var(--font-display);font-weight:700;font-size:13px;color:rgba(161,37,81,.4);
279 + transform:rotate(-2deg)}
280 +@media(hover:hover){
281 + .ev:hover .ev-img img{transform:scale(1.05)}
282 + .ev:hover h3{text-decoration:underline;text-decoration-color:var(--accent);
283 + text-decoration-thickness:2px;text-underline-offset:3px}
284 + .feat:hover img{transform:scale(1.03)}
285 +}
286 +.ev-body{display:flex;flex-direction:column;gap:4px;min-width:0}
287 +.ev-date{font-family:var(--font-mono);font-size:11px;font-weight:700;text-transform:uppercase;
288 + letter-spacing:.05em;color:var(--accent-deep)}
289 +.ev h3{font-family:var(--font-display);font-size:16.5px;font-weight:700;line-height:1.22;
290 + letter-spacing:-.015em;margin:0;display:-webkit-box;-webkit-line-clamp:2;
291 + -webkit-box-orient:vertical;overflow:hidden}
292 +.ev-where{font-size:12.5px;color:var(--ink-2);margin:0;
293 + white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
294 +.ev-meta{display:flex;align-items:center;gap:10px;margin:2px 0 0;font-family:var(--font-mono);
295 + font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;
296 + color:var(--ink-3);white-space:nowrap;overflow:hidden}
297 +.ev-meta .src{color:rgba(139,146,140,.75);font-weight:500;overflow:hidden;text-overflow:ellipsis}
298 +.ev-tag{position:absolute;left:10px;bottom:10px;z-index:2;font-family:var(--font-mono);
299 + font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;
300 + background:rgba(20,24,20,.82);color:#fff;border-radius:9px;padding:5px 10px;
301 + backdrop-filter:blur(3px)}
302 +.ev-tag.free{background:var(--green)}
303 +.ev-tag.stat-cancelled{background:#b3261e}
304 +.ev-tag.stat-soldout{background:#5f6368}
305 +.ev-tag.stat-postponed{background:#a05a00}
306 +
307 +/* grand événement vedette (À la une) */
308 +.feat{position:relative;display:block;border-radius:20px;overflow:hidden;
309 + aspect-ratio:16/8.5;min-height:300px;cursor:pointer;background:var(--ink);
310 + box-shadow:var(--shadow-off-soft);margin-bottom:16px}
311 +@media(max-width:640px){.feat{aspect-ratio:4/4.6;min-height:0;border-radius:17px}}
312 +.feat img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
313 + transition:transform .5s ease}
314 +.feat::before{content:"";position:absolute;inset:0;z-index:1;
315 + background:linear-gradient(198deg,rgba(20,24,20,0) 34%,rgba(20,24,20,.55) 62%,rgba(20,24,20,.88) 96%)}
316 +.feat-kick{position:absolute;top:14px;left:14px;z-index:2;font-family:var(--font-mono);
317 + font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;
318 + background:var(--paper);color:var(--accent-deep);border-radius:999px;padding:6px 12px}
319 +.feat .fav-btn{top:12px;right:12px}
320 +.feat-body{position:absolute;inset:auto 0 0 0;z-index:2;padding:20px 22px 22px;color:#fff}
321 +.feat-date{display:inline-flex;background:var(--accent);color:#fff;border-radius:9px;
322 + padding:6px 11px;font-family:var(--font-mono);font-size:11px;font-weight:700;
323 + text-transform:uppercase;letter-spacing:.05em;margin-bottom:11px}
324 +.feat h3{color:#fff;font-size:clamp(22px,3.6vw,36px);line-height:1.07;letter-spacing:-.02em;
325 + margin:0 0 7px;max-width:760px;display:-webkit-box;-webkit-line-clamp:3;
326 + -webkit-box-orient:vertical;overflow:hidden}
327 +.feat-where{font-size:13.5px;color:rgba(255,255,255,.85);margin:0;
328 + white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
329 +.feat-where b{color:#fff;font-weight:600}
330 +
331 +/* carrousel horizontal (scroll-snap, débord plein écran sur mobile) */
332 +.hscroll{display:grid;grid-auto-flow:column;grid-auto-columns:min(44vw,238px);gap:15px;
333 + overflow-x:auto;scroll-snap-type:x mandatory;padding:4px 2px 12px;scrollbar-width:none}
334 +.hscroll::-webkit-scrollbar{display:none}
335 +.hscroll .ev{scroll-snap-align:start}
336 +@media(max-width:640px){
337 + .hscroll{grid-auto-columns:60vw;margin-left:-16px;margin-right:-16px;padding:4px 16px 12px}
338 +}
339 +
340 +/* liste compacte (À découvrir) */
341 +.compact{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:2px 26px}
342 +.evc{display:flex;gap:13px;align-items:center;padding:11px 0;min-width:0;cursor:pointer;
343 + border-bottom:1px dashed var(--line)}
344 +.evc-img{position:relative;flex:none;width:76px;height:76px;border-radius:13px;overflow:hidden;
345 + background:linear-gradient(135deg,var(--accent-soft),var(--surface-2))}
346 +.evc-img::after{content:"";position:absolute;inset:0;border-radius:inherit;
347 + border:1px solid rgba(20,24,20,.14)}
348 +.evc-img img{width:100%;height:100%;object-fit:cover}
349 +.evc-body{min-width:0;flex:1}
350 +.evc .ev-date{font-size:10px}
351 +.evc h3{font-family:var(--font-display);font-size:14.5px;font-weight:700;line-height:1.25;
352 + letter-spacing:-.01em;margin:1px 0 2px;display:-webkit-box;-webkit-line-clamp:2;
353 + -webkit-box-orient:vertical;overflow:hidden}
354 +.evc .ev-where{font-size:12px}
355 +
356 +/* ---------- résultats : compteur, pilules actives, Liste|Carte ---------- */
357 +.rtool{display:flex;align-items:center;justify-content:space-between;gap:12px;
358 + flex-wrap:wrap;margin:20px 2px 8px}
359 +.meta-line{display:flex;align-items:center;gap:10px;
204 360 font-family:var(--font-mono);font-size:11.5px;text-transform:uppercase;
205 361 letter-spacing:.08em;color:var(--ink-3)}
206 362 .meta-line b{color:var(--ink)}
207 −.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;margin:8px 0 34px}
208 −.card{overflow:hidden;display:flex;flex-direction:column;cursor:pointer}
209 −.card-img{position:relative;height:150px;border-bottom:1.5px solid var(--ink);
210 − background:repeating-linear-gradient(-45deg,var(--lime-soft),var(--lime-soft) 12px,var(--surface-2) 12px,var(--surface-2) 24px);overflow:hidden}
211 −.card-img img{width:100%;height:100%;object-fit:cover}
212 −.badge-date{position:absolute;left:10px;bottom:10px;font-family:var(--font-mono);
213 − font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;
214 − color:var(--on-accent);background:var(--accent);border:1.5px solid var(--ink);
215 − border-radius:var(--r-ctl);padding:4px 9px;white-space:nowrap;box-shadow:2px 2px 0 var(--ink)}
216 −.card-body{padding:15px 16px 14px;display:flex;flex-direction:column;gap:8px;flex:1}
217 −.card-top{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}
363 +.afilters{display:flex;flex-wrap:wrap;gap:7px;margin:2px 2px 6px}
364 +.apill{display:inline-flex;align-items:center;gap:8px;min-height:34px;padding:5px 7px 5px 13px;
365 + border:1.5px solid var(--accent-deep);border-radius:999px;background:var(--accent-soft);
366 + font-family:var(--font-display);font-weight:700;font-size:12px;color:var(--accent-deep);
367 + cursor:pointer;transition:all .12s ease}
368 +.apill i{font-style:normal;width:20px;height:20px;border-radius:50%;background:var(--accent-deep);
369 + color:#fff;font-size:10px;display:inline-flex;align-items:center;justify-content:center}
370 +.apill:hover{background:var(--accent);color:#fff}
371 +.vtoggle{display:inline-flex;border:1.5px solid var(--ink);border-radius:999px;
372 + background:var(--surface);padding:3px;box-shadow:2px 2px 0 rgba(20,24,20,.12)}
373 +.vtoggle button{border:0;background:none;min-height:36px;padding:6px 16px;border-radius:999px;
374 + font-family:var(--font-display);font-weight:700;font-size:12.5px;cursor:pointer;
375 + display:inline-flex;gap:7px;align-items:center;color:var(--ink-2);transition:all .14s ease}
376 +.vtoggle button svg{width:13px;height:13px}
377 +.vtoggle button.on{background:var(--ink);color:var(--paper)}
378 +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(236px,1fr));gap:26px 20px;margin:12px 0 34px}
379 +@media(max-width:640px){.grid{grid-template-columns:1fr 1fr;gap:22px 12px}
380 + .grid .ev h3{font-size:14.5px}}
381 +
382 +/* ---------- carte (Leaflet, chargé à la demande) ---------- */
383 +.mapwrap{position:relative;isolation:isolate;border:1.5px solid var(--ink);border-radius:17px;
384 + overflow:hidden;height:min(68dvh,640px);margin:12px 0 44px;background:var(--surface-2)}
385 +@media(max-width:760px){.mapwrap{height:calc(var(--vh100, 100dvh) - 190px);margin:10px 0 30px}}
386 +#map{position:absolute;inset:0;z-index:0}
387 +.map-note{position:absolute;left:12px;bottom:12px;z-index:1200;font-family:var(--font-mono);
388 + font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;
389 + background:rgba(255,255,255,.92);border:1px solid rgba(20,24,20,.3);color:var(--ink-2);
390 + border-radius:999px;padding:6px 12px;pointer-events:none;max-width:70%}
391 +.map-load{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
392 + z-index:1100;font-family:var(--font-mono);font-size:11px;text-transform:uppercase;
393 + letter-spacing:.08em;color:var(--ink-3);background:var(--surface-2)}
394 +.leaflet-popup-content-wrapper{border-radius:14px;border:1.5px solid var(--ink);
395 + box-shadow:4px 4px 0 rgba(20,24,20,.18);font-family:var(--font-body)}
396 +.leaflet-popup-content{margin:0;width:220px!important}
397 +.mpop{display:block;color:var(--ink);text-decoration:none}
398 +.mpop img{width:100%;height:104px;object-fit:cover;border-radius:12px 12px 0 0}
399 +.mpop .mp-b{padding:10px 13px 12px}
400 +.mpop .ev-date{font-size:10px}
401 +.mpop h4{font-family:var(--font-display);font-size:13.5px;font-weight:700;line-height:1.25;
402 + margin:3px 0 3px;letter-spacing:-.01em}
403 +.mpop p{font-size:11.5px;color:var(--ink-2);margin:0;white-space:nowrap;
404 + overflow:hidden;text-overflow:ellipsis}
405 +
406 +/* badges conservés (fiche événement + bannières de statut) */
218 407 .badge-free{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;
219 − color:#fff;background:var(--green);border-radius:var(--r-ctl);padding:4px 8px;flex:none}
408 + color:#fff;background:var(--green);border-radius:7px;padding:4px 8px;flex:none}
220 409 .badge-price{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;
221 410 color:var(--ink);background:var(--amber-soft);border:1px solid var(--amber);
222 − border-radius:var(--r-ctl);padding:3px 8px;flex:none}
411 + border-radius:7px;padding:3px 8px;flex:none}
223 412 .badge-status{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;
224 − color:#fff;background:#b3261e;border:1px solid var(--ink);
225 − border-radius:var(--r-ctl);padding:4px 8px;flex:none}
413 + color:#fff;background:#b3261e;border-radius:7px;padding:4px 8px;flex:none}
226 414 .badge-status--soldout{background:#5f6368}
227 415 .badge-status--postponed{background:#a05a00}
228 −.status-banner{margin:0 0 14px;padding:10px 14px;border:1.5px solid var(--ink);
229 − border-radius:var(--r-ctl);font-weight:700;color:#fff;background:#b3261e;
230 − box-shadow:2px 2px 0 var(--ink)}
416 +.status-banner{margin:0 0 14px;padding:11px 15px;border-radius:12px;font-weight:700;
417 + color:#fff;background:#b3261e}
231 418 .status-banner--soldout{background:#5f6368}
232 419 .status-banner--postponed{background:#a05a00}
233 −.card h3{font-size:17px;font-weight:700;line-height:1.25}
234 −.card .desc{font-size:12.5px;color:var(--ink-2);display:-webkit-box;
235 − -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
236 −.card .where{font-size:12.5px;color:var(--ink-3)}
237 −.card .where b{color:var(--ink-2);font-weight:600}
238 −.card .cats{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto;padding-top:4px}
239 420 .cat{font-family:var(--font-mono);font-size:9.5px;font-weight:700;letter-spacing:.06em;
240 421 text-transform:uppercase;color:var(--ink-2);border:1px solid var(--line-strong);
241 − border-radius:var(--r-ctl);padding:2px 7px}
242 −.card .src{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;
243 − letter-spacing:.04em;color:var(--ink-3);border-top:1px dashed var(--line-strong);
244 − padding-top:10px;margin-top:6px;display:flex;justify-content:space-between}
245 −.card .src .go{color:var(--green);font-weight:700}
422 + border-radius:999px;padding:3px 9px}
246 423 .more{display:block;margin:0 auto 54px}
424 +.more[hidden]{display:none}
247 425 .empty{padding:60px 10px;text-align:center;color:var(--ink-3);grid-column:1/-1}
248 426 .empty b{font-family:var(--font-display);font-size:19px;color:var(--ink);display:block;margin-bottom:6px}
249 427
250 −/* ---------- fiche événement ---------- */
251 −.detail{padding:34px 0 50px}
428 +/* ---------- fiche événement v2 ---------- */
429 +.detail{padding-top:24px;padding-bottom:60px}
252 430 .back{display:inline-flex;align-items:center;gap:8px;min-height:44px;font-family:var(--font-mono);
253 431 font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;
254 − color:var(--green);cursor:pointer;margin-bottom:8px}
432 + color:var(--accent-deep);cursor:pointer;margin-bottom:6px}
255 433 @media(pointer:coarse){.chip{min-height:44px}.user-chip .out{min-height:32px;padding:2px 8px}}
256 434 .back:hover{text-decoration:underline}
257 −.detail-grid{display:grid;grid-template-columns:1.5fr 1fr;gap:26px;align-items:start}
258 −@media(max-width:860px){.detail-grid{grid-template-columns:1fr}}
259 −.detail-hero{overflow:hidden}
260 −.detail-hero .img{height:340px;border-bottom:1.5px solid var(--ink);
261 − background:repeating-linear-gradient(-45deg,var(--lime-soft),var(--lime-soft) 14px,var(--surface-2) 14px,var(--surface-2) 28px)}
262 −.detail-hero .img img{width:100%;height:100%;object-fit:cover}
263 −.detail-hero .body{padding:24px 26px 26px}
264 −.detail-hero h1{font-size:clamp(26px,3.4vw,40px);line-height:1.08;margin:10px 0 14px}
265 −.detail-hero .desc{color:var(--ink-2);font-size:15px;white-space:pre-line}
266 −.detail-cats{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}
267 −.side{display:flex;flex-direction:column;gap:16px}
268 −.side .box{padding:18px 20px}
269 −.fact{display:flex;justify-content:space-between;gap:14px;padding:9px 0;border-bottom:1px dashed var(--line-strong);font-size:13.5px}
270 −.fact:last-child{border-bottom:none}
435 +.detail-grid{display:grid;grid-template-columns:1.55fr 1fr;gap:30px;align-items:start}
436 +@media(max-width:860px){.detail-grid{grid-template-columns:1fr;gap:20px}}
437 +.detail-main{min-width:0}
438 +.d-imgwrap{position:relative;aspect-ratio:16/9;border-radius:19px;overflow:hidden;
439 + background:linear-gradient(135deg,var(--accent-soft),var(--surface-2))}
440 +.d-imgwrap::after{content:"";position:absolute;inset:0;border-radius:inherit;
441 + border:1px solid rgba(20,24,20,.18);pointer-events:none}
442 +.d-imgwrap img{width:100%;height:100%;object-fit:cover}
443 +@media(max-width:640px){.d-imgwrap{aspect-ratio:4/3;border-radius:15px;margin:0 0 2px}}
444 +.d-body{padding:20px 2px 0}
445 +.d-body h1{font-size:clamp(26px,3.6vw,42px);line-height:1.07;margin:10px 0 14px}
446 +.d-body .desc{color:var(--ink-2);font-size:15px;white-space:pre-line;max-width:70ch}
447 +.detail-cats{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}
448 +.side{display:flex;flex-direction:column;gap:14px;position:sticky;top:76px}
449 +@media(max-width:860px){.side{position:static}}
450 +.side .box{background:var(--surface);border:1px solid rgba(20,24,20,.25);border-radius:16px;
451 + padding:18px 20px;box-shadow:var(--shadow-flat)}
452 +.fact{display:flex;justify-content:space-between;gap:14px;padding:10px 0;
453 + border-bottom:1px dashed var(--line);font-size:13.5px}
454 +.fact:first-child{padding-top:2px}
455 +.fact:last-child{border-bottom:none;padding-bottom:2px}
271 456 .fact .k{font-family:var(--font-mono);font-size:10px;font-weight:700;
272 457 text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);padding-top:3px;flex:none}
273 458 .fact .v{text-align:right;font-weight:600}
274 459 .side .btn{width:100%}
275 −.maplink{font-family:var(--font-mono);font-size:11px;color:var(--green);font-weight:700;text-transform:uppercase}
460 +.btn-cta{background:var(--accent);color:#fff;border-color:var(--accent-deep);
461 + box-shadow:4px 4px 0 rgba(161,37,81,.3)}
462 +.btn-cta:hover{background:var(--accent-deep);color:#fff}
463 +.maplink{font-family:var(--font-mono);font-size:11px;color:var(--accent-deep);font-weight:700;text-transform:uppercase}
276 464 .maplink:hover{text-decoration:underline}
277 −.similar h2{font-size:22px;margin:44px 0 4px}
465 +.similar h2{font-size:22px;margin:46px 0 4px}
466 +/* barre d'action collée en bas — mobile seulement (billets toujours à un pouce) */
467 +.d-ctabar{display:none}
468 +@media(max-width:860px){
469 + .d-ctabar{position:fixed;left:0;right:0;bottom:0;z-index:var(--z-bottombar,600);
470 + display:flex;align-items:center;gap:14px;background:var(--surface);
471 + border-top:1.5px solid var(--ink);padding:10px 16px calc(10px + env(safe-area-inset-bottom))}
472 + .d-ctabar .p{flex:1;min-width:0}
473 + .d-ctabar .p b{font-family:var(--font-display);font-size:15.5px;letter-spacing:-.01em;
474 + display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
475 + .d-ctabar .p span{display:block;font-family:var(--font-mono);font-size:9.5px;
476 + text-transform:uppercase;letter-spacing:.05em;color:var(--accent-deep);margin-top:1px}
477 + .d-ctabar .btn{flex:none;min-width:132px}
478 + .detail{padding-bottom:120px}
479 + /* le bouton ka-agent remonte au-dessus de la barre CTA sur la fiche mobile */
480 + body:has(#view-detail:not(.hidden)) .kaa-btn{
481 + bottom:calc(86px + env(safe-area-inset-bottom,0px))!important}
482 +}
483 +.d-imgwrap.noimg{aspect-ratio:16/5}
278 484
279 485 /* ---------- sources / footer ---------- */
280 −.sources{background:var(--surface-2);border-top:1.5px solid var(--ink);padding:44px 0 38px}
486 +.sources{background:var(--surface-2);border-top:1px solid rgba(20,24,20,.35);padding:46px 0 40px;margin-top:26px}
281 487 .sources h2{font-size:26px;margin-top:12px}
282 488 .sources p.lede2{color:var(--ink-2);font-size:14.5px;max-width:720px;margin:10px 0 0}
283 −.src-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-top:22px}
284 −.src-card{padding:16px 18px}
285 −.src-card h4{font-size:15px;margin-bottom:5px}
489 +.src-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:24px}
490 +.src-card{background:var(--surface);border:1px solid rgba(20,24,20,.18);border-radius:14px;
491 + padding:15px 17px;box-shadow:var(--shadow-flat)}
492 +.src-card h4{font-size:14.5px;margin-bottom:5px}
286 493 .src-card .lic{font-family:var(--font-mono);font-size:9.5px;font-weight:700;
287 494 color:var(--green);text-transform:uppercase;letter-spacing:.06em}
288 495 .src-card .n{font-size:12.5px;color:var(--ink-3);margin-top:6px}
289 −.src-card .n b{color:var(--green-deep)}
496 +.src-card .n b{color:var(--accent-deep)}
290 497 .ka-footer{margin-top:0}
291 498
292 499 /* ---------- page /contact ---------- */
293 −.contact-page{padding:44px 0 64px}
500 +.contact-page{padding-top:44px;padding-bottom:64px}
294 501 .contact-page h1{font-size:clamp(30px,4.2vw,48px);line-height:1.06;margin-top:16px;max-width:820px}
295 502 .contact-page .lede{color:var(--ink-2);font-size:16px;max-width:660px;margin:16px 0 0}
296 503 .contact-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr));
@@ -314,8 +521,8 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
314 521 .contact-sites a small{font-family:var(--font-mono);font-weight:500;font-size:10px;
315 522 color:var(--ink-3);margin-left:8px}
316 523 /* ---------- page /stats (module ka-stats, vanilla + SVG) ---------- */
317 −.stats-page{padding:40px 0 60px}
318 −@media(max-width:640px){.stats-page{padding:28px 0 44px}}
524 +.stats-page{padding-top:40px;padding-bottom:60px}
525 +@media(max-width:640px){.stats-page{padding-top:28px;padding-bottom:44px}}
319 526 .st-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-end;justify-content:space-between}
320 527 .st-head h1{font-size:clamp(28px,4vw,44px);line-height:1.06;margin-top:12px}
321 528 .st-fresh{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}
@@ -455,6 +662,55 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
455 662 .d-title-row h1{min-width:0}
456 663 .fav-empty{grid-column:1/-1}
457 664 .fav-empty .btn-ka{margin-top:12px}
665 +/* ---------- panneau de filtres : bottom sheet mobile / modale centrée desktop ---------- */
666 +.sheet-ov{position:fixed;inset:0;z-index:var(--z-overlay,800);background:rgba(20,24,20,.45);
667 + opacity:0;transition:opacity .22s ease;pointer-events:none}
668 +.sheet-ov.open{opacity:1;pointer-events:auto}
669 +.sheet{position:fixed;left:0;right:0;bottom:0;z-index:var(--z-modal,900);background:var(--paper);
670 + border:1.5px solid var(--ink);border-bottom:0;border-radius:22px 22px 0 0;
671 + max-height:88dvh;display:flex;flex-direction:column;
672 + transform:translateY(103%);transition:transform .3s cubic-bezier(.32,.72,.28,1);
673 + visibility:hidden}
674 +.sheet.open{transform:none;visibility:visible}
675 +.sheet-grab{width:44px;height:5px;border-radius:3px;background:rgba(20,24,20,.22);
676 + margin:10px auto 2px;flex:none}
677 +.sheet-head{display:flex;justify-content:space-between;align-items:center;
678 + padding:8px 20px 12px;border-bottom:1px solid var(--line);flex:none}
679 +.sheet-head h2{font-size:17px;margin:0}
680 +.sheet-x{width:38px;height:38px;border-radius:50%;border:1.5px solid var(--ink);
681 + background:var(--surface);cursor:pointer;font-size:14px;line-height:1;padding:0;
682 + display:inline-flex;align-items:center;justify-content:center}
683 +.sheet-x:hover{background:var(--accent-soft)}
684 +.sheet-body{overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;
685 + padding:18px 20px 12px}
686 +.sheet-sec{margin:0 0 22px}
687 +.sheet-sec>.klabel{display:block;margin-bottom:10px}
688 +.sheet-chips{display:flex;flex-wrap:wrap;gap:8px}
689 +.sheet .select{max-width:420px}
690 +.sheet-dates{display:flex;gap:8px;align-items:center;margin-top:12px;flex-wrap:wrap}
691 +.sheet-dates .input{width:158px;padding:9px 11px;font-size:14px;border-radius:11px}
692 +.sheet-foot{display:flex;gap:10px;padding:12px 20px calc(14px + env(safe-area-inset-bottom));
693 + border-top:1.5px solid var(--ink);flex:none;background:var(--surface);border-radius:0}
694 +.sheet-foot .btn{flex:1;border-radius:12px}
695 +@media(min-width:761px){
696 + .sheet{left:50%;right:auto;bottom:auto;top:50%;width:min(620px,94vw);max-height:82vh;
697 + border-radius:20px;border-bottom:1.5px solid var(--ink);
698 + box-shadow:12px 12px 0 rgba(20,24,20,.16);
699 + transform:translate(-50%,-46%) scale(.97);opacity:0;
700 + transition:opacity .18s ease,transform .18s ease}
701 + .sheet.open{opacity:1;transform:translate(-50%,-50%) scale(1)}
702 + .sheet-grab{display:none}
703 + .sheet-foot{border-radius:0 0 18px 18px}
704 +}
705 +
706 +/* ---------- toast ---------- */
707 +.toast{position:fixed;left:50%;bottom:calc(22px + env(safe-area-inset-bottom));
708 + transform:translateX(-50%) translateY(10px);z-index:var(--z-toast,950);
709 + background:var(--ink);color:var(--paper);border-radius:13px;padding:12px 19px;
710 + font-size:13.5px;font-weight:600;opacity:0;transition:all .25s ease;
711 + pointer-events:none;max-width:92vw;text-align:center;box-shadow:0 8px 24px rgba(20,24,20,.3)}
712 +.toast.show{opacity:1;transform:translateX(-50%)}
713 +
458 714 #ssr{display:none}
459 715 .hidden{display:none!important}
460 716 </style>
@@ -480,11 +736,10 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
480 736 Un service <b>Groupe<span class="ka">KA</span></b></a>
481 737 <span class="brand-tag">Sorties &amp; événements — Québec</span>
482 738 <nav class="nav">
483 − <a href="/#sources">Sources</a>
739 + <a data-nav href="/">Sorties</a>
484 740 <a data-nav href="/favoris" title="Mes favoris — Mon univers Ka">♥ Favoris</a>
485 741 <a data-nav href="/stats">Stats</a>
486 742 <a data-nav href="/contact">Contact</a>
487 − <a href="/docs" target="_blank" rel="noopener">API</a>
488 743 </nav>
489 744 <span id="auth"></span>
490 745 <button id="ka-mnav-btn" class="ka-mnav-btn" aria-label="Ouvrir le menu"
@@ -520,40 +775,59 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
520 775 </div>
521 776 </div>
522 777
523 −<!-- ======== VUE LISTE ======== -->
778 +<!-- ======== VUE LISTE : accueil éditorial + résultats Liste|Carte ======== -->
524 779 <main id="view-list">
525 − <section class="hero container">
526 − <span class="kicker">Agrégateur indépendant · mise à jour horaire</span>
527 − <h1>Toutes les sorties du Québec, <span class="hl">un seul endroit.</span></h1>
528 − <p class="lede">Concerts, festivals, spectacles, expositions, sport, famille — agrégés
529 − depuis des billetteries et des sources ouvertes officielles, dans les 17 régions.
530 − Chaque fiche renvoie vers la billetterie ou la source originale.</p>
531 − <div class="stat-row" id="stats"></div>
780 + <section class="hero">
781 + <div class="hero-mosaic" id="hero-mosaic" aria-hidden="true"></div>
782 + <div class="container">
783 + <span class="kicker">Agrégateur indépendant · mise à jour continue</span>
784 + <h1>Toutes les sorties du Québec, <span class="hl">un seul endroit.</span></h1>
785 + <p class="hero-cats">concerts <i>·</i> festivals <i>·</i> humour <i>·</i> théâtre
786 + <i>·</i> expos <i>·</i> famille <i>·</i> sport</p>
787 + <p class="hero-stats" id="stats"></p>
788 + </div>
532 789 </section>
533 790
534 − <section class="container">
535 − <div class="filterbar gk-card">
536 − <div class="f-primary">
537 − <label class="f-search">🔎<input type="text" id="q" placeholder="Artiste, événement, salle, ville…"></label>
538 − <label class="f-ctl"><span class="klabel">Région</span>
539 − <select id="region"><option value="">Toutes les régions</option></select>
540 − </label>
541 − <label class="f-ctl"><span class="klabel">Quand</span>
542 − <select id="period">
543 − <option value="">À venir (tout)</option>
544 − <option value="today">Aujourd'hui</option>
545 − <option value="weekend">Ce week-end</option>
546 − <option value="7">7 prochains jours</option>
547 − <option value="30">30 prochains jours</option>
548 − </select>
549 − </label>
550 − </div>
551 − <div class="chips" id="cats"></div>
791 + <section class="container searchzone">
792 + <div class="searchbar" role="search">
793 + <svg class="sico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
794 + stroke-linecap="round" aria-hidden="true">
795 + <circle cx="11" cy="11" r="7"/><path d="m20 20-3.8-3.8"/></svg>
796 + <input type="search" id="q" placeholder="Artiste, événement, salle ou ville…"
797 + autocomplete="off" aria-label="Rechercher une sortie">
798 + <button class="sclear hidden" id="q-clear" aria-label="Effacer la recherche">✕</button>
552 799 </div>
800 + <div class="quick" id="quick"></div>
801 + <div class="days" id="days" aria-label="Naviguer par jour"></div>
802 + </section>
553 803
554 − <div class="meta-line"><span id="count">Chargement…</span></div>
804 + <!-- accueil éditorial : sections composées par renderHome() -->
805 + <section class="container" id="home"></section>
806 +
807 + <!-- résultats de recherche / filtres : liste ou carte -->
808 + <section class="container hidden" id="results">
809 + <div class="rtool">
810 + <div class="meta-line"><span id="count">Chargement…</span></div>
811 + <div class="vtoggle" role="group" aria-label="Affichage">
812 + <button type="button" id="v-list" class="on" aria-pressed="true">
813 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
814 + stroke-linecap="round" aria-hidden="true">
815 + <path d="M4 6h16M4 12h16M4 18h16"/></svg>Liste</button>
816 + <button type="button" id="v-map" aria-pressed="false">
817 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
818 + stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
819 + <path d="M12 21s-7-5.5-7-11a7 7 0 0 1 14 0c0 5.5-7 11-7 11z"/>
820 + <circle cx="12" cy="10" r="2.6"/></svg>Carte</button>
821 + </div>
822 + </div>
823 + <div class="afilters" id="afilters"></div>
555 824 <section class="grid" id="grid"></section>
556 825 <button class="more btn btn-primary" id="more" hidden>Afficher plus d'événements</button>
826 + <div class="mapwrap hidden" id="mapwrap">
827 + <div id="map"></div>
828 + <div class="map-load" id="map-load">Chargement de la carte…</div>
829 + <span class="map-note hidden" id="map-note"></span>
830 + </div>
557 831 </section>
558 832
559 833 <section class="sources" id="sources">
@@ -568,14 +842,55 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
568 842 </section>
569 843 </main>
570 844
845 +<!-- ======== PANNEAU DE FILTRES (bottom sheet mobile / modale desktop) ======== -->
846 +<div class="sheet-ov" id="sheet-ov"></div>
847 +<div class="sheet" id="sheet" role="dialog" aria-modal="true" aria-label="Filtres">
848 + <div class="sheet-grab" aria-hidden="true"></div>
849 + <div class="sheet-head">
850 + <h2>Filtres</h2>
851 + <button type="button" class="sheet-x" id="sheet-x" aria-label="Fermer les filtres">✕</button>
852 + </div>
853 + <div class="sheet-body">
854 + <div class="sheet-sec">
855 + <span class="klabel">Quand</span>
856 + <div class="sheet-chips" id="sh-when"></div>
857 + <div class="sheet-dates">
858 + <input class="input" type="date" id="sh-from" aria-label="Du">
859 + <span class="klabel">au</span>
860 + <input class="input" type="date" id="sh-to" aria-label="Au">
861 + </div>
862 + </div>
863 + <div class="sheet-sec">
864 + <span class="klabel">Région</span>
865 + <select class="select" id="region"><option value="">Toutes les régions</option></select>
866 + </div>
867 + <div class="sheet-sec">
868 + <span class="klabel">Catégories</span>
869 + <div class="sheet-chips" id="cats"></div>
870 + </div>
871 + <div class="sheet-sec">
872 + <span class="klabel">Prix</span>
873 + <div class="sheet-chips">
874 + <button type="button" class="chip free" id="chip-free">✦ Gratuit seulement</button>
875 + </div>
876 + </div>
877 + </div>
878 + <div class="sheet-foot">
879 + <button type="button" class="btn btn-ghost" id="sh-reset">Réinitialiser</button>
880 + <button type="button" class="btn btn-cta" id="sh-apply">Voir les sorties</button>
881 + </div>
882 +</div>
883 +
884 +<div class="toast" id="toast" role="status" aria-live="polite"></div>
885 +
571 886 <!-- ======== VUE FICHE ÉVÉNEMENT ======== -->
572 887 <main id="view-detail" class="hidden">
573 888 <div class="container detail">
574 889 <span class="back" id="back">← Toutes les sorties</span>
575 890 <div class="detail-grid">
576 − <article class="gk-card detail-hero">
577 − <div class="img" id="d-img"></div>
578 − <div class="body">
891 + <article class="detail-main">
892 + <div class="d-imgwrap" id="d-img"></div>
893 + <div class="d-body">
579 894 <span class="klabel" id="d-cats-label"></span>
580 895 <div id="d-status"></div>
581 896 <div class="d-title-row"><h1 id="d-title"></h1><span id="d-fav"></span></div>
@@ -584,9 +899,9 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
584 899 </div>
585 900 </article>
586 901 <aside class="side">
587 − <div class="gk-card box" id="d-facts"></div>
588 − <a class="btn btn-primary" id="d-cta" target="_blank" rel="noopener">Billets · fiche originale ↗</a>
589 − <div class="gk-card box" id="d-src"></div>
902 + <div class="box" id="d-facts"></div>
903 + <a class="btn btn-cta" id="d-cta" target="_blank" rel="noopener">Billets · fiche originale ↗</a>
904 + <div class="box" id="d-src"></div>
590 905 </aside>
591 906 </div>
592 907 <section class="similar">
@@ -595,6 +910,8 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
595 910 <div class="grid" id="d-similar"></div>
596 911 </section>
597 912 </div>
913 + <!-- CTA sticky mobile : le billet toujours à portée de pouce -->
914 + <div class="d-ctabar" id="d-ctabar"></div>
598 915 </main>
599 916
600 917 <!-- ======== VUE FAVORIS (/favoris) — ♥ « Mon univers Ka » (hub groupe-ka.com) ======== -->
@@ -762,7 +1079,8 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
762 1079 /* Sorti-Ka — frontend + routeur. Auteur : Simon-Pierre Boucher <contact@spboucher.ai> */
763 1080 "use strict";
764 1081 const $ = id => document.getElementById(id);
765 −const state = { q:"", region:"", category:"", free:false, from:"", to:"", offset:0, limit:30, total:0 };
1082 +const state = { q:"", region:"", category:"", free:false, from:"", to:"", near:"",
1083 + view:"list", offset:0, limit:30, total:0 };
766 1084
767 1085 const CAT_LABELS = {
768 1086 "festival":"Festivals","musique":"Musique","arts-scene":"Arts de la scène",
@@ -814,32 +1132,105 @@ function statusBadge(ev){
814 1132 if(!ev.status || !STATUS_LABELS[ev.status]) return "";
815 1133 return `<span class="badge-status badge-status--${esc(ev.status)}">${STATUS_LABELS[ev.status]}</span>`;
816 1134 }
817 −function priceBadge(ev){
818 − if(ev.is_free === true) return `<span class="badge-free">Gratuit</span>`;
819 − if(ev.price_min != null) return `<span class="badge-price">Dès ${ev.price_min.toFixed(2).replace(".",",")} $</span>`;
820 − if(ev.is_free === false) return `<span class="badge-price">Payant</span>`;
1135 +function money(v){
1136 + return `${v.toFixed(2).replace(".",",").replace(/,00$/,"")} $`;
1137 +}
1138 +function priceTxt(ev){
1139 + if(ev.is_free === true) return "Gratuit";
1140 + if(ev.price_min != null) return `Dès ${money(ev.price_min)}`;
1141 + return "";
1142 +}
1143 +const WDAYS = ["dim","lun","mar","mer","jeu","ven","sam"];
1144 +/* date éditoriale des fiches : « Mer 27 août · 20 h » / « 27 août → 3 sept » */
1145 +function evDate(ev){
1146 + if(!ev.start_date) return "Date à confirmer";
1147 + const yNow = new Date().getFullYear();
1148 + const [y,m,d] = ev.start_date.split("-").map(Number);
1149 + if(ev.end_date && ev.end_date !== ev.start_date){
1150 + const [y2,m2,d2] = ev.end_date.split("-").map(Number);
1151 + return `${d} ${MONTHS[m-1]} → ${d2} ${MONTHS[m2-1]}${y2!==yNow?` ${y2}`:""}`;
1152 + }
1153 + const wd = WDAYS[new Date(y, m-1, d).getDay()];
1154 + let s = `${wd} ${d} ${MONTHS[m-1]}${y!==yNow?` ${y}`:""}`;
1155 + if(ev.start_time && ev.start_time !== "00:00") s += ` · ${fmtTime(ev.start_time)}`;
1156 + return s;
1157 +}
1158 +/* étiquette posée sur l'image : statut sinon prix */
1159 +function evTag(ev){
1160 + if(ev.status && STATUS_LABELS[ev.status])
1161 + return `<span class="ev-tag stat-${esc(ev.status)}">${STATUS_LABELS[ev.status]}</span>`;
1162 + if(ev.is_free === true) return `<span class="ev-tag free">Gratuit</span>`;
1163 + if(ev.price_min != null) return `<span class="ev-tag">Dès ${money(ev.price_min)}</span>`;
821 1164 return "";
822 1165 }
1166 +function evImg(ev){
1167 + return ev.image
1168 + ? `<img src="${esc(ev.image)}" alt="" loading="lazy"
1169 + onerror="this.parentElement.classList.add('noimg');this.remove()">` : "";
1170 +}
823 1171
1172 +/* fiche standard (grilles + carrousels) — l'image domine, hiérarchie
1173 + DATE / titre / lieu · ville / prix (sur l'image) / catégorie · source */
824 1174 function card(ev){
825 − const where = [ev.venue, ev.city, ev.region].filter(Boolean)
826 − .map((x,i)=> i===1?`<b>${esc(x)}</b>`:esc(x)).join(" · ");
827 − const cats = (ev.categories||[]).filter(c=>c!=="autre").slice(0,3)
828 − .map(c=>`<span class="cat">${esc(CAT_LABELS[c]||c)}</span>`).join("");
829 − const img = ev.image
830 − ? `<img src="${esc(ev.image)}" alt="" loading="lazy" onerror="this.remove()">` : "";
831 − return `<a class="card gk-card gk-hover" data-nav href="/evenement/${esc(ev.uid)}">
832 − <div class="card-img">${img}<span class="badge-date">${dateBadge(ev)}</span>${favBtn(ev)}</div>
833 − <div class="card-body">
834 − <div class="card-top"><h3>${esc(ev.title)}</h3>${statusBadge(ev)||priceBadge(ev)}</div>
835 − ${ev.description?`<div class="desc">${esc(ev.description)}</div>`:""}
836 − <div class="where">${where||"Lieu à confirmer"}</div>
837 − <div class="cats">${cats}</div>
838 − <div class="src"><span>${SRC_LABELS[ev.source]||ev.source}</span><span class="go">Fiche →</span></div>
839 − </div>
840 − </a>`;
1175 + const where = [ev.venue, ev.city].filter(Boolean).map(esc).join(" · ");
1176 + const cat = (ev.categories||[]).filter(c=>c!=="autre")[0];
1177 + return `<a class="ev" data-nav href="/evenement/${esc(ev.uid)}">
1178 + <div class="ev-img${ev.image?"":" noimg"}">${evImg(ev)}${evTag(ev)}${favBtn(ev)}</div>
1179 + <div class="ev-body">
1180 + <span class="ev-date">${evDate(ev)}</span>
1181 + <h3>${esc(ev.title)}</h3>
1182 + <p class="ev-where">${where || esc(ev.region || "Québec")}</p>
1183 + <p class="ev-meta">${cat?`<span>${esc(CAT_LABELS[cat]||cat)}</span>`:""}
1184 + <span class="src">${esc(SRC_LABELS[ev.source]||ev.source)}</span></p>
1185 + </div></a>`;
1186 +}
1187 +
1188 +/* grand événement vedette — image pleine largeur, texte en surimpression */
1189 +function featCard(ev){
1190 + const where = [ev.venue, ev.city].filter(Boolean).map(esc).join(" · ");
1191 + const p = priceTxt(ev);
1192 + return `<a class="feat" data-nav href="/evenement/${esc(ev.uid)}">
1193 + ${evImg(ev)}<span class="feat-kick">À la une</span>${favBtn(ev)}
1194 + <div class="feat-body">
1195 + <span class="feat-date">${evDate(ev)}</span>
1196 + <h3>${esc(ev.title)}</h3>
1197 + <p class="feat-where">${where}${p?` — <b>${p}</b>`:""}</p>
1198 + </div></a>`;
841 1199 }
842 1200
1201 +/* rangée compacte (À découvrir) */
1202 +function compactCard(ev){
1203 + const where = [ev.venue, ev.city].filter(Boolean).map(esc).join(" · ");
1204 + return `<a class="evc" data-nav href="/evenement/${esc(ev.uid)}">
1205 + <span class="evc-img${ev.image?"":" noimg"}">${evImg(ev)}</span>
1206 + <span class="evc-body">
1207 + <span class="ev-date">${evDate(ev)}</span>
1208 + <h3>${esc(ev.title)}</h3>
1209 + <p class="ev-where">${where || esc(ev.region || "Québec")}</p>
1210 + </span></a>`;
1211 +}
1212 +
1213 +/* ---------- état des filtres ---------- */
1214 +function isoLocal(d){
1215 + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
1216 +}
1217 +const todayISO = () => isoLocal(new Date());
1218 +/* fin de semaine : vendredi → dimanche (ou aujourd'hui si déjà entamée) */
1219 +function weekendRange(){
1220 + const t = new Date(), day = t.getDay();
1221 + const fri = new Date(t);
1222 + if(day >= 1 && day <= 4) fri.setDate(t.getDate() + (5 - day));
1223 + const sun = new Date(fri);
1224 + sun.setDate(fri.getDate() + (fri.getDay() === 0 ? 0 : 7 - fri.getDay()));
1225 + return [isoLocal(fri), isoLocal(sun)];
1226 +}
1227 +function plusDays(n){
1228 + const d = new Date(); d.setDate(d.getDate()+n); return isoLocal(d);
1229 +}
1230 +function isFiltering(){
1231 + return !!(state.q || state.region || state.category || state.free
1232 + || state.from || state.to || state.near);
1233 +}
843 1234 function params(){
844 1235 const p = new URLSearchParams();
845 1236 if(state.q) p.set("q", state.q);
@@ -848,13 +1239,153 @@ function params(){
848 1239 if(state.free) p.set("free","true");
849 1240 if(state.from) p.set("from", state.from);
850 1241 if(state.to) p.set("to", state.to);
1242 + if(state.near){ p.set("near", state.near); p.set("radius_km","30"); p.set("sort","near"); }
851 1243 p.set("limit", state.limit); p.set("offset", state.offset);
852 1244 return p.toString();
853 1245 }
1246 +async function fetchEvents(qs){
1247 + return await (await fetch(`/api/events?${qs}`)).json();
1248 +}
1249 +
1250 +/* ---------- accueil éditorial : sections composées ---------- */
1251 +let HOME_DONE = false;
1252 +const HOME_DEFS = [
1253 + {id:"alaune", sk:"Sélection Sorti·Ka", t:"À la une", mode:"feat",
1254 + q:{limit:30}},
1255 + {id:"cesoir", sk:"Aujourd'hui", t:"Ce soir", mode:"row",
1256 + q:()=>({from:todayISO(), to:todayISO(), limit:24}), all:()=>({from:todayISO(), to:todayISO()})},
1257 + {id:"weekend", sk:"Vendredi → dimanche", t:"Cette fin de semaine", mode:"grid",
1258 + q:()=>{ const [a,b]=weekendRange(); return {from:a, to:b, limit:8}; },
1259 + all:()=>{ const [a,b]=weekendRange(); return {from:a, to:b}; }},
1260 + {id:"gratuit", sk:"0 $", t:"Gratuit", mode:"row",
1261 + q:{free:true, limit:14}, all:{free:true}},
1262 + {id:"musique", sk:"Catégorie", t:"Concerts & musique", mode:"row",
1263 + q:{category:"musique", limit:14}, all:{category:"musique"}},
1264 + {id:"festival", sk:"Catégorie", t:"Festivals", mode:"row",
1265 + q:{category:"festival", limit:14}, all:{category:"festival"}},
1266 + {id:"famille", sk:"Catégorie", t:"Famille", mode:"row",
1267 + q:{category:"famille", limit:14}, all:{category:"famille"}},
1268 + {id:"decouvrir", sk:"Fraîchement ajoutés", t:"À découvrir", mode:"compact",
1269 + q:{sort:"recent", limit:10}},
1270 +];
1271 +function secHead(d, total){
1272 + const n = total ? `<button type="button" class="sec-all" data-all="${d.id}">
1273 + Tout voir (${total.toLocaleString("fr-CA")}) →</button>` : "";
1274 + return `<div class="sec-head"><div><span class="sk">${d.sk}</span><h2>${d.t}</h2></div>${n}</div>`;
1275 +}
1276 +function heroMosaic(evs){
1277 + const tiles = evs.filter(e=>e.image).slice(0,12);
1278 + if(!tiles.length) return;
1279 + $("hero-mosaic").innerHTML = tiles.map(e =>
1280 + `<span class="tile"><img src="${esc(e.image)}" alt="" loading="lazy"
1281 + onerror="this.remove()"></span>`).join("");
1282 +}
1283 +/* durée d'un événement en jours (0 = un seul jour) */
1284 +function evSpan(e){
1285 + if(!e.end_date || !e.start_date || e.end_date === e.start_date) return 0;
1286 + return (new Date(e.end_date) - new Date(e.start_date)) / 864e5;
1287 +}
1288 +async function renderHome(){
1289 + if(HOME_DONE) return;
1290 + HOME_DONE = true;
1291 + const host = $("home");
1292 + host.innerHTML = HOME_DEFS.map(d=>`<section class="sec" id="sec-${d.id}"></section>`).join("");
1293 + const results = await Promise.all(HOME_DEFS.map(async d => {
1294 + try{
1295 + const spec = typeof d.q === "function" ? d.q() : d.q;
1296 + const p = new URLSearchParams();
1297 + Object.entries(spec).forEach(([k,v]) => p.set(k, v));
1298 + return [d, await fetchEvents(p.toString())];
1299 + }catch(e){ return [d, null]; }
1300 + }));
1301 + // rendu séquentiel avec dédoublonnage inter-sections : un événement déjà mis
1302 + // en vitrine plus haut ne se répète pas ; les expos/festivals au long cours
1303 + // cèdent la vedette aux événements datés (le rythme vient de la variété)
1304 + const seen = new Set();
1305 + const take = (evs, n) => {
1306 + const out = evs.slice(0, n);
1307 + out.forEach(e => seen.add(e.uid));
1308 + return out;
1309 + };
1310 + for(const [d, data] of results){
1311 + const el = $(`sec-${d.id}`);
1312 + if(!el) continue;
1313 + if(!data){ el.remove(); continue; }
1314 + let evs = (data.events || []).filter(e => !seen.has(e.uid));
1315 + if(d.id === "alaune"){
1316 + evs = evs.filter(e => e.image);
1317 + evs.sort((a,b) => (evSpan(a) > 3) - (evSpan(b) > 3));
1318 + } else if(d.id === "cesoir" || d.id === "weekend"){
1319 + evs.sort((a,b) => (evSpan(a) > 3) - (evSpan(b) > 3));
1320 + }
1321 + if(!evs.length){ el.remove(); continue; }
1322 + if(d.mode === "feat"){
1323 + const picked = take(evs, 10);
1324 + el.innerHTML = secHead(d, 0) + featCard(picked[0])
1325 + + (picked.length > 1 ? `<div class="hscroll">${picked.slice(1).map(card).join("")}</div>` : "");
1326 + heroMosaic(evs);
1327 + } else if(d.mode === "row"){
1328 + el.innerHTML = secHead(d, data.total) + `<div class="hscroll">${take(evs, 14).map(card).join("")}</div>`;
1329 + } else if(d.mode === "grid"){
1330 + el.innerHTML = secHead(d, data.total) + `<div class="grid">${take(evs, 8).map(card).join("")}</div>`;
1331 + } else {
1332 + el.innerHTML = secHead(d, 0) + `<div class="compact">${take(evs, 10).map(compactCard).join("")}</div>`;
1333 + }
1334 + bindNav(el);
1335 + const btn = el.querySelector("[data-all]");
1336 + if(btn) btn.onclick = () => {
1337 + const f = typeof d.all === "function" ? d.all() : (d.all || {});
1338 + resetFilters(false);
1339 + if(f.from){ state.from = f.from; state.to = f.to || ""; }
1340 + if(f.free) state.free = true;
1341 + if(f.category) state.category = f.category;
1342 + load();
1343 + window.scrollTo({top: $("results").offsetTop - 130, behavior:"smooth"});
1344 + };
1345 + }
1346 + favPaint();
1347 +}
1348 +
1349 +/* ---------- pilules de filtres actifs (résultats) ---------- */
1350 +function filterPills(){
1351 + const pills = [];
1352 + if(state.q) pills.push(["q", `« ${state.q} »`]);
1353 + if(state.from && state.from === state.to && state.from === todayISO()) pills.push(["dates","Aujourd'hui"]);
1354 + else if(state.from || state.to){
1355 + const w = weekendRange();
1356 + pills.push(["dates", state.from===w[0]&&state.to===w[1] ? "Cette fin de semaine"
1357 + : [state.from&&fmtDate(state.from), state.to&&fmtDate(state.to)].filter(Boolean).join(" → ")]);
1358 + }
1359 + if(state.region) pills.push(["region", state.region]);
1360 + if(state.category) pills.push(["category", CAT_LABELS[state.category]||state.category]);
1361 + if(state.free) pills.push(["free", "Gratuit"]);
1362 + if(state.near) pills.push(["near", "Près de moi"]);
1363 + $("afilters").innerHTML = pills.map(([k,label]) =>
1364 + `<button type="button" class="apill" data-clear="${k}">${esc(label)}<i>✕</i></button>`).join("");
1365 + $("afilters").querySelectorAll("[data-clear]").forEach(b => b.onclick = () => {
1366 + const k = b.dataset.clear;
1367 + if(k==="q"){ state.q=""; $("q").value=""; }
1368 + else if(k==="dates"){ state.from=""; state.to=""; }
1369 + else if(k==="region") state.region="";
1370 + else if(k==="category") state.category="";
1371 + else if(k==="free") state.free=false;
1372 + else if(k==="near") state.near="";
1373 + load();
1374 + });
1375 +}
854 1376
1377 +/* ---------- chargement : accueil éditorial OU résultats ---------- */
855 1378 async function load(reset=true){
856 − if(reset){ state.offset = 0; }
857 − const data = await (await fetch(`/api/events?${params()}`)).json();
1379 + const filtering = isFiltering();
1380 + $("home").classList.toggle("hidden", filtering);
1381 + $("results").classList.toggle("hidden", !filtering);
1382 + syncControls();
1383 + if(!filtering){ renderHome(); return; }
1384 + if(reset) state.offset = 0;
1385 + filterPills();
1386 + let data;
1387 + try{ data = await fetchEvents(params()); }
1388 + catch(e){ $("count").textContent = "Erreur de chargement — réessayez."; return; }
858 1389 state.total = data.total;
859 1390 const html = data.events.map(card).join("");
860 1391 if(reset){
@@ -863,23 +1394,254 @@ async function load(reset=true){
863 1394 } else {
864 1395 $("grid").insertAdjacentHTML("beforeend", html);
865 1396 }
866 − $("count").innerHTML = `<b>${data.total.toLocaleString("fr-CA")}</b>&nbsp;événement${data.total>1?"s":""} à venir`;
867 − $("more").hidden = state.offset + data.events.length >= data.total;
1397 + $("count").innerHTML = `<b>${data.total.toLocaleString("fr-CA")}</b>&nbsp;sortie${data.total>1?"s":""} trouvée${data.total>1?"s":""}`;
1398 + $("sh-apply").textContent = `Voir ${data.total.toLocaleString("fr-CA")} sortie${data.total>1?"s":""}`;
1399 + $("more").hidden = state.view === "map" || state.offset + data.events.length >= data.total;
868 1400 bindNav($("grid"));
1401 + favPaint();
1402 + if(state.view === "map") refreshMarkers();
1403 +}
1404 +let loadT;
1405 +function loadDebounced(){ clearTimeout(loadT); loadT = setTimeout(load, 220); }
1406 +
1407 +/* ---------- accès rapides + timeline de jours ---------- */
1408 +const QUICK_DEFS = [
1409 + ["cesoir","Ce soir"], ["weekend","Cette fin de semaine"], ["free","Gratuit"],
1410 +];
1411 +const PIN_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
1412 + stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1413 + <path d="M12 21s-7-5.5-7-11a7 7 0 0 1 14 0c0 5.5-7 11-7 11z"/><circle cx="12" cy="10" r="2.6"/></svg>`;
1414 +const SLIDERS_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
1415 + stroke-linecap="round" aria-hidden="true">
1416 + <path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2.2"/><circle cx="9" cy="17" r="2.2"/></svg>`;
1417 +function renderQuick(){
1418 + $("quick").innerHTML =
1419 + QUICK_DEFS.map(([k,l]) => `<button type="button" class="qchip" data-qk="${k}">${l}</button>`).join("")
1420 + + `<button type="button" class="qchip" data-qk="near">${PIN_SVG}Près de moi</button>`
1421 + + `<button type="button" class="qchip" id="btn-filters">${SLIDERS_SVG}Filtres
1422 + <span class="fb hidden" id="fcount"></span></button>`;
1423 + $("quick").querySelectorAll("[data-qk]").forEach(b => b.onclick = () => {
1424 + const k = b.dataset.qk;
1425 + if(k === "cesoir"){
1426 + const on = state.from === todayISO() && state.to === todayISO();
1427 + state.from = on ? "" : todayISO(); state.to = on ? "" : todayISO();
1428 + } else if(k === "weekend"){
1429 + const [a,z] = weekendRange();
1430 + const on = state.from === a && state.to === z;
1431 + state.from = on ? "" : a; state.to = on ? "" : z;
1432 + } else if(k === "free"){
1433 + state.free = !state.free;
1434 + } else if(k === "near"){
1435 + toggleNear(); return;
1436 + }
1437 + load();
1438 + });
1439 + $("btn-filters").onclick = openSheet;
1440 +}
1441 +function renderDays(){
1442 + const t = new Date();
1443 + let html = `<button type="button" class="day all" data-d=""><small>·Ka</small><b>Tout</b><i>&nbsp;</i></button>`;
1444 + for(let i=0; i<15; i++){
1445 + const d = new Date(t); d.setDate(t.getDate()+i);
1446 + const iso = isoLocal(d);
1447 + const lab = i===0 ? "auj" : i===1 ? "dem" : WDAYS[d.getDay()];
1448 + html += `<button type="button" class="day" data-d="${iso}">
1449 + <small>${lab}</small><b>${d.getDate()}</b><i>${MONTHS[d.getMonth()]}</i></button>`;
1450 + }
1451 + $("days").innerHTML = html;
1452 + $("days").querySelectorAll(".day").forEach(b => b.onclick = () => {
1453 + const d = b.dataset.d;
1454 + if(!d){ state.from = ""; state.to = ""; }
1455 + else if(state.from === d && state.to === d){ state.from = ""; state.to = ""; }
1456 + else { state.from = d; state.to = d; }
1457 + load();
1458 + });
1459 +}
1460 +/* reflète l'état dans les accès rapides, la timeline et le panneau */
1461 +function syncControls(){
1462 + const today = todayISO(), [wa,wz] = weekendRange();
1463 + document.querySelectorAll("[data-qk]").forEach(b => {
1464 + const k = b.dataset.qk;
1465 + b.classList.toggle("on",
1466 + k==="cesoir" ? (state.from===today && state.to===today)
1467 + : k==="weekend" ? (state.from===wa && state.to===wz)
1468 + : k==="free" ? state.free
1469 + : k==="near" ? !!state.near : false);
1470 + });
1471 + document.querySelectorAll(".day[data-d]").forEach(b =>
1472 + b.classList.toggle("on", b.dataset.d ? (state.from===b.dataset.d && state.to===b.dataset.d)
1473 + : !state.from && !state.to));
1474 + const n = ["q","region","category","from","near"].filter(k=>state[k]).length + (state.free?1:0);
1475 + const fc = $("fcount");
1476 + if(fc){ fc.textContent = n; fc.classList.toggle("hidden", !n); }
1477 + $("q-clear").classList.toggle("hidden", !state.q);
1478 + // panneau de filtres
1479 + if($("region").value !== state.region) $("region").value = state.region;
1480 + document.querySelectorAll("#cats .chip[data-cat]").forEach(x =>
1481 + x.classList.toggle("on", x.dataset.cat === state.category));
1482 + $("chip-free").classList.toggle("on", state.free);
1483 + $("sh-from").value = state.from; $("sh-to").value = state.to;
1484 + const whenKey = (!state.from && !state.to) ? "" :
1485 + (state.from===today && state.to===today) ? "today" :
1486 + (state.from===wa && state.to===wz) ? "weekend" :
1487 + (state.from===today && state.to===plusDays(7)) ? "7" :
1488 + (state.from===today && state.to===plusDays(30)) ? "30" : "custom";
1489 + document.querySelectorAll("#sh-when .chip").forEach(c =>
1490 + c.classList.toggle("on", c.dataset.w === whenKey));
1491 +}
1492 +function resetFilters(reload=true){
1493 + state.region=""; state.category=""; state.free=false;
1494 + state.from=""; state.to=""; state.near="";
1495 + if(reload) load();
1496 +}
1497 +
1498 +/* ---------- géolocalisation « près de moi » ---------- */
1499 +function toast(msg, ms=2600){
1500 + const t = $("toast");
1501 + t.textContent = msg;
1502 + t.classList.add("show");
1503 + clearTimeout(t._t);
1504 + t._t = setTimeout(()=>t.classList.remove("show"), ms);
1505 +}
1506 +function toggleNear(){
1507 + if(state.near){ state.near = ""; load(); return; }
1508 + if(!navigator.geolocation){ toast("Géolocalisation non disponible sur cet appareil"); return; }
1509 + toast("Recherche de votre position…");
1510 + navigator.geolocation.getCurrentPosition(
1511 + pos => {
1512 + state.near = `${pos.coords.latitude.toFixed(5)},${pos.coords.longitude.toFixed(5)}`;
1513 + load();
1514 + toast("Sorties à moins de 30 km de vous");
1515 + },
1516 + () => toast("Position indisponible — vérifiez les permissions de localisation"),
1517 + {timeout: 9000, maximumAge: 600000});
1518 +}
1519 +
1520 +/* ---------- panneau de filtres (bottom sheet / modale) ---------- */
1521 +const WHEN_DEFS = [["","À venir"],["today","Aujourd'hui"],["weekend","Fin de semaine"],
1522 + ["7","7 jours"],["30","30 jours"]];
1523 +function openSheet(){
1524 + $("sh-apply").textContent = isFiltering() && state.total
1525 + ? `Voir ${state.total.toLocaleString("fr-CA")} sorties` : "Voir les sorties";
1526 + $("sheet").classList.add("open");
1527 + $("sheet-ov").classList.add("open");
1528 + document.documentElement.classList.add("ka-scroll-lock");
1529 + syncControls();
1530 +}
1531 +function closeSheet(){
1532 + $("sheet").classList.remove("open");
1533 + $("sheet-ov").classList.remove("open");
1534 + document.documentElement.classList.remove("ka-scroll-lock");
1535 +}
1536 +function initSheet(){
1537 + $("sh-when").innerHTML = WHEN_DEFS.map(([v,l]) =>
1538 + `<button type="button" class="chip" data-w="${v}">${l}</button>`).join("");
1539 + $("sh-when").querySelectorAll(".chip").forEach(c => c.onclick = () => {
1540 + const v = c.dataset.w;
1541 + if(v === "today"){ state.from = todayISO(); state.to = todayISO(); }
1542 + else if(v === "weekend"){ const [a,z] = weekendRange(); state.from = a; state.to = z; }
1543 + else if(v === "7"){ state.from = todayISO(); state.to = plusDays(7); }
1544 + else if(v === "30"){ state.from = todayISO(); state.to = plusDays(30); }
1545 + else { state.from = ""; state.to = ""; }
1546 + loadDebounced(); syncControls();
1547 + });
1548 + const onDate = () => {
1549 + state.from = $("sh-from").value; state.to = $("sh-to").value;
1550 + loadDebounced(); syncControls();
1551 + };
1552 + $("sh-from").onchange = onDate; $("sh-to").onchange = onDate;
1553 + $("region").onchange = e => { state.region = e.target.value; loadDebounced(); };
1554 + $("chip-free").onclick = () => { state.free = !state.free; loadDebounced(); syncControls(); };
1555 + $("sh-reset").onclick = () => { resetFilters(); };
1556 + $("sh-apply").onclick = closeSheet;
1557 + $("sheet-x").onclick = closeSheet;
1558 + $("sheet-ov").onclick = closeSheet;
1559 + document.addEventListener("keydown", e => { if(e.key === "Escape") closeSheet(); });
869 1560 }
870 1561
871 −function setPeriod(v){
872 − const today = new Date(); const iso = d => d.toISOString().slice(0,10);
873 − state.from = ""; state.to = "";
874 − if(v==="today"){ state.from = iso(today); state.to = iso(today); }
875 − else if(v==="weekend"){
876 − const day = today.getDay();
877 − const sat = new Date(today);
878 − if(day !== 6 && day !== 0) sat.setDate(today.getDate() + (6 - day));
879 − const sun = new Date(sat); sun.setDate(sat.getDate() + (day === 0 ? 0 : 1));
880 − state.from = iso(day===6||day===0 ? today : sat); state.to = iso(sun);
1562 +/* ---------- vue carte (Leaflet, chargé à la demande) ---------- */
1563 +const MAPS = {inst:null, layer:null, loading:false};
1564 +function ensureLeaflet(){
1565 + if(window.L) return Promise.resolve();
1566 + return new Promise((res, rej) => {
1567 + const c = document.createElement("link");
1568 + c.rel = "stylesheet";
1569 + c.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
1570 + document.head.appendChild(c);
1571 + const s = document.createElement("script");
1572 + s.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
1573 + s.onload = res; s.onerror = rej;
1574 + document.body.appendChild(s);
1575 + });
1576 +}
1577 +function setView(v){
1578 + state.view = v;
1579 + $("v-list").classList.toggle("on", v === "list");
1580 + $("v-list").setAttribute("aria-pressed", v === "list");
1581 + $("v-map").classList.toggle("on", v === "map");
1582 + $("v-map").setAttribute("aria-pressed", v === "map");
1583 + $("grid").classList.toggle("hidden", v === "map");
1584 + $("mapwrap").classList.toggle("hidden", v !== "map");
1585 + $("more").hidden = v === "map" || state.offset + state.limit >= state.total;
1586 + if(v === "map") openMap();
1587 +}
1588 +async function openMap(){
1589 + try{ await ensureLeaflet(); }
1590 + catch(e){ $("map-load").textContent = "Carte indisponible — vérifiez la connexion."; return; }
1591 + if(!MAPS.inst){
1592 + MAPS.inst = L.map("map", {zoomControl:true}).setView([46.83,-71.25], 6);
1593 + L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
1594 + attribution:"© <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> · © <a href='https://carto.com/attributions'>CARTO</a>",
1595 + maxZoom: 19, subdomains:"abcd"}).addTo(MAPS.inst);
1596 + MAPS.layer = L.layerGroup().addTo(MAPS.inst);
1597 + MAPS.inst.on("popupopen", e => bindNav(e.popup.getElement()));
881 1598 }
882 − else if(v){ const end = new Date(today); end.setDate(today.getDate()+Number(v)); state.from = iso(today); state.to = iso(end); }
1599 + $("map-load").classList.add("hidden");
1600 + setTimeout(() => MAPS.inst.invalidateSize(), 80);
1601 + refreshMarkers();
1602 +}
1603 +function popupHTML(ev){
1604 + return `<a class="mpop" data-nav href="/evenement/${esc(ev.uid)}">
1605 + ${ev.image?`<img src="${esc(ev.image)}" alt="" loading="lazy" onerror="this.remove()">`:""}
1606 + <span class="mp-b"><span class="ev-date">${evDate(ev)}</span>
1607 + <h4>${esc(ev.title)}</h4>
1608 + <p>${[ev.venue, ev.city].filter(Boolean).map(esc).join(" · ")}${priceTxt(ev)?` — ${priceTxt(ev)}`:""}</p>
1609 + </span></a>`;
1610 +}
1611 +/* jusqu'à 600 événements géolocalisés pour les filtres courants */
1612 +async function refreshMarkers(){
1613 + if(!MAPS.inst || MAPS.loading) return;
1614 + MAPS.loading = true;
1615 + try{
1616 + const evs = [];
1617 + for(let page=0; page<3; page++){
1618 + const p = new URLSearchParams(params());
1619 + p.set("limit","200"); p.set("offset", String(page*200));
1620 + const data = await fetchEvents(p.toString());
1621 + evs.push(...data.events);
1622 + if((page+1)*200 >= data.total) break;
1623 + }
1624 + MAPS.layer.clearLayers();
1625 + const pts = [];
1626 + evs.filter(e => e.lat && e.lng).forEach(ev => {
1627 + pts.push([ev.lat, ev.lng]);
1628 + L.circleMarker([ev.lat, ev.lng], {radius:7, color:"#a12551", weight:1.5,
1629 + fillColor:"#d6336c", fillOpacity:.85})
1630 + .bindPopup(popupHTML(ev), {closeButton:false}).addTo(MAPS.layer);
1631 + });
1632 + if(state.near){
1633 + const [la,ln] = state.near.split(",").map(Number);
1634 + L.circleMarker([la,ln], {radius:9, color:"#141814", weight:2.5,
1635 + fillColor:"#1c5c41", fillOpacity:1}).bindTooltip("Vous êtes ici").addTo(MAPS.layer);
1636 + MAPS.inst.setView([la,ln], 11);
1637 + } else if(pts.length){
1638 + MAPS.inst.fitBounds(L.latLngBounds(pts).pad(0.12), {maxZoom: 13});
1639 + }
1640 + const note = $("map-note");
1641 + note.textContent = `${pts.length.toLocaleString("fr-CA")} sur ${state.total.toLocaleString("fr-CA")} sorties géolocalisées — affinez avec les filtres`;
1642 + note.classList.toggle("hidden", !pts.length);
1643 + }catch(e){}
1644 + MAPS.loading = false;
883 1645 }
884 1646
885 1647 /* ---------- fiche événement ---------- */
@@ -892,6 +1654,7 @@ async function showDetail(uid){
892 1654 document.title = `${ev.title} — ${ev.city || "Québec"} | Sorti·Ka`;
893 1655 $("d-img").innerHTML = ev.image
894 1656 ? `<img src="${esc(ev.image)}" alt="" onerror="this.remove()">` : "";
1657 + $("d-img").classList.toggle("noimg", !ev.image);
895 1658 $("d-cats-label").textContent = (ev.categories||[]).filter(c=>c!=="autre")
896 1659 .map(c=>CAT_LABELS[c]||c).join(" · ") || "Événement";
897 1660 $("d-status").innerHTML = ev.status && STATUS_LABELS[ev.status]
@@ -925,6 +1688,10 @@ async function showDetail(uid){
925 1688 `<div class="fact"><span class="k">${k}</span><span class="v">${v}</span></div>`).join("");
926 1689
927 1690 $("d-cta").href = ev.url;
1691 + $("d-ctabar").innerHTML = `<div class="p">
1692 + <b>${priceTxt(ev) || (ev.is_free === false ? "Payant — voir la billetterie" : "Voir la billetterie")}</b>
1693 + <span>${evDate(ev)}</span></div>
1694 + <a class="btn btn-cta" href="${esc(ev.url)}" target="_blank" rel="noopener">Billets ↗</a>`;
928 1695 $("d-src").innerHTML = `<span class="klabel">Source & attribution</span>
929 1696 <div style="margin-top:6px;font-size:13px">${SRC_LABELS[ev.source]||ev.source}</div>
930 1697 <div style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3);margin-top:4px">
@@ -1796,19 +2563,21 @@ document.addEventListener("click", e => {
1796 2563 function favCard(it){
1797 2564 FAV.meta[it.item_id] = it;
1798 2565 const img = it.image_url
1799 − ? `<img src="${esc(it.image_url)}" alt="" loading="lazy" onerror="this.remove()">` : "";
2566 + ? `<img src="${esc(it.image_url)}" alt="" loading="lazy"
2567 + onerror="this.parentElement.classList.add('noimg');this.remove()">` : "";
1800 2568 const local = (it.url || "").replace(/^https?:\/\/[^/]*sorti-ka\.com/i, "")
1801 2569 || `/evenement/${encodeURIComponent(it.item_id)}`;
1802 − return `<a class="card gk-card gk-hover" data-nav href="${esc(local)}">
1803 − <div class="card-img">${img}
2570 + return `<a class="ev" data-nav href="${esc(local)}">
2571 + <div class="ev-img${it.image_url?"":" noimg"}">${img}
2572 + ${it.price_label?`<span class="ev-tag${it.price_label==="Gratuit"?" free":""}">${esc(it.price_label)}</span>`:""}
1804 2573 <button type="button" class="fav-btn on" data-fav="${esc(it.item_id)}" aria-pressed="true"
1805 2574 aria-label="Retirer de mes favoris (Mon univers Ka)"
1806 2575 title="Retirer de mes favoris (Mon univers Ka)">${FAV_HEART}</button>
1807 2576 </div>
1808 − <div class="card-body">
1809 − <div class="card-top"><h3>${esc(it.title)}</h3>${it.price_label?`<span class="badge-price">${esc(it.price_label)}</span>`:""}</div>
1810 − ${it.subtitle?`<div class="where">${esc(it.subtitle)}</div>`:""}
1811 − <div class="src"><span>Mon univers Ka</span><span class="go">Fiche →</span></div>
2577 + <div class="ev-body">
2578 + <h3>${esc(it.title)}</h3>
2579 + ${it.subtitle?`<p class="ev-where">${esc(it.subtitle)}</p>`:""}
2580 + <p class="ev-meta"><span class="src">Mon univers Ka</span></p>
1812 2581 </div>
1813 2582 </a>`;
1814 2583 }
@@ -1874,20 +2643,18 @@ function route(){
1874 2643 else if(location.pathname === "/contact") showContact();
1875 2644 else if(location.pathname === "/favoris") showFavoris();
1876 2645 else if(location.pathname === "/stats") showStats();
1877 − else if(r){
1878 − // page SSR /region/{slug} : la SPA ouvre la recherche filtrée sur la région
1879 − const name = allRegions.find(x => kaSlug(x) === decodeURIComponent(r[1]));
1880 − if(name){ state.region = name; $("region").value = name; }
1881 − showList(); load();
1882 − }
1883 − else if(c){
1884 − // page SSR /categorie/{cat} : recherche filtrée sur la catégorie
1885 − const cat = decodeURIComponent(c[1]);
1886 − state.category = cat;
1887 − document.querySelectorAll(".chip[data-cat]").forEach(x=>x.classList.toggle("on", x.dataset.cat===cat));
1888 − showList(); load();
2646 + else {
2647 + if(r){
2648 + // page SSR /region/{slug} : la SPA ouvre la recherche filtrée sur la région
2649 + const name = allRegions.find(x => kaSlug(x) === decodeURIComponent(r[1]));
2650 + if(name) state.region = name;
2651 + } else if(c){
2652 + // page SSR /categorie/{cat} : recherche filtrée sur la catégorie
2653 + state.category = decodeURIComponent(c[1]);
2654 + }
2655 + showList();
2656 + load();
1889 2657 }
1890 − else showList();
1891 2658 }
1892 2659 function go(path){
1893 2660 history.pushState({}, "", path);
@@ -1922,38 +2689,53 @@ $("back").onclick = () => go("/");
1922 2689 /* ---------- init ---------- */
1923 2690 async function init(){
1924 2691 favLoad(); // favoris ♥ — repeint les cœurs dès la réponse du hub
1925 − const regions = await (await fetch("/api/regions")).json();
1926 − allRegions = regions;
1927 − $("region").insertAdjacentHTML("beforeend",
1928 − regions.map(r=>`<option>${esc(r)}</option>`).join(""));
1929 −
1930 − const cats = await (await fetch("/api/categories")).json();
1931 − $("cats").innerHTML =
1932 − `<span class="chip free" id="chip-free">✦ Gratuit</span>` +
1933 − cats.filter(c=>c!=="autre").map(c=>`<span class="chip" data-cat="${c}">${CAT_LABELS[c]||c}</span>`).join("");
1934 − $("chip-free").onclick = e => { state.free=!state.free; e.target.classList.toggle("on",state.free); load(); };
1935 − document.querySelectorAll(".chip[data-cat]").forEach(ch => ch.onclick = () => {
1936 − const c = ch.dataset.cat;
1937 − state.category = state.category===c ? "" : c;
1938 − document.querySelectorAll(".chip[data-cat]").forEach(x=>x.classList.toggle("on", x.dataset.cat===state.category));
1939 − load();
2692 + renderQuick();
2693 + renderDays();
2694 + initSheet();
2695 +
2696 + // recherche au centre
2697 + let t; $("q").addEventListener("input", e=>{
2698 + clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); load(); }, 280);
1940 2699 });
2700 + $("q-clear").onclick = () => { $("q").value=""; state.q=""; load(); };
2701 +
2702 + // bascule Liste | Carte + pagination
2703 + $("v-list").onclick = () => setView("list");
2704 + $("v-map").onclick = () => setView("map");
2705 + $("more").onclick = () => { state.offset += state.limit; load(false); };
2706 + bindNav(document);
2707 +
2708 + // référentiels (avant route() : /region/{slug} en a besoin)
2709 + try{
2710 + const regions = await (await fetch("/api/regions")).json();
2711 + allRegions = regions;
2712 + $("region").insertAdjacentHTML("beforeend",
2713 + regions.map(r=>`<option>${esc(r)}</option>`).join(""));
2714 + }catch(e){}
2715 + try{
2716 + const cats = await (await fetch("/api/categories")).json();
2717 + $("cats").innerHTML = cats.filter(c=>c!=="autre")
2718 + .map(c=>`<button type="button" class="chip" data-cat="${c}">${CAT_LABELS[c]||c}</button>`).join("");
2719 + document.querySelectorAll("#cats .chip[data-cat]").forEach(ch => ch.onclick = () => {
2720 + state.category = state.category===ch.dataset.cat ? "" : ch.dataset.cat;
2721 + loadDebounced(); syncControls();
2722 + });
2723 + }catch(e){}
1941 2724
2725 + // compteurs = preuve de richesse, intégrés au hero en phrase éditoriale
1942 2726 try{
1943 2727 const s = await (await fetch("/api/stats")).json();
1944 2728 const nReg = Object.keys(s.by_region).filter(r=>r!=="Non rattachée").length;
1945 − $("stats").innerHTML = [
1946 − `<span class="stat-chip"><span class="pulse"></span><b>${s.upcoming.toLocaleString("fr-CA")}</b> événements à venir</span>`,
1947 − `<span class="stat-chip"><b>${s.free_upcoming.toLocaleString("fr-CA")}</b> gratuits</span>`,
1948 − `<span class="stat-chip"><b>${nReg}</b> régions</span>`,
1949 − `<span class="stat-chip"><b>${s.cities.toLocaleString("fr-CA")}</b> villes</span>`,
1950 − ].join("");
2729 + const f = n => n.toLocaleString("fr-CA");
2730 + $("stats").innerHTML =
2731 + `<b>${f(s.upcoming)}</b> événements à venir <i>·</i> dans <b>${f(s.cities)}</b> villes `
2732 + + `<i>·</i> sur <b>${nReg}</b> régions <i>·</i> dont <b>${f(s.free_upcoming)}</b> gratuits`;
1951 2733 }catch(e){}
1952 2734
1953 2735 try{
1954 2736 const reg = await (await fetch("/api/sources")).json();
1955 2737 $("src-grid").innerHTML = (reg.sources||[]).filter(s=>s.statut==="actif"||s.active_events>0).map(s=>`
1956 − <div class="src-card gk-card">
2738 + <div class="src-card">
1957 2739 <h4>${esc(s.name)}</h4>
1958 2740 <div class="lic">${esc((s.acces_legal||"").split("—")[0])}</div>
1959 2741 <div class="n"><b>${(s.active_events||0).toLocaleString("fr-CA")}</b> événements actifs · ${esc((s.extraction||"").split("(")[0])}</div>
@@ -1978,22 +2760,13 @@ async function init(){
1978 2760 };
1979 2761 } else {
1980 2762 $("auth").innerHTML = `<a class="btn-ka" href="/api/auth/ka/login">
1981 − <span class="kdot">·Ka</span> Se connecter avec KA</a>
2763 + <span class="kdot">·Ka</span><span class="tx">Se connecter avec KA</span></a>
1982 2764 <a class="ka-signup" href="https://www.groupe-ka.com/connexion"
1983 2765 target="_blank" rel="noopener">Créer un compte</a>`;
1984 2766 }
1985 2767 }
1986 2768 }catch(e){}
1987 2769
1988 − let t; $("q").addEventListener("input", e=>{
1989 − clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); load(); }, 280);
1990 − });
1991 − $("region").onchange = e => { state.region = e.target.value; load(); };
1992 − $("period").onchange = e => { setPeriod(e.target.value); load(); };
1993 − $("more").onclick = () => { state.offset += state.limit; load(false); };
1994 − bindNav(document);
1995 −
1996 − await load();
1997 2770 route();
1998 2771 }
1999 2772 init();
modified sortika/web.py +23 −2
@@ -7,6 +7,7 @@
7 7 from __future__ import annotations
8 8
9 9 import json
10 +import math
10 11 from datetime import date
11 12 from pathlib import Path
12 13
@@ -60,7 +61,9 @@ def list_events(
60 61 date_from: str = Query("", alias="from", description="ISO — événements se terminant à partir de cette date"),
61 62 date_to: str = Query("", alias="to", description="ISO — événements commençant au plus tard à cette date"),
62 63 upcoming: bool = Query(True, description="masquer les événements passés"),
63 − sort: str = Query("date", description="date | recent"),
64 + sort: str = Query("date", description="date | recent | near"),
65 + near: str = Query("", description="lat,lng — filtre géographique (rayon radius_km)"),
66 + radius_km: float = Query(25, ge=1, le=300, description="rayon du filtre near en km"),
64 67 limit: int = Query(30, ge=1, le=200),
65 68 offset: int = Query(0, ge=0),
66 69 ) -> dict:
@@ -91,7 +94,25 @@ def list_events(
91 94 where.append("start_date <= ?")
92 95 params.append(date_to)
93 96
94 − if sort == "date" and date_from:
97 + # filtre géographique « près de moi » : boîte englobante autour du point
98 + # (1° lat ≈ 110,6 km ; 1° lng ≈ 111,3 km × cos(lat)) — assez précis au QC
99 + nlat = nlng = None
100 + if near:
101 + try:
102 + nlat, nlng = (float(x) for x in near.split(","))
103 + except ValueError:
104 + raise HTTPException(422, "near attend « lat,lng »")
105 + dlat = radius_km / 110.6
106 + dlng = radius_km / (111.3 * max(0.2, math.cos(math.radians(nlat))))
107 + where.append("lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?")
108 + params += [nlat - dlat, nlat + dlat, nlng - dlng, nlng + dlng]
109 +
110 + if sort == "near" and nlat is not None:
111 + # distance approchée (carrés de degrés, lng corrigé) — suffit pour trier
112 + coslat2 = math.cos(math.radians(nlat)) ** 2
113 + order = f"((lat-?)*(lat-?) + (lng-?)*(lng-?)*{coslat2:.6f}) ASC, start_date ASC"
114 + order_params = [nlat, nlat, nlng, nlng]
115 + elif sort == "date" and date_from:
95 116 # les événements déjà commencés mais toujours en cours (expositions,
96 117 # festivals longs) se classent à leur date effective, pas à leur début
97 118 order = "start_date IS NULL, MAX(start_date, ?) ASC, title ASC"
98 119