Upgrade majeur UI + options de recherche + réparation d'encodage
UI : thème clair style Lou·Ka (blanc/navy/orange, ombres douces), mode sombre navy avec toggle persistant, Fraunces en accents, suggestions instantanées navigables au clavier (/api/suggest), filtres fraîcheur + Éducation, panneau de filtres compact sur mobile, recherches liées, pagination numérotée, raccourci « / », badge recherche sémantique/IA, meter de couverture sémantique sur /status. Encodage : decode_html privilégie l'UTF-8 strict sur le charset HTTP menteur (10 000+ docs avaient « é » au lieu de « é »), fix_mojibake répare le double encodage (y compris contenu mixte, token par token), script de réparation du stock avec curseur search_after. API : /api/suggest (titres + requêtes populaires), related dans /api/search, embedding_coverage dans /api/status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
24 changed files +1,137 −172
modified
apps/api/main.py
+41 −1
@@ -87,6 +87,7 @@ BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education" | ||
| 87 | 87 | # et dégradent le top-10 au lieu de l'améliorer. |
| 88 | 88 | KNN_MIN_COVERAGE = 0.6 |
| 89 | 89 | _knn_ready_cache: dict = {"at": 0.0, "ready": False} |
| 90 | +_coverage_cache: dict = {"at": -120.0, "value": 0.0} | |
| 90 | 91 | |
| 91 | 92 | |
| 92 | 93 | async def _knn_ready() -> bool: |
@@ -106,6 +107,31 @@ async def health(): | ||
| 106 | 107 | return {"ok": True, "search_ok": await search.ping()} |
| 107 | 108 | |
| 108 | 109 | |
| 110 | +@app.get("/api/suggest") | |
| 111 | +async def api_suggest(q: str = Query(..., min_length=1, max_length=120)): | |
| 112 | + """Autocomplete : requêtes populaires réelles + complétions de titres indexés. | |
| 113 | + | |
| 114 | + Best-effort et rapide : chaque source est optionnelle, l'échec de l'une | |
| 115 | + n'empêche pas l'autre (§13).""" | |
| 116 | + prefix = re.sub(r"\s+", " ", q).strip()[:100] | |
| 117 | + if len(prefix) < 2: | |
| 118 | + return {"suggestions": []} | |
| 119 | + popular_task = asyncio.create_task(db.suggest_queries(prefix, limit=4)) | |
| 120 | + titles_task = asyncio.create_task(search.suggest_titles(prefix, limit=6)) | |
| 121 | + suggestions: list[str] = [] | |
| 122 | + seen: set[str] = set() | |
| 123 | + for task in (popular_task, titles_task): | |
| 124 | + try: | |
| 125 | + for s in await asyncio.wait_for(task, timeout=0.4): | |
| 126 | + key = s.lower() | |
| 127 | + if key != prefix.lower() and key not in seen: | |
| 128 | + seen.add(key) | |
| 129 | + suggestions.append(s) | |
| 130 | + except Exception: | |
| 131 | + continue | |
| 132 | + return {"suggestions": suggestions[:8]} | |
| 133 | + | |
| 134 | + | |
| 109 | 135 | @app.get("/api/search") |
| 110 | 136 | async def api_search( |
| 111 | 137 | q: str = Query(..., min_length=1, max_length=200), |
@@ -222,8 +248,17 @@ async def api_search( | ||
| 222 | 248 | except Exception: |
| 223 | 249 | log.exception("analytics de recherche sautées") |
| 224 | 250 | |
| 251 | + # Recherches liées (page 1 seulement) — best-effort, jamais bloquant | |
| 252 | + related: list[str] = [] | |
| 253 | + if page == 1: | |
| 254 | + try: | |
| 255 | + terms = re.findall(r"[\w'-]{3,}", q.lower(), re.UNICODE) | |
| 256 | + related = await asyncio.wait_for(db.related_queries(terms, exclude=q), timeout=0.3) | |
| 257 | + except Exception: | |
| 258 | + related = [] | |
| 259 | + | |
| 225 | 260 | return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, |
| 226 | − "semantic": semantic, "reranked": reranked, "results": results} | |
| 261 | + "semantic": semantic, "reranked": reranked, "related": related, "results": results} | |
| 227 | 262 | |
| 228 | 263 | |
| 229 | 264 | @app.get("/api/status") |
@@ -238,6 +273,10 @@ async def api_status(): | ||
| 238 | 273 | paused = await coord.is_paused() |
| 239 | 274 | except Exception: |
| 240 | 275 | paused = False |
| 276 | + # Couverture sémantique (part de l'index avec embedding) — cache 60 s | |
| 277 | + now = time.monotonic() | |
| 278 | + if now - _coverage_cache["at"] > 60: | |
| 279 | + _coverage_cache.update(at=now, value=await search.embedding_coverage()) | |
| 241 | 280 | return { |
| 242 | 281 | "pages_indexed": snapshot.get("pages_indexed", 0), |
| 243 | 282 | "domains_count": snapshot.get("domains_count", 0), |
@@ -248,6 +287,7 @@ async def api_status(): | ||
| 248 | 287 | "frontier_in_progress": snapshot.get("frontier_in_progress", 0), |
| 249 | 288 | "crawler_state": "paused" if paused else "running", |
| 250 | 289 | "search_ok": search_ok, |
| 290 | + "embedding_coverage": round(_coverage_cache["value"], 4), | |
| 251 | 291 | } |
| 252 | 292 | |
| 253 | 293 | |
modified
apps/web/app/globals.css
+232 −35
@@ -13,34 +13,85 @@ | ||
| 13 | 13 | @tailwind utilities; |
| 14 | 14 | |
| 15 | 15 | :root { |
| 16 | − --paper: #f5f3ee; | |
| 16 | + /* Thème clair « Lou·Ka » : blanc, encre navy, accent orange, liens bleus, | |
| 17 | + coins doux et ombres diffuses (fini les ombres décalées en clair). */ | |
| 18 | + --paper: #ffffff; | |
| 17 | 19 | --surface: #ffffff; |
| 18 | − --surface-2: #faf9f5; | |
| 19 | − --ink: #141814; | |
| 20 | − --ink-2: #4d5551; | |
| 21 | − --ink-3: #8b928c; | |
| 22 | − --line: rgba(20, 24, 20, 0.14); | |
| 23 | − --line-strong: rgba(20, 24, 20, 0.85); | |
| 24 | − --green: #1c5c41; | |
| 25 | − --green-deep: #123f2e; | |
| 26 | − --lime: #b9cfee; | |
| 27 | − --lime-soft: #e6eef9; | |
| 28 | − --amber: #e8a33d; | |
| 29 | − --amber-soft: #fdf3e2; | |
| 30 | − --danger: #b3423a; | |
| 31 | − --danger-soft: #fbe9e7; | |
| 32 | − | |
| 33 | − --r-card: 10px; | |
| 34 | − --r-ctl: 6px; | |
| 35 | − --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05); | |
| 36 | − --shadow-off: 6px 6px 0 var(--ink); | |
| 37 | − --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08); | |
| 20 | + --surface-2: #f4f5f8; | |
| 21 | + --ink: #0b1330; | |
| 22 | + --ink-2: #3e4560; | |
| 23 | + --ink-3: #8a90a5; | |
| 24 | + --line: rgba(11, 19, 48, 0.1); | |
| 25 | + --line-strong: rgba(11, 19, 48, 0.16); | |
| 26 | + --edge: rgba(11, 19, 48, 0.14); | |
| 27 | + --green: #2258ff; | |
| 28 | + --green-deep: #0b1330; | |
| 29 | + --lime: #ff6a00; | |
| 30 | + --lime-soft: #fff1e6; | |
| 31 | + --blue-soft: #e9efff; | |
| 32 | + --amber: #ff6a00; | |
| 33 | + --amber-soft: #fff1e6; | |
| 34 | + --danger: #d64545; | |
| 35 | + --danger-soft: #fdecec; | |
| 36 | + | |
| 37 | + --paper-glass: rgba(255, 255, 255, 0.88); | |
| 38 | + | |
| 39 | + --r-card: 16px; | |
| 40 | + --r-ctl: 10px; | |
| 41 | + --shadow-flat: 0 1px 3px rgba(11, 19, 48, 0.06); | |
| 42 | + --shadow-off: 0 16px 40px rgba(11, 19, 48, 0.14); | |
| 43 | + --shadow-off-soft: 0 8px 24px rgba(11, 19, 48, 0.08); | |
| 44 | + --shadow-btn: 0 4px 14px rgba(11, 19, 48, 0.18); | |
| 45 | + --shadow-field: 0 2px 10px rgba(11, 19, 48, 0.06); | |
| 46 | + | |
| 47 | + --btn-primary-bg: var(--ink); | |
| 48 | + --btn-primary-fg: #ffffff; | |
| 49 | + --btn-primary-hover: #22315e; | |
| 38 | 50 | |
| 39 | 51 | --font-display: var(--font-space-grotesk), system-ui, sans-serif; |
| 52 | + --font-serif: var(--font-fraunces), Georgia, serif; | |
| 40 | 53 | --font-body: var(--font-inter), system-ui, sans-serif; |
| 41 | 54 | --font-mono: var(--font-jetbrains), ui-monospace, monospace; |
| 42 | 55 | } |
| 43 | 56 | |
| 57 | +/* ---------- Mode sombre — navy profond, mêmes accents ---------- */ | |
| 58 | +[data-theme="dark"] { | |
| 59 | + --paper: #0a0f22; | |
| 60 | + --surface: #121834; | |
| 61 | + --surface-2: #0e142c; | |
| 62 | + --ink: #edf0f7; | |
| 63 | + --ink-2: #b3bacf; | |
| 64 | + --ink-3: #7b83a0; | |
| 65 | + --line: rgba(237, 240, 247, 0.12); | |
| 66 | + --line-strong: rgba(237, 240, 247, 0.24); | |
| 67 | + --edge: rgba(237, 240, 247, 0.16); | |
| 68 | + --green: #7da2ff; | |
| 69 | + --green-deep: #0a0f22; | |
| 70 | + --lime: #ff7d1f; | |
| 71 | + --lime-soft: #3a2408; | |
| 72 | + --blue-soft: #16224a; | |
| 73 | + --amber: #ff7d1f; | |
| 74 | + --amber-soft: #3a2408; | |
| 75 | + --danger: #e57373; | |
| 76 | + --danger-soft: #3a1414; | |
| 77 | + | |
| 78 | + --paper-glass: rgba(10, 15, 34, 0.86); | |
| 79 | + | |
| 80 | + --shadow-flat: 0 1px 3px rgba(0, 0, 0, 0.45); | |
| 81 | + --shadow-off: 0 16px 40px rgba(0, 0, 0, 0.5); | |
| 82 | + --shadow-off-soft: 0 8px 24px rgba(0, 0, 0, 0.4); | |
| 83 | + --shadow-btn: 0 4px 14px rgba(0, 0, 0, 0.5); | |
| 84 | + --shadow-field: 0 2px 10px rgba(0, 0, 0, 0.4); | |
| 85 | + | |
| 86 | + /* Bouton primaire inversé : accent orange, texte sombre */ | |
| 87 | + --btn-primary-bg: var(--lime); | |
| 88 | + --btn-primary-fg: #0a0f22; | |
| 89 | + --btn-primary-hover: #ff9346; | |
| 90 | +} | |
| 91 | +[data-theme="dark"] body::before { | |
| 92 | + opacity: 0.22; | |
| 93 | +} | |
| 94 | + | |
| 44 | 95 | @layer base { |
| 45 | 96 | html { |
| 46 | 97 | -webkit-text-size-adjust: 100%; |
@@ -144,7 +195,7 @@ | ||
| 144 | 195 | |
| 145 | 196 | .gk-card { |
| 146 | 197 | background: var(--surface); |
| 147 | − border: 1.5px solid var(--ink); | |
| 198 | + border: 1px solid var(--edge); | |
| 148 | 199 | border-radius: var(--r-card); |
| 149 | 200 | box-shadow: var(--shadow-off-soft); |
| 150 | 201 | } |
@@ -167,7 +218,7 @@ | ||
| 167 | 218 | gap: 8px; |
| 168 | 219 | min-height: 44px; |
| 169 | 220 | padding: 10px 20px; |
| 170 | − border: 1.5px solid var(--ink); | |
| 221 | + border: 1px solid var(--edge); | |
| 171 | 222 | border-radius: var(--r-ctl); |
| 172 | 223 | font-family: var(--font-display); |
| 173 | 224 | font-size: 14px; |
@@ -184,12 +235,144 @@ | ||
| 184 | 235 | box-shadow: none !important; |
| 185 | 236 | } |
| 186 | 237 | .btn-primary { |
| 187 | − background: var(--ink); | |
| 188 | − color: var(--lime); | |
| 189 | − box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.25); | |
| 238 | + background: var(--btn-primary-bg); | |
| 239 | + color: var(--btn-primary-fg); | |
| 240 | + box-shadow: var(--shadow-btn); | |
| 190 | 241 | } |
| 191 | 242 | .btn-primary:hover { |
| 192 | − background: var(--green-deep); | |
| 243 | + background: var(--btn-primary-hover); | |
| 244 | + } | |
| 245 | + | |
| 246 | + /* Bouton « Chercher » incrusté dans le champ */ | |
| 247 | + .btn-search { | |
| 248 | + font-family: var(--font-display); | |
| 249 | + font-weight: 700; | |
| 250 | + border: 1px solid var(--edge); | |
| 251 | + border-radius: 5px; | |
| 252 | + background: var(--btn-primary-bg); | |
| 253 | + color: var(--btn-primary-fg); | |
| 254 | + transition: background 0.15s ease; | |
| 255 | + } | |
| 256 | + .btn-search:hover { | |
| 257 | + background: var(--btn-primary-hover); | |
| 258 | + } | |
| 259 | + | |
| 260 | + /* ---------- Pastilles de filtre (thème-aware) ---------- */ | |
| 261 | + .pill { | |
| 262 | + font-family: var(--font-mono); | |
| 263 | + white-space: nowrap; | |
| 264 | + border: 1px solid var(--edge); | |
| 265 | + border-radius: 999px; | |
| 266 | + padding: 6px 14px; | |
| 267 | + font-size: 11px; | |
| 268 | + font-weight: 700; | |
| 269 | + text-transform: uppercase; | |
| 270 | + letter-spacing: 0.08em; | |
| 271 | + background: var(--surface); | |
| 272 | + color: var(--ink-2); | |
| 273 | + box-shadow: var(--shadow-flat); | |
| 274 | + transition: | |
| 275 | + background 0.15s ease, | |
| 276 | + color 0.15s ease, | |
| 277 | + transform 0.15s ease, | |
| 278 | + box-shadow 0.15s ease; | |
| 279 | + } | |
| 280 | + .pill:hover { | |
| 281 | + background: var(--lime-soft); | |
| 282 | + color: var(--ink); | |
| 283 | + transform: translateY(-1px); | |
| 284 | + box-shadow: var(--shadow-field); | |
| 285 | + } | |
| 286 | + .pill:active { | |
| 287 | + transform: translateY(0); | |
| 288 | + box-shadow: none; | |
| 289 | + } | |
| 290 | + .pill-active { | |
| 291 | + background: var(--btn-primary-bg); | |
| 292 | + color: var(--btn-primary-fg); | |
| 293 | + } | |
| 294 | + .pill-active:hover { | |
| 295 | + background: var(--btn-primary-hover); | |
| 296 | + color: var(--btn-primary-fg); | |
| 297 | + } | |
| 298 | + | |
| 299 | + /* ---------- Suggestions (combobox) ---------- */ | |
| 300 | + .suggest-panel { | |
| 301 | + position: absolute; | |
| 302 | + left: 0; | |
| 303 | + right: 0; | |
| 304 | + top: calc(100% + 8px); | |
| 305 | + z-index: 50; | |
| 306 | + overflow: hidden; | |
| 307 | + background: var(--surface); | |
| 308 | + border: 1px solid var(--edge); | |
| 309 | + border-radius: var(--r-card); | |
| 310 | + box-shadow: var(--shadow-off-soft); | |
| 311 | + animation: rise 0.18s cubic-bezier(0.2, 0.8, 0.2, 1) both; | |
| 312 | + } | |
| 313 | + .suggest-item { | |
| 314 | + display: flex; | |
| 315 | + align-items: center; | |
| 316 | + gap: 10px; | |
| 317 | + width: 100%; | |
| 318 | + padding: 10px 16px; | |
| 319 | + font-size: 14px; | |
| 320 | + text-align: left; | |
| 321 | + color: var(--ink-2); | |
| 322 | + cursor: pointer; | |
| 323 | + } | |
| 324 | + .suggest-item[aria-selected="true"], | |
| 325 | + .suggest-item:hover { | |
| 326 | + background: var(--lime-soft); | |
| 327 | + color: var(--ink); | |
| 328 | + } | |
| 329 | + .suggest-item mark { | |
| 330 | + background: none; | |
| 331 | + color: var(--ink); | |
| 332 | + font-weight: 700; | |
| 333 | + } | |
| 334 | + | |
| 335 | + /* ---------- Divers ---------- */ | |
| 336 | + .kbd { | |
| 337 | + display: inline-block; | |
| 338 | + min-width: 20px; | |
| 339 | + padding: 1px 6px; | |
| 340 | + border: 1px solid var(--edge); | |
| 341 | + border-bottom-width: 3px; | |
| 342 | + border-radius: 5px; | |
| 343 | + font-family: var(--font-mono); | |
| 344 | + font-size: 11px; | |
| 345 | + font-weight: 700; | |
| 346 | + text-align: center; | |
| 347 | + color: var(--ink-2); | |
| 348 | + background: var(--surface); | |
| 349 | + } | |
| 350 | + .serif-accent { | |
| 351 | + font-family: var(--font-serif); | |
| 352 | + font-style: italic; | |
| 353 | + font-weight: 500; | |
| 354 | + letter-spacing: -0.01em; | |
| 355 | + } | |
| 356 | + .chip-link { | |
| 357 | + display: inline-flex; | |
| 358 | + align-items: center; | |
| 359 | + gap: 7px; | |
| 360 | + border: 1px solid var(--edge); | |
| 361 | + border-radius: 999px; | |
| 362 | + padding: 7px 15px; | |
| 363 | + font-size: 13px; | |
| 364 | + font-weight: 550; | |
| 365 | + color: var(--ink-2); | |
| 366 | + background: var(--surface); | |
| 367 | + transition: | |
| 368 | + background 0.15s ease, | |
| 369 | + color 0.15s ease, | |
| 370 | + transform 0.15s ease; | |
| 371 | + } | |
| 372 | + .chip-link:hover { | |
| 373 | + background: var(--lime-soft); | |
| 374 | + color: var(--ink); | |
| 375 | + transform: translateY(-1px); | |
| 193 | 376 | } |
| 194 | 377 | .btn-ghost { |
| 195 | 378 | background: transparent; |
@@ -201,7 +384,7 @@ | ||
| 201 | 384 | .btn-lime { |
| 202 | 385 | background: var(--lime); |
| 203 | 386 | color: var(--ink); |
| 204 | − box-shadow: 4px 4px 0 rgba(185, 207, 238, 0.25); | |
| 387 | + box-shadow: var(--shadow-btn); | |
| 205 | 388 | } |
| 206 | 389 | .btn-lime:hover { |
| 207 | 390 | background: var(--lime-soft); |
@@ -214,12 +397,12 @@ | ||
| 214 | 397 | width: 100%; |
| 215 | 398 | padding: 11px 14px; |
| 216 | 399 | background: var(--surface); |
| 217 | − border: 1.5px solid var(--ink); | |
| 400 | + border: 1px solid var(--edge); | |
| 218 | 401 | border-radius: var(--r-ctl); |
| 219 | 402 | font-family: var(--font-body); |
| 220 | 403 | font-size: 14px; |
| 221 | 404 | color: var(--ink); |
| 222 | − box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.08); | |
| 405 | + box-shadow: var(--shadow-field); | |
| 223 | 406 | transition: box-shadow 0.15s ease; |
| 224 | 407 | } |
| 225 | 408 | .field::placeholder { |
@@ -228,7 +411,7 @@ | ||
| 228 | 411 | .field:focus { |
| 229 | 412 | outline: 2px solid var(--green); |
| 230 | 413 | outline-offset: 1px; |
| 231 | − box-shadow: 3px 3px 0 rgba(28, 92, 65, 0.25); | |
| 414 | + box-shadow: 0 0 0 3px var(--blue-soft); | |
| 232 | 415 | } |
| 233 | 416 | |
| 234 | 417 | /* ---------- Pilules de stats ---------- */ |
@@ -240,12 +423,12 @@ | ||
| 240 | 423 | white-space: nowrap; |
| 241 | 424 | padding: 8px 14px; |
| 242 | 425 | background: var(--surface); |
| 243 | − border: 1.5px solid var(--ink); | |
| 426 | + border: 1px solid var(--edge); | |
| 244 | 427 | border-radius: 999px; |
| 245 | 428 | font-family: var(--font-mono); |
| 246 | 429 | font-size: 12px; |
| 247 | 430 | font-weight: 700; |
| 248 | − box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); | |
| 431 | + box-shadow: var(--shadow-flat); | |
| 249 | 432 | } |
| 250 | 433 | |
| 251 | 434 | .pulse-dot { |
@@ -268,7 +451,7 @@ | ||
| 268 | 451 | /* ---------- Tableau éditorial ---------- */ |
| 269 | 452 | |
| 270 | 453 | .src-wrap { |
| 271 | − border: 1.5px solid var(--ink); | |
| 454 | + border: 1px solid var(--edge); | |
| 272 | 455 | border-radius: var(--r-card); |
| 273 | 456 | box-shadow: var(--shadow-off-soft); |
| 274 | 457 | overflow-x: auto; |
@@ -289,7 +472,7 @@ | ||
| 289 | 472 | color: var(--ink-3); |
| 290 | 473 | background: var(--surface-2); |
| 291 | 474 | padding: 10px 14px; |
| 292 | − border-bottom: 1.5px solid var(--ink); | |
| 475 | + border-bottom: 1px solid var(--line-strong); | |
| 293 | 476 | } |
| 294 | 477 | .src-table td { |
| 295 | 478 | padding: 12px 14px; |
@@ -354,3 +537,17 @@ | ||
| 354 | 537 | scroll-behavior: auto; |
| 355 | 538 | } |
| 356 | 539 | } |
| 540 | + | |
| 541 | +/* Entrée en cascade des résultats (respecte prefers-reduced-motion) */ | |
| 542 | +@media (prefers-reduced-motion: no-preference) { | |
| 543 | + .stagger > * { | |
| 544 | + opacity: 0; | |
| 545 | + animation: rise 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) forwards; | |
| 546 | + } | |
| 547 | + .stagger > *:nth-child(1) { animation-delay: 0.02s; } | |
| 548 | + .stagger > *:nth-child(2) { animation-delay: 0.06s; } | |
| 549 | + .stagger > *:nth-child(3) { animation-delay: 0.1s; } | |
| 550 | + .stagger > *:nth-child(4) { animation-delay: 0.14s; } | |
| 551 | + .stagger > *:nth-child(5) { animation-delay: 0.18s; } | |
| 552 | + .stagger > *:nth-child(n + 6) { animation-delay: 0.22s; } | |
| 553 | +} | |
modified
apps/web/app/layout.tsx
+24 −11
@@ -5,10 +5,11 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { Metadata, Viewport } from "next"; |
| 8 | −import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google"; | |
| 8 | +import { Fraunces, Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google"; | |
| 9 | 9 | import Link from "next/link"; |
| 10 | 10 | import { Wordmark } from "@/components/wordmark"; |
| 11 | 11 | import { LiveCrawl } from "@/components/live-crawl"; |
| 12 | +import { ThemeToggle } from "@/components/theme-toggle"; | |
| 12 | 13 | import "./globals.css"; |
| 13 | 14 | |
| 14 | 15 | const spaceGrotesk = Space_Grotesk({ |
@@ -20,6 +21,14 @@ const jetbrains = JetBrains_Mono({ | ||
| 20 | 21 | subsets: ["latin"], |
| 21 | 22 | variable: "--font-jetbrains", |
| 22 | 23 | }); |
| 24 | +const fraunces = Fraunces({ | |
| 25 | + subsets: ["latin"], | |
| 26 | + style: ["italic", "normal"], | |
| 27 | + variable: "--font-fraunces", | |
| 28 | +}); | |
| 29 | + | |
| 30 | +/** Applique le thème avant le premier rendu (aucun flash clair/sombre). */ | |
| 31 | +const THEME_INIT = `(function(){try{var t=localStorage.getItem("tk-theme");if(!t)t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=t;}catch(e){}})();`; | |
| 23 | 32 | |
| 24 | 33 | export const metadata: Metadata = { |
| 25 | 34 | metadataBase: new URL(process.env.PUBLIC_URL ?? "https://www.trouve-ka.com"), |
@@ -32,7 +41,7 @@ export const metadata: Metadata = { | ||
| 32 | 41 | }; |
| 33 | 42 | |
| 34 | 43 | export const viewport: Viewport = { |
| 35 | − themeColor: "#141814", | |
| 44 | + themeColor: "#0b1330", | |
| 36 | 45 | width: "device-width", |
| 37 | 46 | initialScale: 1, |
| 38 | 47 | }; |
@@ -56,10 +65,11 @@ export default function RootLayout({ | ||
| 56 | 65 | children, |
| 57 | 66 | }: Readonly<{ children: React.ReactNode }>) { |
| 58 | 67 | return ( |
| 59 | − <html lang="fr"> | |
| 68 | + <html lang="fr" suppressHydrationWarning> | |
| 60 | 69 | <body |
| 61 | − className={`${spaceGrotesk.variable} ${inter.variable} ${jetbrains.variable} flex min-h-screen flex-col bg-paper font-body text-ink antialiased`} | |
| 70 | + className={`${spaceGrotesk.variable} ${inter.variable} ${jetbrains.variable} ${fraunces.variable} flex min-h-screen flex-col bg-paper font-body text-ink antialiased`} | |
| 62 | 71 | > |
| 72 | + <script dangerouslySetInnerHTML={{ __html: THEME_INIT }} /> | |
| 63 | 73 | <a |
| 64 | 74 | href="#contenu" |
| 65 | 75 | className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-ctl focus:border-[1.5px] focus:border-ink focus:bg-ink focus:px-4 focus:py-2 focus:font-display focus:text-sm focus:font-bold focus:text-lime" |
@@ -67,7 +77,7 @@ export default function RootLayout({ | ||
| 67 | 77 | Aller au contenu principal |
| 68 | 78 | </a> |
| 69 | 79 | |
| 70 | − <header className="sticky top-0 z-40 border-b-2 border-ink bg-paper md:bg-[rgba(245,243,238,0.9)] md:backdrop-blur-xl"> | |
| 80 | + <header className="sticky top-0 z-40 border-b border-line bg-paper md:bg-[var(--paper-glass)] md:backdrop-blur-xl"> | |
| 71 | 81 | <div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6"> |
| 72 | 82 | <Link href="/" className="group rounded-sm" aria-label="Trouve-KA — accueil"> |
| 73 | 83 | <Wordmark /> |
@@ -90,6 +100,9 @@ export default function RootLayout({ | ||
| 90 | 100 | Soumettre un site |
| 91 | 101 | </Link> |
| 92 | 102 | </li> |
| 103 | + <li> | |
| 104 | + <ThemeToggle /> | |
| 105 | + </li> | |
| 93 | 106 | </ul> |
| 94 | 107 | </nav> |
| 95 | 108 | </div> |
@@ -97,11 +110,11 @@ export default function RootLayout({ | ||
| 97 | 110 | |
| 98 | 111 | <div className="flex flex-1 flex-col">{children}</div> |
| 99 | 112 | |
| 100 | − <footer className="border-t-2 border-ink bg-ink py-9 text-[13px] text-[rgba(245,243,238,0.75)]"> | |
| 113 | + <footer className="border-t border-[#0b1330] bg-[#0b1330] py-9 text-[13px] text-[rgba(237,240,247,0.75)]"> | |
| 101 | 114 | <div className="mx-auto max-w-6xl px-4 sm:px-6"> |
| 102 | 115 | <div className="flex flex-wrap items-baseline justify-between gap-x-8 gap-y-3"> |
| 103 | 116 | <Wordmark variant="footer" /> |
| 104 | − <p className="gk-display text-[15px] font-bold text-[rgba(245,243,238,0.85)]"> | |
| 117 | + <p className="gk-display text-[15px] font-bold text-[rgba(237,240,247,0.85)]"> | |
| 105 | 118 | Un moteur de recherche par{" "} |
| 106 | 119 | <a |
| 107 | 120 | href="https://www.groupe-ka.com" |
@@ -109,7 +122,7 @@ export default function RootLayout({ | ||
| 109 | 122 | rel="noopener noreferrer" |
| 110 | 123 | className="whitespace-nowrap underline-offset-4 hover:underline hover:decoration-lime" |
| 111 | 124 | > |
| 112 | − <span className="text-paper">Groupe</span>{" "} | |
| 125 | + <span className="text-white">Groupe</span>{" "} | |
| 113 | 126 | <span className="text-lime">KA</span> |
| 114 | 127 | </a> |
| 115 | 128 | </p> |
@@ -124,7 +137,7 @@ export default function RootLayout({ | ||
| 124 | 137 | <li key={link.href}> |
| 125 | 138 | <Link |
| 126 | 139 | href={link.href} |
| 127 | − className="rounded-sm text-[rgba(245,243,238,0.75)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime" | |
| 140 | + className="rounded-sm text-[rgba(237,240,247,0.75)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime" | |
| 128 | 141 | > |
| 129 | 142 | {link.label} |
| 130 | 143 | </Link> |
@@ -139,14 +152,14 @@ export default function RootLayout({ | ||
| 139 | 152 | href={href} |
| 140 | 153 | target="_blank" |
| 141 | 154 | rel="noopener noreferrer" |
| 142 | − className="text-[rgba(245,243,238,0.6)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime" | |
| 155 | + className="text-[rgba(237,240,247,0.6)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime" | |
| 143 | 156 | > |
| 144 | 157 | {label} |
| 145 | 158 | </a> |
| 146 | 159 | </li> |
| 147 | 160 | ))} |
| 148 | 161 | </ul> |
| 149 | − <p className="gk-mono mt-7 border-t border-[rgba(245,243,238,0.15)] pt-5 text-[11px] text-[rgba(245,243,238,0.45)]"> | |
| 162 | + <p className="gk-mono mt-7 border-t border-[rgba(237,240,247,0.15)] pt-5 text-[11px] text-[rgba(237,240,247,0.45)]"> | |
| 150 | 163 | © 2026 Trouve-KA — Simon-Pierre Boucher ·{" "} |
| 151 | 164 | <a |
| 152 | 165 | href="mailto:contact@spboucher.ai" |
modified
apps/web/app/page.tsx
+27 −3
@@ -12,13 +12,28 @@ export default function HomePage() { | ||
| 12 | 12 | return ( |
| 13 | 13 | <main |
| 14 | 14 | id="contenu" |
| 15 | − className="flex flex-1 flex-col items-center justify-center px-4 py-14" | |
| 15 | + className="relative flex flex-1 flex-col items-center justify-center overflow-hidden px-4 py-14" | |
| 16 | 16 | > |
| 17 | − <div className="rise w-full max-w-xl text-center"> | |
| 17 | + {/* Halo éditorial discret derrière le héros */} | |
| 18 | + <div | |
| 19 | + aria-hidden="true" | |
| 20 | + className="pointer-events-none absolute left-1/2 top-1/2 h-[540px] w-[540px] -translate-x-1/2 -translate-y-[58%] rounded-full opacity-60 blur-3xl" | |
| 21 | + style={{ | |
| 22 | + background: | |
| 23 | + "radial-gradient(closest-side, var(--lime-soft), transparent 72%)", | |
| 24 | + }} | |
| 25 | + /> | |
| 26 | + | |
| 27 | + <div className="rise relative w-full max-w-xl text-center"> | |
| 18 | 28 | <h1> |
| 19 | 29 | <Wordmark variant="hero" /> |
| 20 | 30 | </h1> |
| 21 | − <div className="mt-10"> | |
| 31 | + <p className="serif-accent mt-4 text-[19px] text-ink-2 sm:text-[21px]"> | |
| 32 | + Le moteur de recherche <span className="text-ink">indépendant</span> du | |
| 33 | + web québécois. | |
| 34 | + </p> | |
| 35 | + | |
| 36 | + <div className="mt-9"> | |
| 22 | 37 | <SearchBar |
| 23 | 38 | size="lg" |
| 24 | 39 | autoFocus |
@@ -29,15 +44,24 @@ export default function HomePage() { | ||
| 29 | 44 | "cégep Sherbrooke", |
| 30 | 45 | "marché public Trois-Rivières", |
| 31 | 46 | "programme Hydro-Québec", |
| 47 | + "cabane à sucre Lanaudière", | |
| 32 | 48 | ]} |
| 33 | 49 | /> |
| 34 | 50 | </div> |
| 51 | + | |
| 35 | 52 | <p className="gk-display mt-9 text-[17px] font-bold tracking-[-0.02em] text-ink"> |
| 36 | 53 | Cherche <span className="hl">le Québec</span>. |
| 37 | 54 | </p> |
| 55 | + | |
| 38 | 56 | <div className="mt-9"> |
| 39 | 57 | <IndexCounter /> |
| 40 | 58 | </div> |
| 59 | + | |
| 60 | + <p className="mt-8 hidden text-xs text-ink-3 sm:block"> | |
| 61 | + Astuce : appuie sur <kbd className="kbd">/</kbd> pour chercher,{" "} | |
| 62 | + <kbd className="kbd">↑</kbd> | |
| 63 | + <kbd className="kbd">↓</kbd> pour naviguer dans les suggestions. | |
| 64 | + </p> | |
| 41 | 65 | </div> |
| 42 | 66 | </main> |
| 43 | 67 | ); |
modified
apps/web/app/search/search-content.tsx
+98 −18
@@ -15,6 +15,7 @@ import { ResultItem } from "@/components/result-item"; | ||
| 15 | 15 | import { SearchBar } from "@/components/search-bar"; |
| 16 | 16 | import { Button } from "@/components/ui/button"; |
| 17 | 17 | import { formatNumber } from "@/lib/format"; |
| 18 | +import { cn } from "@/lib/utils"; | |
| 18 | 19 | import type { SearchResponse } from "@/lib/types"; |
| 19 | 20 | |
| 20 | 21 | const PAGE_SIZE = 10; |
@@ -31,9 +32,24 @@ function buildSearchUrl( | ||
| 31 | 32 | if (filters.language) params.set("language", filters.language); |
| 32 | 33 | if (filters.quebecOnly) params.set("quebec_only", "true"); |
| 33 | 34 | if (filters.images) params.set("images", "true"); |
| 35 | + if (filters.freshness) params.set("freshness", filters.freshness); | |
| 34 | 36 | return `/search?${params.toString()}`; |
| 35 | 37 | } |
| 36 | 38 | |
| 39 | +/** Pages affichées dans la pagination numérotée : 1 … autour de la courante … n. */ | |
| 40 | +function pageNumbers(current: number, total: number): (number | "…")[] { | |
| 41 | + const wanted = new Set<number>([1, total, current - 1, current, current + 1]); | |
| 42 | + const pages = [...wanted] | |
| 43 | + .filter((p) => p >= 1 && p <= total) | |
| 44 | + .sort((a, b) => a - b); | |
| 45 | + const out: (number | "…")[] = []; | |
| 46 | + for (const [index, p] of pages.entries()) { | |
| 47 | + if (index > 0 && p - pages[index - 1] > 1) out.push("…"); | |
| 48 | + out.push(p); | |
| 49 | + } | |
| 50 | + return out; | |
| 51 | +} | |
| 52 | + | |
| 37 | 53 | function ResultsSkeleton() { |
| 38 | 54 | return ( |
| 39 | 55 | <div aria-hidden="true" className="divide-y divide-line"> |
@@ -43,17 +59,17 @@ function ResultsSkeleton() { | ||
| 43 | 59 | className="flex animate-pulse items-start gap-4 py-6 sm:gap-6" |
| 44 | 60 | > |
| 45 | 61 | <div className="min-w-0 flex-1 space-y-2.5"> |
| 46 | − <div className="h-3.5 w-1/3 rounded bg-[rgba(20,24,20,0.07)]" /> | |
| 47 | − <div className="h-5 w-2/3 rounded bg-line" /> | |
| 48 | − <div className="h-3.5 w-full rounded bg-[rgba(20,24,20,0.07)]" /> | |
| 49 | − <div className="h-3.5 w-5/6 rounded bg-[rgba(20,24,20,0.07)]" /> | |
| 62 | + <div className="h-3.5 w-1/3 rounded bg-[var(--line)]" /> | |
| 63 | + <div className="h-5 w-2/3 rounded bg-[var(--line)]" /> | |
| 64 | + <div className="h-3.5 w-full rounded bg-[var(--line)]" /> | |
| 65 | + <div className="h-3.5 w-5/6 rounded bg-[var(--line)]" /> | |
| 50 | 66 | <div className="flex gap-2 pt-0.5"> |
| 51 | − <div className="h-[22px] w-[72px] rounded-[5px] bg-[rgba(20,24,20,0.07)]" /> | |
| 52 | − <div className="h-[22px] w-24 rounded-[5px] bg-[rgba(20,24,20,0.07)]" /> | |
| 67 | + <div className="h-[22px] w-[72px] rounded-[5px] bg-[var(--line)]" /> | |
| 68 | + <div className="h-[22px] w-24 rounded-[5px] bg-[var(--line)]" /> | |
| 53 | 69 | </div> |
| 54 | 70 | </div> |
| 55 | 71 | {i % 2 === 0 ? ( |
| 56 | − <div className="h-[76px] w-[76px] shrink-0 rounded-card bg-[rgba(20,24,20,0.07)] sm:h-[110px] sm:w-[110px]" /> | |
| 72 | + <div className="h-[76px] w-[76px] shrink-0 rounded-card bg-[var(--line)] sm:h-[110px] sm:w-[110px]" /> | |
| 57 | 73 | ) : null} |
| 58 | 74 | </div> |
| 59 | 75 | ))} |
@@ -72,6 +88,7 @@ export function SearchContent() { | ||
| 72 | 88 | language: searchParams.get("language") ?? undefined, |
| 73 | 89 | quebecOnly: searchParams.get("quebec_only") === "true", |
| 74 | 90 | images: searchParams.get("images") === "true", |
| 91 | + freshness: searchParams.get("freshness") ?? undefined, | |
| 75 | 92 | }; |
| 76 | 93 | const imagesMode = Boolean(filters.images); |
| 77 | 94 | |
@@ -104,6 +121,8 @@ export function SearchContent() { | ||
| 104 | 121 | params.set("quebec_only", "true"); |
| 105 | 122 | } |
| 106 | 123 | if (gallery) params.set("images", "true"); |
| 124 | + const freshness = searchParams.get("freshness"); | |
| 125 | + if (freshness) params.set("freshness", freshness); | |
| 107 | 126 | |
| 108 | 127 | fetch(`/api/search?${params.toString()}`, { signal: controller.signal }) |
| 109 | 128 | .then((res) => { |
@@ -135,10 +154,10 @@ export function SearchContent() { | ||
| 135 | 154 | return ( |
| 136 | 155 | <div className="flex-1"> |
| 137 | 156 | {/* Barre persistante sous l'en-tête global (h-14) : recherche + filtres. */} |
| 138 | − <div className="sticky top-14 z-30 border-b border-line bg-paper md:bg-[rgba(245,243,238,0.92)] md:backdrop-blur"> | |
| 157 | + <div className="sticky top-14 z-30 border-b border-line bg-paper md:bg-[var(--paper-glass)] md:backdrop-blur"> | |
| 139 | 158 | <div className="mx-auto max-w-3xl px-4 pt-3"> |
| 140 | 159 | <SearchBar key={q} initialQuery={q} className="max-w-xl" /> |
| 141 | − <div className="overflow-x-auto py-3"> | |
| 160 | + <div className="py-3"> | |
| 142 | 161 | <FilterPills |
| 143 | 162 | filters={filters} |
| 144 | 163 | onChange={(next) => navigate(1, next)} |
@@ -187,28 +206,66 @@ export function SearchContent() { | ||
| 187 | 206 | </div> |
| 188 | 207 | ) : data ? ( |
| 189 | 208 | <> |
| 190 | − <p aria-live="polite" className="klabel"> | |
| 191 | − <span className="mr-1 rounded-[4px] bg-lime px-1.5 py-0.5 text-[11px] text-ink"> | |
| 209 | + <p aria-live="polite" className="klabel flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 210 | + <span className="rounded-[4px] bg-lime px-1.5 py-0.5 text-[11px] text-white"> | |
| 192 | 211 | {formatNumber(data.total)} |
| 193 | − </span>{" "} | |
| 194 | − {data.total === 1 ? "résultat" : "résultats"} ( | |
| 195 | − {formatNumber(data.took_ms)} ms) | |
| 212 | + </span> | |
| 213 | + <span> | |
| 214 | + {data.total === 1 ? "résultat" : "résultats"} ( | |
| 215 | + {formatNumber(data.took_ms)} ms) | |
| 216 | + </span> | |
| 217 | + {data.semantic || data.reranked ? ( | |
| 218 | + <span | |
| 219 | + title={ | |
| 220 | + data.semantic | |
| 221 | + ? "Recherche hybride : mots-clés + compréhension sémantique (FR ↔ EN), réordonnée par IA" | |
| 222 | + : "Top des résultats réordonné par IA" | |
| 223 | + } | |
| 224 | + className="inline-flex items-center gap-1 rounded-full border border-[var(--line-strong)] px-2 py-0.5 text-[9px] text-ink-2" | |
| 225 | + > | |
| 226 | + <span aria-hidden="true" className="text-[8px]">✦</span> | |
| 227 | + {data.semantic ? "Recherche sémantique" : "Classement IA"} | |
| 228 | + </span> | |
| 229 | + ) : null} | |
| 196 | 230 | </p> |
| 197 | 231 | {imagesMode ? ( |
| 198 | 232 | <div className="mt-4"> |
| 199 | 233 | <ImageGallery results={data.results} /> |
| 200 | 234 | </div> |
| 201 | 235 | ) : ( |
| 202 | − <div className="mt-2 divide-y divide-line"> | |
| 236 | + <div className="stagger mt-2 divide-y divide-line"> | |
| 203 | 237 | {data.results.map((result) => ( |
| 204 | 238 | <ResultItem key={result.url} result={result} /> |
| 205 | 239 | ))} |
| 206 | 240 | </div> |
| 207 | 241 | )} |
| 242 | + | |
| 243 | + {!imagesMode && data.related && data.related.length > 0 ? ( | |
| 244 | + <section aria-label="Recherches liées" className="mt-8 border-t border-line pt-6"> | |
| 245 | + <p className="klabel">Recherches liées</p> | |
| 246 | + <ul className="mt-3 flex flex-wrap gap-2"> | |
| 247 | + {data.related.map((related) => ( | |
| 248 | + <li key={related}> | |
| 249 | + <Link | |
| 250 | + href={`/search?q=${encodeURIComponent(related)}`} | |
| 251 | + className="chip-link" | |
| 252 | + > | |
| 253 | + <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="h-3 w-3 opacity-50"> | |
| 254 | + <circle cx="11" cy="11" r="8" /> | |
| 255 | + <path d="m21 21-4.35-4.35" /> | |
| 256 | + </svg> | |
| 257 | + {related} | |
| 258 | + </Link> | |
| 259 | + </li> | |
| 260 | + ))} | |
| 261 | + </ul> | |
| 262 | + </section> | |
| 263 | + ) : null} | |
| 264 | + | |
| 208 | 265 | {hasPrevious || hasNext ? ( |
| 209 | 266 | <nav |
| 210 | 267 | aria-label="Pagination des résultats" |
| 211 | − className="mt-8 flex items-center justify-between border-t border-line pt-5" | |
| 268 | + className="mt-8 flex flex-wrap items-center justify-center gap-2 border-t border-line pt-6" | |
| 212 | 269 | > |
| 213 | 270 | <Button |
| 214 | 271 | variant="ghost" |
@@ -218,8 +275,31 @@ export function SearchContent() { | ||
| 218 | 275 | > |
| 219 | 276 | ← Précédent |
| 220 | 277 | </Button> |
| 221 | − <span className="gk-mono text-xs font-medium text-ink-2"> | |
| 222 | − Page {formatNumber(page)} de {formatNumber(totalPages)} | |
| 278 | + <span className="mx-1 flex items-center gap-1.5"> | |
| 279 | + {pageNumbers(page, totalPages).map((p, index) => | |
| 280 | + p === "…" ? ( | |
| 281 | + <span key={`gap-${index}`} aria-hidden="true" className="px-1 text-ink-3"> | |
| 282 | + … | |
| 283 | + </span> | |
| 284 | + ) : ( | |
| 285 | + <button | |
| 286 | + key={p} | |
| 287 | + type="button" | |
| 288 | + aria-label={`Page ${p}`} | |
| 289 | + aria-current={p === page ? "page" : undefined} | |
| 290 | + disabled={p === page} | |
| 291 | + onClick={() => navigate(p, filters)} | |
| 292 | + className={cn( | |
| 293 | + "gk-mono h-8 min-w-8 rounded-ctl border-[1.5px] px-2 text-xs font-bold transition-colors", | |
| 294 | + p === page | |
| 295 | + ? "border-ink bg-[var(--btn-primary-bg)] text-[var(--btn-primary-fg)]" | |
| 296 | + : "border-transparent text-ink-2 hover:border-ink hover:text-ink", | |
| 297 | + )} | |
| 298 | + > | |
| 299 | + {formatNumber(p)} | |
| 300 | + </button> | |
| 301 | + ), | |
| 302 | + )} | |
| 223 | 303 | </span> |
| 224 | 304 | <Button |
| 225 | 305 | variant="ghost" |
modified
apps/web/app/status/status-panel.tsx
+31 −0
@@ -101,6 +101,37 @@ export function StatusPanel() { | ||
| 101 | 101 | </div> |
| 102 | 102 | </div> |
| 103 | 103 | |
| 104 | + {typeof status.embedding_coverage === "number" ? ( | |
| 105 | + <div className="gk-card mt-5 p-5"> | |
| 106 | + <div className="flex items-baseline justify-between gap-4"> | |
| 107 | + <p className="klabel">Couverture sémantique de l'index</p> | |
| 108 | + <p className="gk-mono text-sm font-bold text-ink"> | |
| 109 | + {Math.round(status.embedding_coverage * 100)} % | |
| 110 | + </p> | |
| 111 | + </div> | |
| 112 | + {/* Meter : remplissage accent, piste = pas pâle du même hue */} | |
| 113 | + <div | |
| 114 | + role="meter" | |
| 115 | + aria-valuemin={0} | |
| 116 | + aria-valuemax={100} | |
| 117 | + aria-valuenow={Math.round(status.embedding_coverage * 100)} | |
| 118 | + aria-label="Part des pages comprises par la recherche sémantique" | |
| 119 | + className="mt-3 h-2.5 overflow-hidden rounded-full" | |
| 120 | + style={{ background: "color-mix(in srgb, var(--green) 14%, var(--surface))" }} | |
| 121 | + > | |
| 122 | + <div | |
| 123 | + className="h-full rounded-full bg-green transition-[width] duration-700" | |
| 124 | + style={{ width: `${Math.round(status.embedding_coverage * 100)}%` }} | |
| 125 | + /> | |
| 126 | + </div> | |
| 127 | + <p className="mt-2.5 text-xs leading-relaxed text-ink-2"> | |
| 128 | + Les pages couvertes sont aussi trouvables par sens (« heat pump » | |
| 129 | + trouve « thermopompe »), pas seulement par mots-clés. La | |
| 130 | + couverture progresse en continu, en arrière-plan. | |
| 131 | + </p> | |
| 132 | + </div> | |
| 133 | + ) : null} | |
| 134 | + | |
| 104 | 135 | <div className="mt-5 grid gap-4 sm:grid-cols-3"> |
| 105 | 136 | <Stat |
| 106 | 137 | label="Domaines connus" |
modified
apps/web/app/trouveka-bot/page.tsx
+2 −2
@@ -57,7 +57,7 @@ export default function TrouvekaBotPage() { | ||
| 57 | 57 | <p className="mt-2 leading-relaxed"> |
| 58 | 58 | Le robot s'identifie toujours avec ce user-agent exact : |
| 59 | 59 | </p> |
| 60 | − <pre className="gk-mono mt-3 overflow-x-auto rounded-ctl border-[1.5px] border-ink bg-surface-2 p-4 text-[13px] text-ink shadow-off-field"> | |
| 60 | + <pre className="gk-mono mt-3 overflow-x-auto rounded-ctl border border-[var(--edge)] bg-surface-2 p-4 text-[13px] text-ink shadow-off-field"> | |
| 61 | 61 | <code>{USER_AGENT}</code> |
| 62 | 62 | </pre> |
| 63 | 63 | </section> |
@@ -82,7 +82,7 @@ export default function TrouvekaBotPage() { | ||
| 82 | 82 | Pour empêcher TrouveKABot de visiter votre site, ajoutez ceci à |
| 83 | 83 | votre fichier <code>robots.txt</code> : |
| 84 | 84 | </p> |
| 85 | − <pre className="gk-mono mt-3 overflow-x-auto rounded-ctl border-[1.5px] border-ink bg-surface-2 p-4 text-[13px] text-ink shadow-off-field"> | |
| 85 | + <pre className="gk-mono mt-3 overflow-x-auto rounded-ctl border border-[var(--edge)] bg-surface-2 p-4 text-[13px] text-ink shadow-off-field"> | |
| 86 | 86 | <code>{"User-agent: TrouveKABot\nDisallow: /"}</code> |
| 87 | 87 | </pre> |
| 88 | 88 | <p className="mt-2 leading-relaxed"> |
modified
apps/web/components/admin/dashboard.tsx
+1 −1
@@ -93,7 +93,7 @@ export function AdminDashboard({ | ||
| 93 | 93 | |
| 94 | 94 | <main id="contenu" className="mx-auto max-w-6xl space-y-7 px-4 py-6"> |
| 95 | 95 | {error && !overview ? ( |
| 96 | − <p className="rounded-ctl border-[1.5px] border-ink bg-danger-soft px-4 py-3 text-sm font-medium text-danger shadow-off-field"> | |
| 96 | + <p className="rounded-ctl border border-[var(--edge)] bg-danger-soft px-4 py-3 text-sm font-medium text-danger shadow-off-field"> | |
| 97 | 97 | Impossible de joindre l'API d'administration. Vérifiez que le |
| 98 | 98 | backend tourne, puis rechargez la page. |
| 99 | 99 | </p> |
modified
apps/web/components/filter-pills.tsx
+123 −45
@@ -1,11 +1,15 @@ | ||
| 1 | 1 | /** |
| 2 | − * Trouve-KA — filtres de recherche en pastilles | |
| 2 | + * Trouve-KA — filtres de recherche (pastilles + panneau compact sur mobile) | |
| 3 | 3 | * Author: Simon-Pierre Boucher |
| 4 | 4 | * Contact: contact@spboucher.ai |
| 5 | + * | |
| 6 | + * Desktop : tout est visible. Mobile : une rangée défilante de catégories + | |
| 7 | + * un bouton « Filtres (n) » qui replie langue / fraîcheur / Québec seulement. | |
| 5 | 8 | */ |
| 6 | 9 | |
| 7 | 10 | "use client"; |
| 8 | 11 | |
| 12 | +import { useState } from "react"; | |
| 9 | 13 | import { cn } from "@/lib/utils"; |
| 10 | 14 | |
| 11 | 15 | /** Filtres actifs de la page de résultats. */ |
@@ -14,6 +18,7 @@ export interface SearchFilters { | ||
| 14 | 18 | language?: string; |
| 15 | 19 | quebecOnly: boolean; |
| 16 | 20 | images?: boolean; |
| 21 | + freshness?: string; | |
| 17 | 22 | } |
| 18 | 23 | |
| 19 | 24 | interface FilterPillsProps { |
@@ -21,84 +26,157 @@ interface FilterPillsProps { | ||
| 21 | 26 | onChange: (next: SearchFilters) => void; |
| 22 | 27 | } |
| 23 | 28 | |
| 24 | −interface Pill { | |
| 25 | − label: string; | |
| 26 | − active: boolean; | |
| 27 | − next: SearchFilters; | |
| 28 | −} | |
| 29 | +const FRESHNESS_OPTIONS = [ | |
| 30 | + ["day", "24 h"], | |
| 31 | + ["week", "Semaine"], | |
| 32 | + ["month", "Mois"], | |
| 33 | + ["year", "Année"], | |
| 34 | +] as const; | |
| 29 | 35 | |
| 30 | 36 | export function FilterPills({ filters, onChange }: FilterPillsProps) { |
| 37 | + const [moreOpen, setMoreOpen] = useState(false); | |
| 38 | + | |
| 31 | 39 | const allActive = |
| 32 | − !filters.category && !filters.language && !filters.quebecOnly && !filters.images; | |
| 40 | + !filters.category && | |
| 41 | + !filters.language && | |
| 42 | + !filters.quebecOnly && | |
| 43 | + !filters.images && | |
| 44 | + !filters.freshness; | |
| 33 | 45 | |
| 34 | − const pills: Pill[] = [ | |
| 35 | − { label: "Tout", active: allActive, next: { quebecOnly: false } }, | |
| 46 | + function toggleCategory(category: string): SearchFilters { | |
| 47 | + return { | |
| 48 | + ...filters, | |
| 49 | + category: filters.category === category ? undefined : category, | |
| 50 | + }; | |
| 51 | + } | |
| 52 | + | |
| 53 | + const primary = [ | |
| 54 | + { label: "Tout", active: allActive, next: { quebecOnly: false } as SearchFilters }, | |
| 36 | 55 | { |
| 37 | 56 | label: "Images", |
| 38 | 57 | active: Boolean(filters.images), |
| 39 | 58 | next: { ...filters, images: !filters.images }, |
| 40 | 59 | }, |
| 41 | − { | |
| 42 | − label: "Actualités", | |
| 43 | − active: filters.category === "news", | |
| 44 | − next: { | |
| 45 | − ...filters, | |
| 46 | − category: filters.category === "news" ? undefined : "news", | |
| 47 | − }, | |
| 48 | − }, | |
| 60 | + { label: "Actualités", active: filters.category === "news", next: toggleCategory("news") }, | |
| 49 | 61 | { |
| 50 | 62 | label: "Gouvernement", |
| 51 | 63 | active: filters.category === "government", |
| 52 | − next: { | |
| 53 | − ...filters, | |
| 54 | − category: filters.category === "government" ? undefined : "government", | |
| 55 | − }, | |
| 64 | + next: toggleCategory("government"), | |
| 65 | + }, | |
| 66 | + { | |
| 67 | + label: "Éducation", | |
| 68 | + active: filters.category === "education", | |
| 69 | + next: toggleCategory("education"), | |
| 56 | 70 | }, |
| 71 | + ]; | |
| 72 | + | |
| 73 | + const secondary = [ | |
| 57 | 74 | { |
| 58 | 75 | label: "Français", |
| 59 | 76 | active: filters.language === "fr", |
| 60 | − next: { | |
| 61 | − ...filters, | |
| 62 | − language: filters.language === "fr" ? undefined : "fr", | |
| 63 | − }, | |
| 77 | + next: { ...filters, language: filters.language === "fr" ? undefined : "fr" } as SearchFilters, | |
| 64 | 78 | }, |
| 65 | 79 | { |
| 66 | 80 | label: "English", |
| 67 | 81 | active: filters.language === "en", |
| 68 | − next: { | |
| 69 | − ...filters, | |
| 70 | − language: filters.language === "en" ? undefined : "en", | |
| 71 | − }, | |
| 82 | + next: { ...filters, language: filters.language === "en" ? undefined : "en" } as SearchFilters, | |
| 72 | 83 | }, |
| 73 | 84 | { |
| 74 | 85 | label: "Québec seulement", |
| 75 | 86 | active: filters.quebecOnly, |
| 76 | − next: { ...filters, quebecOnly: !filters.quebecOnly }, | |
| 87 | + next: { ...filters, quebecOnly: !filters.quebecOnly } as SearchFilters, | |
| 77 | 88 | }, |
| 89 | + ...FRESHNESS_OPTIONS.map(([value, label]) => ({ | |
| 90 | + label, | |
| 91 | + active: filters.freshness === value, | |
| 92 | + next: { | |
| 93 | + ...filters, | |
| 94 | + freshness: filters.freshness === value ? undefined : value, | |
| 95 | + } as SearchFilters, | |
| 96 | + })), | |
| 78 | 97 | ]; |
| 79 | 98 | |
| 99 | + const activeSecondary = secondary.filter((p) => p.active).length; | |
| 100 | + | |
| 80 | 101 | return ( |
| 81 | − <div | |
| 82 | − role="group" | |
| 83 | − aria-label="Filtres de recherche" | |
| 84 | − className="flex flex-wrap gap-2" | |
| 85 | − > | |
| 86 | − {pills.map((pill) => ( | |
| 102 | + <div> | |
| 103 | + {/* Rangée principale : défilante sur mobile, complète sur desktop */} | |
| 104 | + <div | |
| 105 | + role="group" | |
| 106 | + aria-label="Filtres de recherche" | |
| 107 | + className="flex items-center gap-2 overflow-x-auto pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:flex-wrap sm:overflow-visible" | |
| 108 | + > | |
| 109 | + {primary.map((pill) => ( | |
| 110 | + <button | |
| 111 | + key={pill.label} | |
| 112 | + type="button" | |
| 113 | + aria-pressed={pill.active} | |
| 114 | + onClick={() => onChange(pill.next)} | |
| 115 | + className={cn("pill shrink-0", pill.active && "pill-active")} | |
| 116 | + > | |
| 117 | + {pill.label} | |
| 118 | + </button> | |
| 119 | + ))} | |
| 120 | + | |
| 121 | + {/* Mobile : bouton d'ouverture des filtres secondaires */} | |
| 87 | 122 | <button |
| 88 | − key={pill.label} | |
| 89 | 123 | type="button" |
| 90 | − aria-pressed={pill.active} | |
| 91 | − onClick={() => onChange(pill.next)} | |
| 124 | + aria-expanded={moreOpen} | |
| 125 | + aria-controls="filtres-avances" | |
| 126 | + onClick={() => setMoreOpen((open) => !open)} | |
| 92 | 127 | className={cn( |
| 93 | − "gk-mono whitespace-nowrap rounded-full border-[1.5px] border-ink px-3.5 py-1.5 text-[11px] font-bold uppercase tracking-[0.08em] shadow-[2px_2px_0_rgba(20,24,20,0.12)] transition-[background,color,transform,box-shadow] duration-150 hover:-translate-y-px hover:shadow-[3px_3px_0_rgba(20,24,20,0.2)] active:translate-y-0 active:shadow-none", | |
| 94 | − pill.active | |
| 95 | − ? "bg-ink text-lime" | |
| 96 | − : "bg-surface text-ink-2 hover:bg-lime hover:text-ink", | |
| 128 | + "pill shrink-0 sm:hidden", | |
| 129 | + (moreOpen || activeSecondary > 0) && "pill-active", | |
| 97 | 130 | )} |
| 98 | 131 | > |
| 99 | − {pill.label} | |
| 132 | + Filtres{activeSecondary > 0 ? ` (${activeSecondary})` : ""} | |
| 133 | + <span aria-hidden="true" className="ml-1"> | |
| 134 | + {moreOpen ? "▴" : "▾"} | |
| 135 | + </span> | |
| 100 | 136 | </button> |
| 101 | − ))} | |
| 137 | + | |
| 138 | + {/* Desktop : filtres secondaires en ligne */} | |
| 139 | + <span aria-hidden="true" className="mx-1 hidden h-5 w-px shrink-0 bg-line sm:block" /> | |
| 140 | + <div | |
| 141 | + role="group" | |
| 142 | + aria-label="Langue, portée et fraîcheur" | |
| 143 | + className="hidden gap-2 sm:flex sm:flex-wrap" | |
| 144 | + > | |
| 145 | + {secondary.map((pill) => ( | |
| 146 | + <button | |
| 147 | + key={pill.label} | |
| 148 | + type="button" | |
| 149 | + aria-pressed={pill.active} | |
| 150 | + onClick={() => onChange(pill.next)} | |
| 151 | + className={cn("pill", pill.active && "pill-active")} | |
| 152 | + > | |
| 153 | + {pill.label} | |
| 154 | + </button> | |
| 155 | + ))} | |
| 156 | + </div> | |
| 157 | + </div> | |
| 158 | + | |
| 159 | + {/* Mobile : panneau repliable des filtres secondaires */} | |
| 160 | + {moreOpen ? ( | |
| 161 | + <div | |
| 162 | + id="filtres-avances" | |
| 163 | + role="group" | |
| 164 | + aria-label="Langue, portée et fraîcheur" | |
| 165 | + className="mt-2 flex flex-wrap gap-2 border-t border-line pt-2.5 sm:hidden" | |
| 166 | + > | |
| 167 | + {secondary.map((pill) => ( | |
| 168 | + <button | |
| 169 | + key={pill.label} | |
| 170 | + type="button" | |
| 171 | + aria-pressed={pill.active} | |
| 172 | + onClick={() => onChange(pill.next)} | |
| 173 | + className={cn("pill", pill.active && "pill-active")} | |
| 174 | + > | |
| 175 | + {pill.label} | |
| 176 | + </button> | |
| 177 | + ))} | |
| 178 | + </div> | |
| 179 | + ) : null} | |
| 102 | 180 | </div> |
| 103 | 181 | ); |
| 104 | 182 | } |
modified
apps/web/components/image-gallery.tsx
+1 −1
@@ -22,7 +22,7 @@ function GalleryTile({ result }: { result: SearchResult }) { | ||
| 22 | 22 | rel="noopener noreferrer" |
| 23 | 23 | className="group block rounded-card focus-visible:outline focus-visible:outline-2 focus-visible:outline-green" |
| 24 | 24 | > |
| 25 | − <span className="block overflow-hidden rounded-card border-[1.5px] border-ink bg-surface shadow-[3px_3px_0_rgba(20,24,20,0.1)] transition-[transform,box-shadow] duration-150 group-hover:-translate-y-0.5 group-hover:shadow-[5px_5px_0_rgba(20,24,20,0.55)]"> | |
| 25 | + <span className="block overflow-hidden rounded-card border border-[var(--edge)] bg-surface shadow-off-soft transition-[transform,box-shadow] duration-150 group-hover:-translate-y-0.5 group-hover:shadow-off-hard"> | |
| 26 | 26 | <img |
| 27 | 27 | src={result.image} |
| 28 | 28 | alt={result.title} |
modified
apps/web/components/result-thumbnail.tsx
+1 −1
@@ -38,7 +38,7 @@ export function ResultThumbnail({ src, href }: ResultThumbnailProps) { | ||
| 38 | 38 | decoding="async" |
| 39 | 39 | referrerPolicy="no-referrer" |
| 40 | 40 | onError={() => setFailed(true)} |
| 41 | − className="h-[76px] w-[76px] rounded-card border-[1.5px] border-ink bg-surface-2 object-cover shadow-[3px_3px_0_rgba(20,24,20,0.12)] transition-[transform,box-shadow] duration-150 group-hover:-translate-x-px group-hover:-translate-y-px group-hover:shadow-[4px_4px_0_rgba(20,24,20,0.2)] sm:h-[110px] sm:w-[110px]" | |
| 41 | + className="h-[76px] w-[76px] rounded-card border border-[var(--edge)] bg-surface-2 object-cover shadow-off-soft transition-[transform,box-shadow] duration-150 group-hover:-translate-x-px group-hover:-translate-y-px group-hover:shadow-off-hard sm:h-[110px] sm:w-[110px]" | |
| 42 | 42 | /> |
| 43 | 43 | </a> |
| 44 | 44 | ); |
modified
apps/web/components/search-bar.tsx
+181 −20
@@ -1,13 +1,24 @@ | ||
| 1 | 1 | /** |
| 2 | − * Trouve-KA — barre de recherche (homepage et en-tête des résultats) | |
| 2 | + * Trouve-KA — barre de recherche avec suggestions instantanées | |
| 3 | 3 | * Author: Simon-Pierre Boucher |
| 4 | 4 | * Contact: contact@spboucher.ai |
| 5 | + * | |
| 6 | + * Combobox accessible : suggestions live (/api/suggest, debounce 150 ms), | |
| 7 | + * navigation ↑/↓, Entrée pour chercher, Échap pour fermer. Le raccourci | |
| 8 | + * global « / » ramène le focus sur le champ depuis n'importe où. | |
| 5 | 9 | */ |
| 6 | 10 | |
| 7 | 11 | "use client"; |
| 8 | 12 | |
| 9 | 13 | import { useRouter } from "next/navigation"; |
| 10 | −import { useEffect, useState, type FormEvent } from "react"; | |
| 14 | +import { | |
| 15 | + useCallback, | |
| 16 | + useEffect, | |
| 17 | + useId, | |
| 18 | + useRef, | |
| 19 | + useState, | |
| 20 | + type FormEvent, | |
| 21 | +} from "react"; | |
| 11 | 22 | import { cn } from "@/lib/utils"; |
| 12 | 23 | |
| 13 | 24 | interface SearchBarProps { |
@@ -21,7 +32,8 @@ interface SearchBarProps { | ||
| 21 | 32 | className?: string; |
| 22 | 33 | } |
| 23 | 34 | |
| 24 | −const SUGGESTION_INTERVAL_MS = 4000; | |
| 35 | +const PLACEHOLDER_INTERVAL_MS = 4000; | |
| 36 | +const SUGGEST_DEBOUNCE_MS = 150; | |
| 25 | 37 | |
| 26 | 38 | export function SearchBar({ |
| 27 | 39 | initialQuery = "", |
@@ -32,33 +44,135 @@ export function SearchBar({ | ||
| 32 | 44 | className, |
| 33 | 45 | }: SearchBarProps) { |
| 34 | 46 | const router = useRouter(); |
| 47 | + const listboxId = useId(); | |
| 48 | + const inputRef = useRef<HTMLInputElement>(null); | |
| 49 | + const rootRef = useRef<HTMLDivElement>(null); | |
| 50 | + const debounceRef = useRef<number>(0); | |
| 51 | + const abortRef = useRef<AbortController | null>(null); | |
| 52 | + | |
| 35 | 53 | const [query, setQuery] = useState(initialQuery); |
| 36 | − const [suggestionIndex, setSuggestionIndex] = useState(0); | |
| 54 | + const [placeholderIndex, setPlaceholderIndex] = useState(0); | |
| 55 | + const [suggestions, setSuggestions] = useState<string[]>([]); | |
| 56 | + const [open, setOpen] = useState(false); | |
| 57 | + const [selected, setSelected] = useState(-1); | |
| 37 | 58 | |
| 38 | − const suggestionCount = placeholderSuggestions?.length ?? 0; | |
| 59 | + const placeholderCount = placeholderSuggestions?.length ?? 0; | |
| 39 | 60 | |
| 61 | + // Placeholder rotatif (respecte prefers-reduced-motion) | |
| 40 | 62 | useEffect(() => { |
| 41 | − if (suggestionCount < 2) return; | |
| 63 | + if (placeholderCount < 2) return; | |
| 42 | 64 | if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; |
| 43 | 65 | const timer = window.setInterval( |
| 44 | − () => setSuggestionIndex((index) => (index + 1) % suggestionCount), | |
| 45 | − SUGGESTION_INTERVAL_MS, | |
| 66 | + () => setPlaceholderIndex((index) => (index + 1) % placeholderCount), | |
| 67 | + PLACEHOLDER_INTERVAL_MS, | |
| 46 | 68 | ); |
| 47 | 69 | return () => window.clearInterval(timer); |
| 48 | − }, [suggestionCount]); | |
| 70 | + }, [placeholderCount]); | |
| 49 | 71 | |
| 50 | − const placeholder = | |
| 51 | − suggestionCount > 0 | |
| 52 | − ? `Essaie « ${placeholderSuggestions![suggestionIndex % suggestionCount]} »…` | |
| 53 | − : "Rechercher…"; | |
| 72 | + // Raccourci global « / » : focus sur la recherche | |
| 73 | + useEffect(() => { | |
| 74 | + function onKey(event: KeyboardEvent) { | |
| 75 | + if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return; | |
| 76 | + const target = event.target as HTMLElement | null; | |
| 77 | + if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; | |
| 78 | + if (target?.isContentEditable) return; | |
| 79 | + event.preventDefault(); | |
| 80 | + inputRef.current?.focus(); | |
| 81 | + inputRef.current?.select(); | |
| 82 | + } | |
| 83 | + window.addEventListener("keydown", onKey); | |
| 84 | + return () => window.removeEventListener("keydown", onKey); | |
| 85 | + }, []); | |
| 54 | 86 | |
| 55 | − function onSubmit(event: FormEvent) { | |
| 56 | − event.preventDefault(); | |
| 57 | − const q = query.trim(); | |
| 87 | + // Fermer les suggestions au clic extérieur | |
| 88 | + useEffect(() => { | |
| 89 | + function onPointerDown(event: PointerEvent) { | |
| 90 | + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); | |
| 91 | + } | |
| 92 | + window.addEventListener("pointerdown", onPointerDown); | |
| 93 | + return () => window.removeEventListener("pointerdown", onPointerDown); | |
| 94 | + }, []); | |
| 95 | + | |
| 96 | + const fetchSuggestions = useCallback((value: string) => { | |
| 97 | + window.clearTimeout(debounceRef.current); | |
| 98 | + abortRef.current?.abort(); | |
| 99 | + const trimmed = value.trim(); | |
| 100 | + if (trimmed.length < 2) { | |
| 101 | + setSuggestions([]); | |
| 102 | + setOpen(false); | |
| 103 | + return; | |
| 104 | + } | |
| 105 | + debounceRef.current = window.setTimeout(() => { | |
| 106 | + const controller = new AbortController(); | |
| 107 | + abortRef.current = controller; | |
| 108 | + fetch(`/api/suggest?q=${encodeURIComponent(trimmed)}`, { | |
| 109 | + signal: controller.signal, | |
| 110 | + }) | |
| 111 | + .then((res) => (res.ok ? res.json() : { suggestions: [] })) | |
| 112 | + .then((json: { suggestions?: string[] }) => { | |
| 113 | + const list = (json.suggestions ?? []).slice(0, 8); | |
| 114 | + setSuggestions(list); | |
| 115 | + setSelected(-1); | |
| 116 | + setOpen(list.length > 0); | |
| 117 | + }) | |
| 118 | + .catch(() => { | |
| 119 | + /* suggestions best-effort : le champ reste pleinement fonctionnel */ | |
| 120 | + }); | |
| 121 | + }, SUGGEST_DEBOUNCE_MS); | |
| 122 | + }, []); | |
| 123 | + | |
| 124 | + function go(value: string) { | |
| 125 | + const q = value.trim(); | |
| 58 | 126 | if (!q) return; |
| 127 | + setOpen(false); | |
| 128 | + abortRef.current?.abort(); | |
| 129 | + window.clearTimeout(debounceRef.current); | |
| 59 | 130 | router.push(`/search?q=${encodeURIComponent(q)}`); |
| 60 | 131 | } |
| 61 | 132 | |
| 133 | + function onSubmit(event: FormEvent) { | |
| 134 | + event.preventDefault(); | |
| 135 | + go(selected >= 0 && suggestions[selected] ? suggestions[selected] : query); | |
| 136 | + } | |
| 137 | + | |
| 138 | + function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) { | |
| 139 | + if (!open || suggestions.length === 0) { | |
| 140 | + if (event.key === "Escape") (event.target as HTMLInputElement).blur(); | |
| 141 | + return; | |
| 142 | + } | |
| 143 | + if (event.key === "ArrowDown") { | |
| 144 | + event.preventDefault(); | |
| 145 | + setSelected((i) => (i + 1) % suggestions.length); | |
| 146 | + } else if (event.key === "ArrowUp") { | |
| 147 | + event.preventDefault(); | |
| 148 | + setSelected((i) => (i <= 0 ? suggestions.length - 1 : i - 1)); | |
| 149 | + } else if (event.key === "Escape") { | |
| 150 | + setOpen(false); | |
| 151 | + setSelected(-1); | |
| 152 | + } else if (event.key === "Tab") { | |
| 153 | + setOpen(false); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + /** Surligne la portion déjà tapée dans la suggestion. */ | |
| 158 | + function highlight(suggestion: string) { | |
| 159 | + const q = query.trim().toLowerCase(); | |
| 160 | + const index = suggestion.toLowerCase().indexOf(q); | |
| 161 | + if (q.length === 0 || index === -1) return suggestion; | |
| 162 | + return ( | |
| 163 | + <> | |
| 164 | + {suggestion.slice(0, index)} | |
| 165 | + <mark>{suggestion.slice(index, index + q.length)}</mark> | |
| 166 | + {suggestion.slice(index + q.length)} | |
| 167 | + </> | |
| 168 | + ); | |
| 169 | + } | |
| 170 | + | |
| 171 | + const placeholder = | |
| 172 | + placeholderCount > 0 | |
| 173 | + ? `Essaie « ${placeholderSuggestions![placeholderIndex % placeholderCount]} »…` | |
| 174 | + : "Rechercher…"; | |
| 175 | + | |
| 62 | 176 | const isLarge = size === "lg"; |
| 63 | 177 | |
| 64 | 178 | return ( |
@@ -66,7 +180,7 @@ export function SearchBar({ | ||
| 66 | 180 | <label htmlFor="champ-recherche" className="sr-only"> |
| 67 | 181 | Rechercher sur le web québécois |
| 68 | 182 | </label> |
| 69 | − <div className="relative"> | |
| 183 | + <div ref={rootRef} className="relative"> | |
| 70 | 184 | <svg |
| 71 | 185 | aria-hidden="true" |
| 72 | 186 | viewBox="0 0 24 24" |
@@ -76,7 +190,7 @@ export function SearchBar({ | ||
| 76 | 190 | strokeLinecap="round" |
| 77 | 191 | strokeLinejoin="round" |
| 78 | 192 | className={cn( |
| 79 | − "pointer-events-none absolute top-1/2 -translate-y-1/2 text-ink-3", | |
| 193 | + "pointer-events-none absolute top-1/2 z-10 -translate-y-1/2 text-ink-3", | |
| 80 | 194 | isLarge ? "left-5 h-5 w-5" : "left-3.5 h-4 w-4", |
| 81 | 195 | )} |
| 82 | 196 | > |
@@ -84,11 +198,26 @@ export function SearchBar({ | ||
| 84 | 198 | <path d="m21 21-4.35-4.35" /> |
| 85 | 199 | </svg> |
| 86 | 200 | <input |
| 201 | + ref={inputRef} | |
| 87 | 202 | id="champ-recherche" |
| 88 | 203 | type="search" |
| 89 | 204 | name="q" |
| 205 | + role="combobox" | |
| 206 | + aria-expanded={open} | |
| 207 | + aria-controls={listboxId} | |
| 208 | + aria-activedescendant={ | |
| 209 | + selected >= 0 ? `${listboxId}-${selected}` : undefined | |
| 210 | + } | |
| 211 | + aria-autocomplete="list" | |
| 90 | 212 | value={query} |
| 91 | − onChange={(event) => setQuery(event.target.value)} | |
| 213 | + onChange={(event) => { | |
| 214 | + setQuery(event.target.value); | |
| 215 | + fetchSuggestions(event.target.value); | |
| 216 | + }} | |
| 217 | + onFocus={() => { | |
| 218 | + if (suggestions.length > 0 && query.trim().length >= 2) setOpen(true); | |
| 219 | + }} | |
| 220 | + onKeyDown={onKeyDown} | |
| 92 | 221 | placeholder={placeholder} |
| 93 | 222 | autoComplete="off" |
| 94 | 223 | spellCheck={false} |
@@ -110,13 +239,45 @@ export function SearchBar({ | ||
| 110 | 239 | <button |
| 111 | 240 | type="submit" |
| 112 | 241 | className={cn( |
| 113 | − "gk-display absolute right-1.5 top-1/2 -translate-y-1/2 rounded-[5px] border-[1.5px] border-ink bg-ink font-bold text-lime transition-colors hover:bg-green-deep active:translate-y-[calc(-50%+1px)]", | |
| 242 | + "btn-search absolute right-1.5 top-1/2 z-10 -translate-y-1/2 active:translate-y-[calc(-50%+1px)]", | |
| 114 | 243 | isLarge ? "h-11 px-6 text-base" : "h-7 px-4 text-[13px]", |
| 115 | 244 | )} |
| 116 | 245 | > |
| 117 | 246 | Chercher |
| 118 | 247 | </button> |
| 119 | 248 | ) : null} |
| 249 | + | |
| 250 | + {open && suggestions.length > 0 ? ( | |
| 251 | + <ul id={listboxId} role="listbox" aria-label="Suggestions" className="suggest-panel"> | |
| 252 | + {suggestions.map((suggestion, index) => ( | |
| 253 | + <li key={suggestion} role="presentation"> | |
| 254 | + <button | |
| 255 | + type="button" | |
| 256 | + role="option" | |
| 257 | + id={`${listboxId}-${index}`} | |
| 258 | + aria-selected={index === selected} | |
| 259 | + className="suggest-item" | |
| 260 | + onMouseEnter={() => setSelected(index)} | |
| 261 | + onClick={() => go(suggestion)} | |
| 262 | + > | |
| 263 | + <svg | |
| 264 | + aria-hidden="true" | |
| 265 | + viewBox="0 0 24 24" | |
| 266 | + fill="none" | |
| 267 | + stroke="currentColor" | |
| 268 | + strokeWidth="2" | |
| 269 | + strokeLinecap="round" | |
| 270 | + className="h-3.5 w-3.5 shrink-0 opacity-50" | |
| 271 | + > | |
| 272 | + <circle cx="11" cy="11" r="8" /> | |
| 273 | + <path d="m21 21-4.35-4.35" /> | |
| 274 | + </svg> | |
| 275 | + <span className="truncate">{highlight(suggestion)}</span> | |
| 276 | + </button> | |
| 277 | + </li> | |
| 278 | + ))} | |
| 279 | + </ul> | |
| 280 | + ) : null} | |
| 120 | 281 | </div> |
| 121 | 282 | {buttonPlacement === "below" ? ( |
| 122 | 283 | <button type="submit" className="btn btn-primary mt-8 px-9"> |
added
apps/web/components/theme-toggle.tsx
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +/** | |
| 2 | + * Trouve-KA — bascule clair/sombre (persistée, défaut système) | |
| 3 | + * Author: Simon-Pierre Boucher | |
| 4 | + * Contact: contact@spboucher.ai | |
| 5 | + */ | |
| 6 | + | |
| 7 | +"use client"; | |
| 8 | + | |
| 9 | +import { useEffect, useState } from "react"; | |
| 10 | + | |
| 11 | +type Theme = "light" | "dark"; | |
| 12 | + | |
| 13 | +function applyTheme(theme: Theme) { | |
| 14 | + document.documentElement.dataset.theme = theme; | |
| 15 | + try { | |
| 16 | + localStorage.setItem("tk-theme", theme); | |
| 17 | + } catch { | |
| 18 | + /* stockage indisponible (navigation privée) : le choix vaut pour la session */ | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function ThemeToggle() { | |
| 23 | + // null jusqu'au montage : le SSR ne connaît pas le thème → pas d'hydration mismatch | |
| 24 | + const [theme, setTheme] = useState<Theme | null>(null); | |
| 25 | + | |
| 26 | + useEffect(() => { | |
| 27 | + setTheme( | |
| 28 | + (document.documentElement.dataset.theme as Theme) === "dark" | |
| 29 | + ? "dark" | |
| 30 | + : "light", | |
| 31 | + ); | |
| 32 | + }, []); | |
| 33 | + | |
| 34 | + const next: Theme = theme === "dark" ? "light" : "dark"; | |
| 35 | + | |
| 36 | + return ( | |
| 37 | + <button | |
| 38 | + type="button" | |
| 39 | + aria-label={theme === "dark" ? "Passer au mode clair" : "Passer au mode sombre"} | |
| 40 | + title={theme === "dark" ? "Mode clair" : "Mode sombre"} | |
| 41 | + onClick={() => { | |
| 42 | + setTheme(next); | |
| 43 | + applyTheme(next); | |
| 44 | + }} | |
| 45 | + className="inline-flex h-8 w-8 items-center justify-center rounded-full border-[1.5px] border-transparent text-ink-2 transition-colors hover:border-ink hover:text-ink" | |
| 46 | + > | |
| 47 | + {theme === "dark" ? ( | |
| 48 | + <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="h-4 w-4"> | |
| 49 | + <circle cx="12" cy="12" r="4" /> | |
| 50 | + <path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /> | |
| 51 | + </svg> | |
| 52 | + ) : ( | |
| 53 | + <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4"> | |
| 54 | + <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /> | |
| 55 | + </svg> | |
| 56 | + )} | |
| 57 | + </button> | |
| 58 | + ); | |
| 59 | +} | |
modified
apps/web/components/ui/badge.tsx
+1 −1
@@ -11,7 +11,7 @@ import { cn } from "@/lib/utils"; | ||
| 11 | 11 | const badgeVariants = cva( |
| 12 | 12 | // Chip éditorial signature : mono uppercase, bordure encre, micro-ombre |
| 13 | 13 | // dure qui grandit au survol (translation -1px, comme les cartes KA). |
| 14 | − "inline-flex items-center gap-1 whitespace-nowrap rounded-[5px] border border-ink px-2 py-[3px] font-mono text-[10px] font-bold uppercase tracking-[0.09em] shadow-[2px_2px_0_rgba(20,24,20,0.16)] transition-[transform,box-shadow] duration-150 hover:-translate-y-px hover:shadow-[3px_3px_0_rgba(20,24,20,0.24)]", | |
| 14 | + "inline-flex items-center gap-1 whitespace-nowrap rounded-[5px] border border-[var(--edge)] px-2 py-[3px] font-mono text-[10px] font-bold uppercase tracking-[0.09em] shadow-[var(--shadow-flat)] transition-[transform,box-shadow] duration-150 hover:-translate-y-px hover:shadow-[var(--shadow-field)]", | |
| 15 | 15 | { |
| 16 | 16 | variants: { |
| 17 | 17 | variant: { |
modified
apps/web/components/ui/button.tsx
+1 −1
@@ -16,7 +16,7 @@ const buttonVariants = cva( | ||
| 16 | 16 | default: |
| 17 | 17 | "border-ink bg-ink text-lime shadow-off-btn hover:bg-green-deep", |
| 18 | 18 | secondary: |
| 19 | − "border-ink bg-lime text-ink shadow-[4px_4px_0_rgba(217,242,107,0.25)] hover:bg-lime-soft", | |
| 19 | + "border-ink bg-lime text-ink shadow-off-btn hover:bg-lime-soft", | |
| 20 | 20 | outline: "border-ink bg-surface text-ink shadow-off-field hover:bg-lime", |
| 21 | 21 | ghost: "border-transparent text-ink-2 hover:border-ink hover:text-ink", |
| 22 | 22 | danger: |
modified
apps/web/components/wordmark.tsx
+4 −4
@@ -19,7 +19,7 @@ interface WordmarkProps { | ||
| 19 | 19 | export function Wordmark({ variant = "header", className }: WordmarkProps) { |
| 20 | 20 | const base = |
| 21 | 21 | variant === "footer" |
| 22 | − ? "text-[26px] text-paper" | |
| 22 | + ? "text-[26px] text-white" | |
| 23 | 23 | : variant === "hero" |
| 24 | 24 | ? "text-5xl text-ink sm:text-6xl" |
| 25 | 25 | : "text-[21px] text-ink sm:text-[24px]"; |
@@ -34,15 +34,15 @@ export function Wordmark({ variant = "header", className }: WordmarkProps) { | ||
| 34 | 34 | > |
| 35 | 35 | Trouve |
| 36 | 36 | {variant === "footer" ? ( |
| 37 | − <span className="inline-block -rotate-2 rounded-md bg-lime px-[7px] pb-[2px] text-ink"> | |
| 37 | + <span className="inline-block -rotate-2 rounded-md bg-lime px-[7px] pb-[2px] text-white"> | |
| 38 | 38 | KA |
| 39 | 39 | </span> |
| 40 | 40 | ) : variant === "hero" ? ( |
| 41 | − <span className="inline-block -rotate-2 rounded-lg bg-ink px-3 pb-1 text-lime"> | |
| 41 | + <span className="inline-block -rotate-2 rounded-lg bg-[var(--btn-primary-bg)] px-3 pb-1 text-[var(--btn-primary-fg)]"> | |
| 42 | 42 | KA |
| 43 | 43 | </span> |
| 44 | 44 | ) : ( |
| 45 | − <span className="inline-block -rotate-2 rounded-md bg-ink px-[7px] pb-[2px] text-lime transition-transform duration-200 group-hover:rotate-0"> | |
| 45 | + <span className="inline-block -rotate-2 rounded-md bg-[var(--btn-primary-bg)] px-[7px] pb-[2px] text-[var(--btn-primary-fg)] transition-transform duration-200 group-hover:rotate-0"> | |
| 46 | 46 | KA |
| 47 | 47 | </span> |
| 48 | 48 | )} |
modified
apps/web/lib/types.ts
+8 −0
@@ -27,6 +27,12 @@ export interface SearchResponse { | ||
| 27 | 27 | took_ms: number; |
| 28 | 28 | page: number; |
| 29 | 29 | limit: number; |
| 30 | + /** Recherche hybride (BM25 + kNN) active pour cette requête. */ | |
| 31 | + semantic?: boolean; | |
| 32 | + /** Top-20 réordonné par le reranker cross-encoder. */ | |
| 33 | + reranked?: boolean; | |
| 34 | + /** Recherches liées (requêtes populaires partageant des termes). */ | |
| 35 | + related?: string[]; | |
| 30 | 36 | results: SearchResult[]; |
| 31 | 37 | } |
| 32 | 38 | |
@@ -34,6 +40,8 @@ export interface SearchResponse { | ||
| 34 | 40 | export interface StatusResponse { |
| 35 | 41 | pages_indexed: number; |
| 36 | 42 | domains_count: number; |
| 43 | + /** Part de l'index couverte par la recherche sémantique, ∈ [0,1]. */ | |
| 44 | + embedding_coverage?: number; | |
| 37 | 45 | indexed_last_hour: number; |
| 38 | 46 | fetched_last_hour: number; |
| 39 | 47 | crawler_state: string; |
modified
apps/web/tailwind.config.ts
+24 −20
@@ -2,6 +2,10 @@ | ||
| 2 | 2 | * Trouve-KA — configuration Tailwind CSS (tokens « éditorial sharp » Groupe KA) |
| 3 | 3 | * Author: Simon-Pierre Boucher |
| 4 | 4 | * Contact: contact@spboucher.ai |
| 5 | + * | |
| 6 | + * Toutes les couleurs pointent vers les variables CSS de globals.css : | |
| 7 | + * le mode sombre ([data-theme="dark"]) rethème l'app entière sans toucher | |
| 8 | + * aux classes utilitaires. | |
| 5 | 9 | */ |
| 6 | 10 | |
| 7 | 11 | import type { Config } from "tailwindcss"; |
@@ -15,33 +19,32 @@ const config: Config = { | ||
| 15 | 19 | theme: { |
| 16 | 20 | extend: { |
| 17 | 21 | colors: { |
| 18 | − // Palette éditoriale partagée avec l'écosystème Groupe KA. | |
| 19 | − paper: "#f5f3ee", | |
| 22 | + paper: "var(--paper)", | |
| 20 | 23 | surface: { |
| 21 | − DEFAULT: "#ffffff", | |
| 22 | − 2: "#faf9f5", | |
| 24 | + DEFAULT: "var(--surface)", | |
| 25 | + 2: "var(--surface-2)", | |
| 23 | 26 | }, |
| 24 | 27 | ink: { |
| 25 | − DEFAULT: "#141814", | |
| 26 | − 2: "#4d5551", | |
| 27 | − 3: "#8b928c", | |
| 28 | + DEFAULT: "var(--ink)", | |
| 29 | + 2: "var(--ink-2)", | |
| 30 | + 3: "var(--ink-3)", | |
| 28 | 31 | }, |
| 29 | − line: "rgba(20, 24, 20, 0.14)", | |
| 32 | + line: "var(--line)", | |
| 30 | 33 | green: { |
| 31 | − DEFAULT: "#1c5c41", | |
| 32 | − deep: "#123f2e", | |
| 34 | + DEFAULT: "var(--green)", | |
| 35 | + deep: "var(--green-deep)", | |
| 33 | 36 | }, |
| 34 | 37 | lime: { |
| 35 | − DEFAULT: "#b9cfee", | |
| 36 | − soft: "#e6eef9", | |
| 38 | + DEFAULT: "var(--lime)", | |
| 39 | + soft: "var(--lime-soft)", | |
| 37 | 40 | }, |
| 38 | 41 | amber: { |
| 39 | − DEFAULT: "#e8a33d", | |
| 40 | − soft: "#fdf3e2", | |
| 42 | + DEFAULT: "var(--amber)", | |
| 43 | + soft: "var(--amber-soft)", | |
| 41 | 44 | }, |
| 42 | 45 | danger: { |
| 43 | − DEFAULT: "#b3423a", | |
| 44 | − soft: "#fbe9e7", | |
| 46 | + DEFAULT: "var(--danger)", | |
| 47 | + soft: "var(--danger-soft)", | |
| 45 | 48 | }, |
| 46 | 49 | // Palette de statut (réservée aux états — jamais utilisée comme |
| 47 | 50 | // couleur de série; toujours accompagnée d'un libellé texte). |
@@ -58,6 +61,7 @@ const config: Config = { | ||
| 58 | 61 | }, |
| 59 | 62 | fontFamily: { |
| 60 | 63 | display: ["var(--font-space-grotesk)", "system-ui", "sans-serif"], |
| 64 | + serif: ["var(--font-fraunces)", "Georgia", "serif"], | |
| 61 | 65 | body: ["var(--font-inter)", "system-ui", "sans-serif"], |
| 62 | 66 | sans: ["var(--font-inter)", "system-ui", "sans-serif"], |
| 63 | 67 | mono: ["var(--font-jetbrains)", "ui-monospace", "monospace"], |
@@ -67,10 +71,10 @@ const config: Config = { | ||
| 67 | 71 | ctl: "6px", |
| 68 | 72 | }, |
| 69 | 73 | boxShadow: { |
| 70 | − "off-hard": "6px 6px 0 #141814", | |
| 71 | − "off-soft": "8px 8px 0 rgba(20, 24, 20, 0.08)", | |
| 72 | − "off-btn": "4px 4px 0 rgba(20, 24, 20, 0.25)", | |
| 73 | − "off-field": "3px 3px 0 rgba(20, 24, 20, 0.08)", | |
| 74 | + "off-hard": "var(--shadow-off)", | |
| 75 | + "off-soft": "var(--shadow-off-soft)", | |
| 76 | + "off-btn": "var(--shadow-btn)", | |
| 77 | + "off-field": "var(--shadow-field)", | |
| 74 | 78 | }, |
| 75 | 79 | }, |
| 76 | 80 | }, |
modified
packages/database/db.py
+36 −0
@@ -498,6 +498,42 @@ class Database: | ||
| 498 | 498 | took_ms, |
| 499 | 499 | ) |
| 500 | 500 | |
| 501 | + async def suggest_queries(self, prefix: str, limit: int = 5) -> list[str]: | |
| 502 | + """Requêtes réelles populaires commençant par le préfixe (autocomplete).""" | |
| 503 | + rows = await self.pool.fetch( | |
| 504 | + """ | |
| 505 | + SELECT lower(query) AS q, count(*) AS n | |
| 506 | + FROM search_queries | |
| 507 | + WHERE NOT zero_result AND lower(query) LIKE $1 || '%' AND length(query) > length($1) | |
| 508 | + GROUP BY lower(query) | |
| 509 | + ORDER BY n DESC, max(created_at) DESC | |
| 510 | + LIMIT $2 | |
| 511 | + """, | |
| 512 | + prefix.lower(), | |
| 513 | + limit, | |
| 514 | + ) | |
| 515 | + return [r["q"] for r in rows] | |
| 516 | + | |
| 517 | + async def related_queries(self, terms: list[str], exclude: str, limit: int = 6) -> list[str]: | |
| 518 | + """Requêtes populaires partageant au moins un terme (recherches liées).""" | |
| 519 | + if not terms: | |
| 520 | + return [] | |
| 521 | + rows = await self.pool.fetch( | |
| 522 | + """ | |
| 523 | + SELECT lower(query) AS q, count(*) AS n | |
| 524 | + FROM search_queries | |
| 525 | + WHERE NOT zero_result AND lower(query) <> $2 | |
| 526 | + AND EXISTS (SELECT 1 FROM unnest($1::text[]) t WHERE lower(query) LIKE '%' || t || '%') | |
| 527 | + GROUP BY lower(query) | |
| 528 | + ORDER BY n DESC, max(created_at) DESC | |
| 529 | + LIMIT $3 | |
| 530 | + """, | |
| 531 | + [t.lower() for t in terms if len(t) >= 3][:6], | |
| 532 | + exclude.lower(), | |
| 533 | + limit, | |
| 534 | + ) | |
| 535 | + return [r["q"] for r in rows] | |
| 536 | + | |
| 501 | 537 | async def top_zero_result_queries(self, limit: int = 50) -> list[asyncpg.Record]: |
| 502 | 538 | """Requêtes zéro-résultat les plus fréquentes (§12 : « de l'or ») — |
| 503 | 539 | elles pilotent les seeds, les synonymes et les priorités de crawl.""" |
modified
packages/search-core/client.py
+36 −0
@@ -92,6 +92,42 @@ class SearchCore: | ||
| 92 | 92 | except NotFoundError: |
| 93 | 93 | return 0 |
| 94 | 94 | |
| 95 | + async def suggest_titles(self, prefix: str, limit: int = 6) -> list[str]: | |
| 96 | + """Complétions tirées des titres indexés (match_phrase_prefix bilingue).""" | |
| 97 | + try: | |
| 98 | + res = await self.client.search( | |
| 99 | + index=self.index, | |
| 100 | + body={ | |
| 101 | + "size": limit * 3, | |
| 102 | + "query": { | |
| 103 | + "bool": { | |
| 104 | + "should": [ | |
| 105 | + {"match_phrase_prefix": {"title": {"query": prefix, "max_expansions": 20}}}, | |
| 106 | + {"match_phrase_prefix": {"title.en": {"query": prefix, "max_expansions": 20}}}, | |
| 107 | + ] | |
| 108 | + } | |
| 109 | + }, | |
| 110 | + "_source": ["title"], | |
| 111 | + }, | |
| 112 | + ) | |
| 113 | + except Exception: | |
| 114 | + return [] | |
| 115 | + seen: set[str] = set() | |
| 116 | + out: list[str] = [] | |
| 117 | + for hit in res["hits"]["hits"]: | |
| 118 | + title = (hit["_source"].get("title") or "").strip() | |
| 119 | + # Tronquer au segment utile : les titres de pages traînent des | |
| 120 | + # suffixes de site (« … | Ville de Québec ») inutiles en suggestion. | |
| 121 | + short = title.split(" | ")[0].split(" — ")[0].split(" – ")[0].strip() | |
| 122 | + key = short.lower() | |
| 123 | + if not short or len(short) > 90 or key in seen: | |
| 124 | + continue | |
| 125 | + seen.add(key) | |
| 126 | + out.append(short) | |
| 127 | + if len(out) >= limit: | |
| 128 | + break | |
| 129 | + return out | |
| 130 | + | |
| 95 | 131 | async def embedding_coverage(self) -> float: |
| 96 | 132 | """Fraction des documents ayant un embedding ∈ [0,1]. |
| 97 | 133 | |
added
scripts/fix-mojibake/repair.py
+116 −0
@@ -0,0 +1,116 @@ | ||
| 1 | +# Trouve-KA — réparation du mojibake dans l'index | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Répare les documents indexés avec du double encodage UTF-8→cp1252 | |
| 6 | +(« é » → « é ») produit avant le fix de decode_html. | |
| 7 | + | |
| 8 | +Pour chaque doc contenant des signatures : répare title/description/body/headings | |
| 9 | +via fix_mojibake (réparation strictement non destructive), réécrit le doc et | |
| 10 | +efface son embedding (recalculé par le backfill sur le texte propre). | |
| 11 | +Synchronise aussi title/description dans Postgres (table documents). | |
| 12 | + | |
| 13 | +Usage : python scripts/fix-mojibake/repair.py | |
| 14 | +""" | |
| 15 | + | |
| 16 | +import asyncio | |
| 17 | + | |
| 18 | +from opensearchpy import AsyncOpenSearch | |
| 19 | +from opensearchpy.helpers import async_bulk | |
| 20 | + | |
| 21 | +from trouveka.config import get_settings | |
| 22 | +from trouveka.database import Database | |
| 23 | +from trouveka.parser import fix_mojibake | |
| 24 | + | |
| 25 | +FIELDS = ["title", "description", "body", "headings"] | |
| 26 | +QUERY = { | |
| 27 | + "bool": { | |
| 28 | + "should": [ | |
| 29 | + {"match_phrase": {"body": "Ã"}}, | |
| 30 | + {"match_phrase": {"title": "Ã"}}, | |
| 31 | + {"match_phrase": {"description": "Ã"}}, | |
| 32 | + {"query_string": {"query": 'description:"é" OR body:"é" OR title:"é" OR body:"â€" OR body:"è"'}}, | |
| 33 | + ], | |
| 34 | + "minimum_should_match": 1, | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | + | |
| 39 | +async def main() -> None: | |
| 40 | + s = get_settings() | |
| 41 | + client = AsyncOpenSearch(hosts=[s.search_url], timeout=120) | |
| 42 | + db = Database(s.database_url, pool_min=1, pool_max=3) | |
| 43 | + await db.connect() | |
| 44 | + repaired = scanned = 0 | |
| 45 | + search_after = None | |
| 46 | + try: | |
| 47 | + while True: | |
| 48 | + body = { | |
| 49 | + "size": 200, | |
| 50 | + "query": QUERY, | |
| 51 | + "_source": FIELDS + ["url"], | |
| 52 | + # Curseur stable : on balaie TOUT le stock une fois, même si | |
| 53 | + # certains documents matchent sans être réparables. | |
| 54 | + "sort": [{"_id": "asc"}], | |
| 55 | + } | |
| 56 | + if search_after is not None: | |
| 57 | + body["search_after"] = search_after | |
| 58 | + res = await client.search(index=s.search_index, body=body) | |
| 59 | + hits = res["hits"]["hits"] | |
| 60 | + if not hits: | |
| 61 | + break | |
| 62 | + search_after = hits[-1]["sort"] | |
| 63 | + actions = [] | |
| 64 | + for hit in hits: | |
| 65 | + scanned += 1 | |
| 66 | + src = hit["_source"] | |
| 67 | + changes = {} | |
| 68 | + for f in FIELDS: | |
| 69 | + val = src.get(f) | |
| 70 | + if isinstance(val, str): | |
| 71 | + fixed = fix_mojibake(val) | |
| 72 | + if fixed != val: | |
| 73 | + changes[f] = fixed | |
| 74 | + elif isinstance(val, list): | |
| 75 | + fixed_list = [fix_mojibake(v) if isinstance(v, str) else v for v in val] | |
| 76 | + if fixed_list != val: | |
| 77 | + changes[f] = fixed_list | |
| 78 | + if not changes: | |
| 79 | + continue # signatures détectées mais irréparables : recrawl s'en chargera | |
| 80 | + repaired += 1 | |
| 81 | + actions.append({ | |
| 82 | + "_op_type": "update", | |
| 83 | + "_index": s.search_index, | |
| 84 | + "_id": hit["_id"], | |
| 85 | + # embedding effacé : le backfill le recalcule sur le texte propre | |
| 86 | + "script": {"source": "ctx._source.remove('embedding')"}, | |
| 87 | + }) | |
| 88 | + actions.append({ | |
| 89 | + "_op_type": "update", | |
| 90 | + "_index": s.search_index, | |
| 91 | + "_id": hit["_id"], | |
| 92 | + "doc": changes, | |
| 93 | + }) | |
| 94 | + if "title" in changes or "description" in changes: | |
| 95 | + await db.pool.execute( | |
| 96 | + """ | |
| 97 | + UPDATE documents SET | |
| 98 | + title = COALESCE($2, title), | |
| 99 | + description = COALESCE($3, description) | |
| 100 | + WHERE url_id = (SELECT id FROM urls WHERE url = $1 OR canonical_url = $1 LIMIT 1) | |
| 101 | + """, | |
| 102 | + src.get("url"), | |
| 103 | + changes.get("title"), | |
| 104 | + changes.get("description"), | |
| 105 | + ) | |
| 106 | + if actions: | |
| 107 | + await async_bulk(client, actions, raise_on_error=False) | |
| 108 | + print(f"→ scannés {scanned}, réparés {repaired}") | |
| 109 | + finally: | |
| 110 | + print(f"✓ terminé : {repaired} documents réparés sur {scanned} scannés") | |
| 111 | + await db.close() | |
| 112 | + await client.close() | |
| 113 | + | |
| 114 | + | |
| 115 | +if __name__ == "__main__": | |
| 116 | + asyncio.run(main()) | |
modified
services/parser/__init__.py
+2 −2
@@ -8,6 +8,6 @@ Chaque page est traitée comme non fiable : texte extrait puis assaini, | ||
| 8 | 8 | URLs re-canonicalisées, MIME types jamais crus sur parole. |
| 9 | 9 | """ |
| 10 | 10 | |
| 11 | −from .html_parser import parse_html, decode_html, looks_like_garbage | |
| 11 | +from .html_parser import parse_html, decode_html, fix_mojibake, looks_like_garbage | |
| 12 | 12 | |
| 13 | −__all__ = ["parse_html", "decode_html", "looks_like_garbage"] | |
| 13 | +__all__ = ["parse_html", "decode_html", "fix_mojibake", "looks_like_garbage"] | |
modified
services/parser/html_parser.py
+61 −6
@@ -102,23 +102,78 @@ _META_CHARSET_RE = re.compile( | ||
| 102 | 102 | _CHARSET_ALIASES = {"iso-8859-1": "cp1252", "latin-1": "cp1252", "latin1": "cp1252", "ansi": "cp1252"} |
| 103 | 103 | |
| 104 | 104 | |
| 105 | −def decode_html(body: bytes, declared_charset: str | None = None) -> str: | |
| 106 | − """Décode le HTML : charset HTTP déclaré → meta charset → UTF-8 strict → cp1252. | |
| 105 | +# Signatures de mojibake UTF-8→cp1252 (« é »→« é », nbsp→«  », « ’ »→« ’ »…) | |
| 106 | +_MOJIBAKE_RE = re.compile(r"Ã[©¨ªàâ´¹»‰€§®¯²¼½¾]|â€[™œ“”«»\x93\x94]|Â[ °«»]") | |
| 107 | + | |
| 107 | 108 | |
| 108 | − Les vieux sites québécois sont souvent en ISO-8859-1/cp1252; décoder | |
| 109 | − aveuglément en UTF-8 produit du charabia qui finirait dans l'index. | |
| 109 | +def fix_mojibake(text: str) -> str: | |
| 110 | + """Répare le double encodage UTF-8 lu comme cp1252, si détecté. | |
| 111 | + | |
| 112 | + Ne touche au texte que si (1) les signatures sont présentes, (2) l'aller- | |
| 113 | + retour cp1252→utf-8 réussit strictement et (3) le résultat contient moins | |
| 114 | + de signatures qu'avant — jamais de réparation destructive. | |
| 110 | 115 | """ |
| 116 | + if not text or not _MOJIBAKE_RE.search(text): | |
| 117 | + return text | |
| 118 | + before = len(_MOJIBAKE_RE.findall(text)) | |
| 119 | + # 2e candidat : « à »/nbsp mojibakés produisent « Ã »/« Â » + nbsp, que la | |
| 120 | + # normalisation d'espaces a pu réduire en espace simple — on le restaure | |
| 121 | + # pour que l'aller-retour cp1252→utf-8 redevienne possible. | |
| 122 | + # « Ã » vient de « à » (dont le 2e octet 0xA0 s'affiche en nbsp) suivi du | |
| 123 | + # vrai espace du texte : on restaure le nbsp SANS manger l'espace. | |
| 124 | + candidates = (text, text.replace("Ã ", "Ã\xa0 ").replace("Â ", "Â\xa0")) | |
| 125 | + for cand in candidates: | |
| 126 | + try: | |
| 127 | + repaired = cand.encode("cp1252").decode("utf-8") | |
| 128 | + except (UnicodeEncodeError, UnicodeDecodeError): | |
| 129 | + continue | |
| 130 | + if len(_MOJIBAKE_RE.findall(repaired)) < before: | |
| 131 | + return re.sub(r" {2,}", " ", repaired.replace("\xa0", " ")) | |
| 132 | + # Contenu MIXTE (vrais accents + mojibake dans le même champ) : l'aller- | |
| 133 | + # retour global échoue sur les vrais accents. Réparation token par token — | |
| 134 | + # un mot mélange rarement les deux. | |
| 135 | + tokens = text.replace("Ã ", "Ã\xa0 ").replace("Â ", "Â\xa0").split(" ") | |
| 136 | + changed = False | |
| 137 | + for i, tok in enumerate(tokens): | |
| 138 | + if not _MOJIBAKE_RE.search(tok): | |
| 139 | + continue | |
| 140 | + try: | |
| 141 | + fixed = tok.encode("cp1252").decode("utf-8") | |
| 142 | + except (UnicodeEncodeError, UnicodeDecodeError): | |
| 143 | + continue | |
| 144 | + if len(_MOJIBAKE_RE.findall(fixed)) < len(_MOJIBAKE_RE.findall(tok)): | |
| 145 | + tokens[i] = fixed | |
| 146 | + changed = True | |
| 147 | + if changed: | |
| 148 | + return re.sub(r" {2,}", " ", " ".join(tokens).replace("\xa0", " ")) | |
| 149 | + return text | |
| 150 | + | |
| 151 | + | |
| 152 | +def decode_html(body: bytes, declared_charset: str | None = None) -> str: | |
| 153 | + """Décode le HTML : UTF-8 strict d'abord, puis charsets déclarés, puis cp1252. | |
| 154 | + | |
| 155 | + L'UTF-8 strict passe AVANT le charset HTTP déclaré : quantité de serveurs | |
| 156 | + annoncent iso-8859-1 (défaut Apache) en servant de l'UTF-8, et cp1252 | |
| 157 | + « réussit » toujours → « é » devenait « é » dans 10 000+ documents. | |
| 158 | + Un décodage UTF-8 strict qui réussit sur des octets multi-octets n'est | |
| 159 | + pratiquement jamais du vrai cp1252. Les vrais sites legacy cp1252 (dont le | |
| 160 | + décodage UTF-8 strict échoue) restent correctement gérés ensuite. | |
| 161 | + """ | |
| 162 | + try: | |
| 163 | + return fix_mojibake(body.decode("utf-8")) | |
| 164 | + except UnicodeDecodeError: | |
| 165 | + pass | |
| 111 | 166 | candidates: list[str] = [] |
| 112 | 167 | if declared_charset: |
| 113 | 168 | candidates.append(declared_charset.lower()) |
| 114 | 169 | meta = _META_CHARSET_RE.search(body[:4096]) |
| 115 | 170 | if meta: |
| 116 | 171 | candidates.append(meta.group(1).decode("ascii", errors="ignore").lower()) |
| 117 | − candidates.extend(["utf-8", "cp1252"]) | |
| 172 | + candidates.append("cp1252") | |
| 118 | 173 | for charset in candidates: |
| 119 | 174 | charset = _CHARSET_ALIASES.get(charset, charset) |
| 120 | 175 | try: |
| 121 | − return body.decode(charset) | |
| 176 | + return fix_mojibake(body.decode(charset)) | |
| 122 | 177 | except (UnicodeDecodeError, LookupError): |
| 123 | 178 | continue |
| 124 | 179 | return body.decode("utf-8", errors="replace") |
added
tests/test_parser_encoding.py
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +# Trouve-KA — tests de décodage et réparation d'encodage | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.parser import decode_html, fix_mojibake | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_fix_mojibake_repare_double_encodage(): | |
| 9 | + assert fix_mojibake("Une équipe donnée à Trois-Rivières") == "Une équipe donnée à Trois-Rivières" | |
| 10 | + assert fix_mojibake("décédé à l'âge de 87 ans") == "décédé à l'âge de 87 ans" | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_fix_mojibake_ne_touche_pas_au_texte_sain(): | |
| 14 | + sain = "Une équipe professionnelle à Trois-Rivières — déjà là." | |
| 15 | + assert fix_mojibake(sain) == sain | |
| 16 | + assert fix_mojibake("plain ascii text") == "plain ascii text" | |
| 17 | + | |
| 18 | + | |
| 19 | +def test_decode_html_utf8_prioritaire_sur_charset_menteur(): | |
| 20 | + # Serveur qui déclare iso-8859-1 mais sert de l'UTF-8 (défaut Apache) | |
| 21 | + body = "Ville de Trois-Rivières — déneigement".encode("utf-8") | |
| 22 | + assert decode_html(body, "iso-8859-1") == "Ville de Trois-Rivières — déneigement" | |
| 23 | + | |
| 24 | + | |
| 25 | +def test_decode_html_vrai_cp1252_toujours_gere(): | |
| 26 | + body = "Ville de Québec — déneigement".encode("cp1252") | |
| 27 | + assert decode_html(body, "iso-8859-1") == "Ville de Québec — déneigement" | |
| 28 | ||