SPB Git

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.

favoris ♥ « Mon univers Ka » (KA ID) : hub groupe-ka.com magasin central

- sortika/hubfav.py : client HMAC du hub (patron food-ka) — toggle synchrone,
  lecture avec cache mémoire 30 s invalidé au toggle, aucun stockage local
- API : GET /api/favorites + POST /api/favorites/toggle (session KA ID, 401
  sinon ; 403 compte non relié ; 502 hub injoignable)
- frontend : cœur ♥ sur les cartes et la fiche (non connecté -> connexion
  KA ID), page /favoris + entrée de nav, toggle optimiste avec rétablissement
- SSR : route /favoris (noindex, hors sitemaps)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 23, 2026) parent 71919c4 Browse files

4 changed files +334 −5

modified frontend/index.html +173 −3
@@ -397,6 +397,21 @@ button{font-family:inherit}
397 .st-empty{padding:20px;background:var(--surface-2);border-style:dashed} 397 .st-empty{padding:20px;background:var(--surface-2);border-style:dashed}
398 .st-empty b{font-family:var(--font-display);font-size:14px} 398 .st-empty b{font-family:var(--font-display);font-size:14px}
399 .st-loading{padding:40px 0;font-family:var(--font-mono);font-size:12px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.08em} 399 .st-loading{padding:40px 0;font-family:var(--font-mono);font-size:12px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.08em}
400+/* ---- favoris ♥ « Mon univers Ka » (hub groupe-ka.com) ---- */
401+.fav-btn{position:absolute;top:8px;right:8px;z-index:2;width:34px;height:34px;
402+ display:inline-flex;align-items:center;justify-content:center;cursor:pointer;
403+ background:var(--surface);border:1.5px solid var(--ink);border-radius:50%;
404+ color:var(--ink);padding:0;transition:transform .12s ease,background .12s ease}
405+.fav-btn svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2;stroke-linejoin:round}
406+.fav-btn:hover{transform:scale(1.08);background:var(--accent-soft)}
407+.fav-btn.on{background:var(--accent);border-color:var(--accent-deep);color:var(--on-accent)}
408+.fav-btn.on svg{fill:currentColor}
409+.fav-btn.big{position:static;flex:none;width:44px;height:44px}
410+.fav-btn.big svg{width:21px;height:21px}
411+.d-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
412+.d-title-row h1{min-width:0}
413+.fav-empty{grid-column:1/-1}
414+.fav-empty .btn-ka{margin-top:12px}
400 #ssr{display:none} 415 #ssr{display:none}
401 .hidden{display:none!important} 416 .hidden{display:none!important}
402 </style> 417 </style>
@@ -423,6 +438,7 @@ button{font-family:inherit}
423 <span class="brand-tag">Sorties &amp; événements — Québec</span> 438 <span class="brand-tag">Sorties &amp; événements — Québec</span>
424 <nav class="nav"> 439 <nav class="nav">
425 <a href="/#sources">Sources</a> 440 <a href="/#sources">Sources</a>
441+ <a data-nav href="/favoris" title="Mes favoris — Mon univers Ka">♥ Favoris</a>
426 <a data-nav href="/stats">Stats</a> 442 <a data-nav href="/stats">Stats</a>
427 <a data-nav href="/contact">Contact</a> 443 <a data-nav href="/contact">Contact</a>
428 <a href="/docs" target="_blank" rel="noopener">API</a> 444 <a href="/docs" target="_blank" rel="noopener">API</a>
@@ -489,7 +505,7 @@ button{font-family:inherit}
489 <div class="body"> 505 <div class="body">
490 <span class="klabel" id="d-cats-label"></span> 506 <span class="klabel" id="d-cats-label"></span>
491 <div id="d-status"></div> 507 <div id="d-status"></div>
492− <h1 id="d-title"></h1> 508+ <div class="d-title-row"><h1 id="d-title"></h1><span id="d-fav"></span></div>
493 <div class="desc" id="d-desc"></div> 509 <div class="desc" id="d-desc"></div>
494 <div class="detail-cats" id="d-cats"></div> 510 <div class="detail-cats" id="d-cats"></div>
495 </div> 511 </div>
@@ -508,6 +524,19 @@ button{font-family:inherit}
508 </div> 524 </div>
509 </main> 525 </main>
510 526
527+<!-- ======== VUE FAVORIS (/favoris) — ♥ « Mon univers Ka » (hub groupe-ka.com) ======== -->
528+<main id="view-favoris" class="hidden">
529+ <section class="hero container">
530+ <span class="kicker">Mon univers Ka · synchronisés sur toutes les plateformes Groupe KA</span>
531+ <h1>Mes <span class="hl">favoris</span></h1>
532+ <p class="lede" id="fav-lede">Vos sorties et événements favoris, rattachés à votre compte KA ID.</p>
533+ </section>
534+ <section class="container">
535+ <div class="meta-line"><span id="fav-count"></span></div>
536+ <section class="grid" id="fav-grid"></section>
537+ </section>
538+</main>
539+
511 <!-- ======== VUE STATS (/stats) — module ka-stats (SPEC.md), vanilla + SVG ======== --> 540 <!-- ======== VUE STATS (/stats) — module ka-stats (SPEC.md), vanilla + SVG ======== -->
512 <main id="view-stats" class="hidden"> 541 <main id="view-stats" class="hidden">
513 <div class="container stats-page"> 542 <div class="container stats-page">
@@ -724,7 +753,7 @@ function card(ev){
724 const img = ev.image 753 const img = ev.image
725 ? `<img src="${esc(ev.image)}" alt="" loading="lazy" onerror="this.remove()">` : ""; 754 ? `<img src="${esc(ev.image)}" alt="" loading="lazy" onerror="this.remove()">` : "";
726 return `<a class="card gk-card gk-hover" data-nav href="/evenement/${esc(ev.uid)}"> 755 return `<a class="card gk-card gk-hover" data-nav href="/evenement/${esc(ev.uid)}">
727− <div class="card-img">${img}<span class="badge-date">${dateBadge(ev)}</span></div> 756+ <div class="card-img">${img}<span class="badge-date">${dateBadge(ev)}</span>${favBtn(ev)}</div>
728 <div class="card-body"> 757 <div class="card-body">
729 <div class="card-top"><h3>${esc(ev.title)}</h3>${statusBadge(ev)||priceBadge(ev)}</div> 758 <div class="card-top"><h3>${esc(ev.title)}</h3>${statusBadge(ev)||priceBadge(ev)}</div>
730 ${ev.description?`<div class="desc">${esc(ev.description)}</div>`:""} 759 ${ev.description?`<div class="desc">${esc(ev.description)}</div>`:""}
@@ -795,6 +824,7 @@ async function showDetail(uid){
795 : ev.status==="postponed" ? "Cet événement est reporté — vérifiez la nouvelle date chez la source" 824 : ev.status==="postponed" ? "Cet événement est reporté — vérifiez la nouvelle date chez la source"
796 : "Complet — plus de billets disponibles"}</div>` : ""; 825 : "Complet — plus de billets disponibles"}</div>` : "";
797 $("d-title").textContent = ev.title; 826 $("d-title").textContent = ev.title;
827+ $("d-fav").innerHTML = favBtn(ev, true);
798 $("d-desc").textContent = ev.description || ""; 828 $("d-desc").textContent = ev.description || "";
799 $("d-cats").innerHTML = (ev.raw_categories||[]).slice(0,5).filter(c=>c.length<40) 829 $("d-cats").innerHTML = (ev.raw_categories||[]).slice(0,5).filter(c=>c.length<40)
800 .map(c=>`<span class="cat">${esc(c)}</span>`).join(""); 830 .map(c=>`<span class="cat">${esc(c)}</span>`).join("");
@@ -1598,8 +1628,146 @@ async function sbGenerate(){
1598 1628
1599 $("st-custom-btn").onclick = () => { SB.cat = null; SB.err = ""; sbOpen(); }; 1629 $("st-custom-btn").onclick = () => { SB.cat = null; SB.err = ""; sbOpen(); };
1600 1630
1631+/* ---------- favoris ♥ « Mon univers Ka » ----------
1632+ Le hub Groupe KA (groupe-ka.com) est le magasin central des favoris du
1633+ groupe : AUCUN stockage navigateur. Lecture/écriture via l'API locale
1634+ /api/favorites, qui relaie au hub (session KA ID requise). */
1635+const FAV = {ready:false, connected:false, ids:new Set(), items:[], meta:{}};
1636+const FAV_HEART = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`;
1637+
1638+/* événement -> item de favori poussé au hub (titre, lieu · date, prix, image) */
1639+function favItem(ev){
1640+ let price = "";
1641+ if(ev.is_free === true) price = "Gratuit";
1642+ else if(ev.price_min != null) price = `Dès ${ev.price_min.toFixed(2).replace(".",",")} $`;
1643+ return {
1644+ item_id: ev.uid,
1645+ title: ev.title || "Événement",
1646+ subtitle: [ev.venue || ev.city, dateBadge(ev)].filter(Boolean).join(" · "),
1647+ price_label: price,
1648+ image_url: ev.image || "",
1649+ url: `https://www.sorti-ka.com/evenement/${encodeURIComponent(ev.uid)}`,
1650+ };
1651+}
1652+
1653+/* cœur ♥ — cartes (par défaut) et fiche événement (big) */
1654+function favBtn(ev, big=false){
1655+ FAV.meta[ev.uid] = favItem(ev);
1656+ const on = FAV.ids.has(ev.uid);
1657+ const label = on ? "Retirer de mes favoris (Mon univers Ka)"
1658+ : "Ajouter à mes favoris (Mon univers Ka)";
1659+ return `<button type="button" class="fav-btn${on?" on":""}${big?" big":""}"
1660+ data-fav="${esc(ev.uid)}" aria-pressed="${on}" aria-label="${label}"
1661+ title="${label}">${FAV_HEART}</button>`;
1662+}
1663+
1664+/* repeint tous les cœurs affichés selon l'état courant */
1665+function favPaint(){
1666+ document.querySelectorAll(".fav-btn[data-fav]").forEach(b => {
1667+ const on = FAV.ids.has(b.dataset.fav);
1668+ b.classList.toggle("on", on);
1669+ b.setAttribute("aria-pressed", on);
1670+ const label = on ? "Retirer de mes favoris (Mon univers Ka)"
1671+ : "Ajouter à mes favoris (Mon univers Ka)";
1672+ b.setAttribute("aria-label", label); b.title = label;
1673+ });
1674+}
1675+
1676+/* lit la liste au hub (via l'API locale) — 401 = pas connecté, cœurs vides */
1677+async function favLoad(){
1678+ try{
1679+ const r = await fetch("/api/favorites");
1680+ if(r.status === 401){ FAV.connected = false; return; }
1681+ if(!r.ok) return;
1682+ const d = await r.json();
1683+ FAV.connected = true;
1684+ FAV.items = d.items || [];
1685+ FAV.ids = new Set(d.ids || []);
1686+ favPaint();
1687+ }catch(e){}
1688+ finally{ FAV.ready = true; }
1689+}
1690+
1691+/* toggle optimiste — le hub confirme (sinon on rétablit) */
1692+async function favToggle(uid){
1693+ if(!FAV.connected){ location.href = "/api/auth/ka/login"; return; }
1694+ const on = !FAV.ids.has(uid);
1695+ const item = FAV.meta[uid] || FAV.items.find(i => i.item_id === uid) || {item_id: uid};
1696+ const apply = v => {
1697+ if(v){ FAV.ids.add(uid); if(!FAV.items.some(i=>i.item_id===uid)) FAV.items.push(item); }
1698+ else { FAV.ids.delete(uid); FAV.items = FAV.items.filter(i => i.item_id !== uid); }
1699+ favPaint();
1700+ if(location.pathname === "/favoris") renderFavs();
1701+ };
1702+ apply(on);
1703+ try{
1704+ const r = await fetch("/api/favorites/toggle", {method:"POST",
1705+ headers:{"Content-Type":"application/json"},
1706+ body: JSON.stringify({on, item})});
1707+ if(!r.ok) throw new Error(r.status);
1708+ }catch(e){ apply(!on); }
1709+}
1710+
1711+/* clic sur un cœur — en phase capture : la carte entière est un lien */
1712+document.addEventListener("click", e => {
1713+ const b = e.target.closest(".fav-btn[data-fav]");
1714+ if(!b) return;
1715+ e.preventDefault(); e.stopPropagation();
1716+ favToggle(b.dataset.fav);
1717+}, true);
1718+
1719+/* carte d'un favori (données du hub — l'événement peut avoir quitté l'index) */
1720+function favCard(it){
1721+ FAV.meta[it.item_id] = it;
1722+ const img = it.image_url
1723+ ? `<img src="${esc(it.image_url)}" alt="" loading="lazy" onerror="this.remove()">` : "";
1724+ const local = (it.url || "").replace(/^https?:\/\/[^/]*sorti-ka\.com/i, "")
1725+ || `/evenement/${encodeURIComponent(it.item_id)}`;
1726+ return `<a class="card gk-card gk-hover" data-nav href="${esc(local)}">
1727+ <div class="card-img">${img}
1728+ <button type="button" class="fav-btn on" data-fav="${esc(it.item_id)}" aria-pressed="true"
1729+ aria-label="Retirer de mes favoris (Mon univers Ka)"
1730+ title="Retirer de mes favoris (Mon univers Ka)">${FAV_HEART}</button>
1731+ </div>
1732+ <div class="card-body">
1733+ <div class="card-top"><h3>${esc(it.title)}</h3>${it.price_label?`<span class="badge-price">${esc(it.price_label)}</span>`:""}</div>
1734+ ${it.subtitle?`<div class="where">${esc(it.subtitle)}</div>`:""}
1735+ <div class="src"><span>Mon univers Ka</span><span class="go">Fiche →</span></div>
1736+ </div>
1737+ </a>`;
1738+}
1739+
1740+function renderFavs(){
1741+ if(!FAV.connected){
1742+ $("fav-count").innerHTML = "Connectez-vous pour retrouver vos favoris";
1743+ $("fav-grid").innerHTML = `<div class="empty fav-empty">
1744+ <b>Vos favoris vous suivent partout dans l'univers Ka</b>
1745+ Connectez-vous avec votre KA ID pour ajouter des sorties à vos favoris —
1746+ ils sont synchronisés sur toutes les plateformes du Groupe KA.<br>
1747+ <a class="btn-ka" href="/api/auth/ka/login"><span class="kdot">·Ka</span> Se connecter avec KA ID</a></div>`;
1748+ return;
1749+ }
1750+ const items = FAV.items;
1751+ $("fav-count").innerHTML = items.length
1752+ ? `<b>${items.length.toLocaleString("fr-CA")}</b>&nbsp;favori${items.length>1?"s":""} sur Sorti·Ka`
1753+ : "Aucun favori pour l'instant";
1754+ $("fav-grid").innerHTML = items.map(favCard).join("") ||
1755+ `<div class="empty fav-empty"><b>Aucun favori pour l'instant</b>
1756+ Touchez le cœur ♥ d'un événement pour le garder ici — et le retrouver
1757+ sur toutes les plateformes du Groupe KA.</div>`;
1758+ bindNav($("fav-grid"));
1759+}
1760+
1761+async function showFavoris(){
1762+ document.title = "Mes favoris — Sorti-Ka | Mon univers Ka";
1763+ show("view-favoris");
1764+ window.scrollTo({top:0});
1765+ await favLoad(); // le hub est la source de vérité — relire à l'ouverture
1766+ renderFavs();
1767+}
1768+
1601 /* ---------- vues ---------- */ 1769 /* ---------- vues ---------- */
1602−const VIEWS = ["view-list","view-detail","view-contact","view-stats"]; 1770+const VIEWS = ["view-list","view-detail","view-contact","view-stats","view-favoris"];
1603 function show(id){ 1771 function show(id){
1604 VIEWS.forEach(v => $(v).classList.toggle("hidden", v !== id)); 1772 VIEWS.forEach(v => $(v).classList.toggle("hidden", v !== id));
1605 } 1773 }
@@ -1628,6 +1796,7 @@ function route(){
1628 const c = location.pathname.match(/^\/categorie\/([^/]+)$/); 1796 const c = location.pathname.match(/^\/categorie\/([^/]+)$/);
1629 if(m) showDetail(decodeURIComponent(m[1])); 1797 if(m) showDetail(decodeURIComponent(m[1]));
1630 else if(location.pathname === "/contact") showContact(); 1798 else if(location.pathname === "/contact") showContact();
1799+ else if(location.pathname === "/favoris") showFavoris();
1631 else if(location.pathname === "/stats") showStats(); 1800 else if(location.pathname === "/stats") showStats();
1632 else if(r){ 1801 else if(r){
1633 // page SSR /region/{slug} : la SPA ouvre la recherche filtrée sur la région 1802 // page SSR /region/{slug} : la SPA ouvre la recherche filtrée sur la région
@@ -1659,6 +1828,7 @@ $("back").onclick = () => go("/");
1659 1828
1660 /* ---------- init ---------- */ 1829 /* ---------- init ---------- */
1661 async function init(){ 1830 async function init(){
1831+ favLoad(); // favoris ♥ — repeint les cœurs dès la réponse du hub
1662 const regions = await (await fetch("/api/regions")).json(); 1832 const regions = await (await fetch("/api/regions")).json();
1663 allRegions = regions; 1833 allRegions = regions;
1664 $("region").insertAdjacentHTML("beforeend", 1834 $("region").insertAdjacentHTML("beforeend",
added sortika/hubfav.py +104 −0
@@ -0,0 +1,104 @@
1+# -----------------------------------------------------------------------------
2+# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# hubfav.py : favoris « Mon univers Ka » — le hub Groupe KA (groupe-ka.com)
5+# est le MAGASIN CENTRAL des favoris du groupe : Sorti·Ka ne stocke rien
6+# localement. Chaque ♥ est poussé au hub (POST signé HMAC, synchrone : un
7+# échec remonte à l'appelant) et la liste est lue au hub (GET signé, cache
8+# mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO.
9+# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel).
10+# (patron Food-Ka : foodka/hubfav.py)
11+# -----------------------------------------------------------------------------
12+from __future__ import annotations
13+
14+import hashlib
15+import hmac
16+import os
17+import threading
18+import time
19+
20+import requests
21+
22+CLIENT_ID = "sorti-ka"
23+KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
24+CACHE_TTL = 30 # secondes — liste des favoris
25+TIMEOUT = 6 # secondes
26+
27+_cache: dict[str, tuple[float, list[dict]]] = {}
28+_lock = threading.Lock()
29+
30+# champs d'item acceptés -> longueur maximale (troncature défensive)
31+_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200,
32+ "price_label": 60, "image_url": 500, "url": 500}
33+
34+
35+def _sig(ka_id: str, ts: int) -> str | None:
36+ """Signature HMAC-SHA256 du hub : hex("sorti-ka.<ka_id>.<ts>")."""
37+ secret = os.environ.get("KA_SSO_SECRET")
38+ if not secret:
39+ return None
40+ return hmac.new(secret.encode(),
41+ f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
42+ hashlib.sha256).hexdigest()
43+
44+
45+def linked(ka_id: str | None) -> bool:
46+ """Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe)."""
47+ return bool(ka_id) and str(ka_id).startswith("ka-")
48+
49+
50+def clean_item(item: dict) -> dict:
51+ """Ne garde que les champs d'item connus, en chaînes tronquées."""
52+ return {k: str(item.get(k) or "")[:n]
53+ for k, n in _FIELDS.items() if item.get(k)}
54+
55+
56+def hub_toggle(ka_id: str, action: str, item: dict) -> bool:
57+ """Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s :
58+ le hub est le magasin des favoris, l'échec doit remonter à l'appelant."""
59+ ts = int(time.time())
60+ sig = _sig(ka_id, ts)
61+ if not sig or not linked(ka_id) or action not in ("add", "remove"):
62+ return False
63+ try:
64+ r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
65+ json={"client_id": CLIENT_ID, "ka_id": ka_id,
66+ "ts": str(ts), "sig": sig,
67+ "action": action, "item": item})
68+ ok = r.status_code == 200
69+ except Exception:
70+ ok = False
71+ if ok:
72+ with _lock:
73+ _cache.pop(ka_id, None) # la prochaine lecture reflète le toggle
74+ return ok
75+
76+
77+def hub_list(ka_id: str) -> list[dict] | None:
78+ """Favoris Sorti·Ka du membre, lus au hub (cache mémoire 30 s).
79+ [] = aucun favori ; None = hub injoignable (erreur, jamais mise en cache)."""
80+ if not linked(ka_id):
81+ return [] # compte legacy non relié au hub
82+ now = time.time()
83+ with _lock:
84+ hit = _cache.get(ka_id)
85+ if hit and now - hit[0] < CACHE_TTL:
86+ return hit[1]
87+ sig = _sig(ka_id, int(now))
88+ if not sig:
89+ return None
90+ try:
91+ r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
92+ params={"client_id": CLIENT_ID, "ka_id": ka_id,
93+ "ts": int(now), "sig": sig})
94+ if r.status_code != 200:
95+ return None
96+ favs = r.json().get("favorites") or []
97+ if not isinstance(favs, list):
98+ return None
99+ except Exception:
100+ return None
101+ favs = [f for f in favs if isinstance(f, dict)]
102+ with _lock:
103+ _cache[ka_id] = (now, favs)
104+ return favs
modified sortika/seo.py +22 −0
@@ -376,6 +376,28 @@ def contact_page() -> HTMLResponse:
376 return _render(head, ssr) 376 return _render(head, ssr)
377 377
378 378
379+@router.get("/favoris", include_in_schema=False)
380+def favoris_page() -> HTMLResponse:
381+ """Page /favoris (SPA) — favoris ♥ « Mon univers Ka » du membre connecté.
382+ Page privée (session KA ID) : noindex, absente des sitemaps."""
383+ title = f"Mes favoris — {SITE_NAME} | Mon univers Ka"
384+ desc = ("Vos sorties et événements favoris, synchronisés avec votre "
385+ "compte KA ID sur toutes les plateformes du Groupe KA.")
386+ head = (
387+ f"<title>{_e(title)}</title>\n"
388+ f'<meta name="description" content="{_e(desc)}">\n'
389+ '<meta name="robots" content="noindex">\n'
390+ f'<meta property="og:site_name" content="{SITE_NAME}">\n'
391+ '<meta property="og:locale" content="fr_CA">\n'
392+ + OG_IMAGE_TAGS
393+ )
394+ ssr = ("<article><h1>Mes favoris — Mon univers Ka</h1>"
395+ f"<p>{_e(desc)}</p>"
396+ '<p><a href="/">Toutes les sorties et tous les événements du '
397+ "Québec</a></p></article>")
398+ return _render(head, ssr)
399+
400+
379 @router.get("/stats", include_in_schema=False) 401 @router.get("/stats", include_in_schema=False)
380 def stats_page() -> HTMLResponse: 402 def stats_page() -> HTMLResponse:
381 """Page /stats (SPA) — head SEO + contenu SSR pour les robots.""" 403 """Page /stats (SPA) — head SEO + contenu SSR pour les robots."""
modified sortika/web.py +35 −2
@@ -10,13 +10,13 @@ import json
10 from datetime import date 10 from datetime import date
11 from pathlib import Path 11 from pathlib import Path
12 12
13−from fastapi import Body, FastAPI, HTTPException, Query 13+from fastapi import Body, FastAPI, HTTPException, Query, Request
14 from fastapi.middleware.cors import CORSMiddleware 14 from fastapi.middleware.cors import CORSMiddleware
15 from fastapi.middleware.gzip import GZipMiddleware 15 from fastapi.middleware.gzip import GZipMiddleware
16 from fastapi.responses import FileResponse, Response 16 from fastapi.responses import FileResponse, Response
17 from fastapi.staticfiles import StaticFiles 17 from fastapi.staticfiles import StaticFiles
18 18
19−from . import auth, db, kapdf, seo 19+from . import auth, db, hubfav, kapdf, seo
20 from . import stats as kstats 20 from . import stats as kstats
21 from .normalize import CATEGORIES 21 from .normalize import CATEGORIES
22 from .regions import REGIONS 22 from .regions import REGIONS
@@ -291,6 +291,39 @@ def sources() -> dict:
291 return reg 291 return reg
292 292
293 293
294+# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----
295+@app.get("/api/favorites")
296+def favorites(request: Request):
297+ """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""
298+ user = auth.current_user(request)
299+ if not user:
300+ raise HTTPException(401, "Connexion KA ID requise")
301+ items = hubfav.hub_list(user.get("ka_id") or "")
302+ if items is None:
303+ raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")
304+ return {"ids": [i["item_id"] for i in items if i.get("item_id")],
305+ "items": items}
306+
307+
308+@app.post("/api/favorites/toggle")
309+def toggle_favorite(request: Request, body: dict = Body(...)):
310+ """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub
311+ Groupe KA de façon synchrone : le hub est la seule source de vérité."""
312+ user = auth.current_user(request)
313+ if not user:
314+ raise HTTPException(401, "Connexion KA ID requise")
315+ ka_id = user.get("ka_id") or ""
316+ if not hubfav.linked(ka_id):
317+ raise HTTPException(403, "Compte non relié au hub Groupe KA")
318+ on = bool(body.get("on"))
319+ item = hubfav.clean_item(body.get("item") or {})
320+ if not item.get("item_id"):
321+ raise HTTPException(422, "item.item_id requis")
322+ if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):
323+ raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")
324+ return {"ok": True, "on": on}
325+
326+
294 # --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py --- 327 # --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py ---
295 app.include_router(auth.router) 328 app.include_router(auth.router)
296 329
297 330
Menu

Theme