SPB Git forge

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%

Favoris unifiés « Mon univers Ka » (KA ID) — hub groupe-ka.com

- creaka/hubfav.py : client HMAC du hub (GET liste cache 30 s, POST toggle
  synchrone, aucun stockage local) — magasin central des favoris du groupe
- creaka/web.py : routes GET /api/favorites + POST /api/favorites/toggle
  (session KA ID requise, 401 sinon), déclarées avant le fallback SPA
- frontend : cœur ♥ sur les cartes de créateurs et CTA sur la fiche
  (« Se connecter avec KA ID » si déconnecté), page /favoris (grille des
  favoris lus au hub, retrait en place), nav header + footer + tab bar
  mobile (5 onglets)

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

4 changed files +481 −12

added creaka/hubfav.py +103 −0
@@ -0,0 +1,103 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/hubfav.py
4 +# Desc: Favoris « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) est le
5 +# MAGASIN CENTRAL des favoris du groupe : Créa-Ka ne stocke rien
6 +# localement. Chaque ♥ est poussé au hub (POST signé HMAC, synchrone :
7 +# un échec remonte à l'appelant) et la liste est lue au hub (GET signé,
8 +# cache mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO.
9 +# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel).
10 +# ==============================================================================
11 +from __future__ import annotations
12 +
13 +import hashlib
14 +import hmac
15 +import os
16 +import threading
17 +import time
18 +
19 +import requests
20 +
21 +CLIENT_ID = "crea-ka"
22 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
23 +CACHE_TTL = 30 # secondes — liste des favoris
24 +TIMEOUT = 6 # secondes
25 +
26 +_cache: dict[str, tuple[float, list[dict]]] = {}
27 +_lock = threading.Lock()
28 +
29 +# champs d'item acceptés -> longueur maximale (troncature défensive)
30 +_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200,
31 + "price_label": 60, "image_url": 500, "url": 500}
32 +
33 +
34 +def _sig(ka_id: str, ts: int) -> str | None:
35 + """Signature HMAC-SHA256 du hub : hex("crea-ka.<ka_id>.<ts>")."""
36 + secret = os.environ.get("KA_SSO_SECRET")
37 + if not secret:
38 + return None
39 + return hmac.new(secret.encode(),
40 + f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
41 + hashlib.sha256).hexdigest()
42 +
43 +
44 +def linked(ka_id: str | None) -> bool:
45 + """Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe)."""
46 + return bool(ka_id) and str(ka_id).startswith("ka-")
47 +
48 +
49 +def clean_item(item: dict) -> dict:
50 + """Ne garde que les champs d'item connus, en chaînes tronquées."""
51 + return {k: str(item.get(k) or "")[:n]
52 + for k, n in _FIELDS.items() if item.get(k)}
53 +
54 +
55 +def hub_toggle(ka_id: str, action: str, item: dict) -> bool:
56 + """Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s :
57 + le hub est le magasin des favoris, l'échec doit remonter à l'appelant."""
58 + ts = int(time.time())
59 + sig = _sig(ka_id, ts)
60 + if not sig or not linked(ka_id) or action not in ("add", "remove"):
61 + return False
62 + try:
63 + r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
64 + json={"client_id": CLIENT_ID, "ka_id": ka_id,
65 + "ts": str(ts), "sig": sig,
66 + "action": action, "item": item})
67 + ok = r.status_code == 200
68 + except Exception:
69 + ok = False
70 + if ok:
71 + with _lock:
72 + _cache.pop(ka_id, None) # la prochaine lecture reflète le toggle
73 + return ok
74 +
75 +
76 +def hub_list(ka_id: str) -> list[dict] | None:
77 + """Favoris Créa-Ka du membre, lus au hub (cache mémoire 30 s).
78 + [] = aucun favori ; None = hub injoignable (erreur, jamais mise en cache)."""
79 + if not linked(ka_id):
80 + return [] # compte legacy non relié au hub
81 + now = time.time()
82 + with _lock:
83 + hit = _cache.get(ka_id)
84 + if hit and now - hit[0] < CACHE_TTL:
85 + return hit[1]
86 + sig = _sig(ka_id, int(now))
87 + if not sig:
88 + return None
89 + try:
90 + r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
91 + params={"client_id": CLIENT_ID, "ka_id": ka_id,
92 + "ts": int(now), "sig": sig})
93 + if r.status_code != 200:
94 + return None
95 + favs = r.json().get("favorites") or []
96 + if not isinstance(favs, list):
97 + return None
98 + except Exception:
99 + return None
100 + favs = [f for f in favs if isinstance(f, dict)]
101 + with _lock:
102 + _cache[ka_id] = (now, favs)
103 + return favs
modified creaka/web.py +36 −2
@@ -10,13 +10,13 @@ import json
10 10 import threading
11 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 14 from fastapi.middleware.cors import CORSMiddleware
15 15 from fastapi.middleware.gzip import GZipMiddleware
16 16 from fastapi.responses import FileResponse, Response
17 17 from pydantic import BaseModel, Field
18 18
19 −from . import auth, db, ethics, kapdf, seo
19 +from . import auth, db, ethics, hubfav, kapdf, seo
20 20 from . import stats as stats_mod
21 21 from .normalize import NICHES, PLATFORMS, REGIONS
22 22
@@ -203,6 +203,40 @@ def optout(req: OptOutRequest):
203 203 "message": "Fiche masquée. La demande sera vérifiée ; merci."}
204 204
205 205
206 +# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----
207 +
208 +@app.get("/api/favorites")
209 +def favorites(request: Request):
210 + """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""
211 + user = auth.current_user(request)
212 + if not user:
213 + raise HTTPException(401, "Connexion KA ID requise")
214 + items = hubfav.hub_list(user.get("ka_id") or "")
215 + if items is None:
216 + raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")
217 + return {"ids": [i["item_id"] for i in items if i.get("item_id")],
218 + "items": items}
219 +
220 +
221 +@app.post("/api/favorites/toggle")
222 +def toggle_favorite(request: Request, body: dict = Body(...)):
223 + """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub
224 + Groupe KA de façon synchrone : le hub est la seule source de vérité."""
225 + user = auth.current_user(request)
226 + if not user:
227 + raise HTTPException(401, "Connexion KA ID requise")
228 + ka_id = user.get("ka_id") or ""
229 + if not hubfav.linked(ka_id):
230 + raise HTTPException(403, "Compte non relié au hub Groupe KA")
231 + on = bool(body.get("on"))
232 + item = hubfav.clean_item(body.get("item") or {})
233 + if not item.get("item_id"):
234 + raise HTTPException(422, "item.item_id requis")
235 + if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):
236 + raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")
237 + return {"ok": True, "on": on}
238 +
239 +
206 240 # SEO : fiches créateur SSR, robots.txt, sitemaps — déclaré AVANT
207 241 # les routes statiques pour prendre la main sur le fallback SPA
208 242 app.include_router(seo.router)
modified frontend/dist/index.html +171 −5
@@ -330,7 +330,7 @@ html.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; ove
330 330 / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET
331 331 (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar
332 332 4 onglets, filtres défilants, anti-zoom iOS).
333 − Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact
333 + Routes : / · /createur/{id} · /stats · /favoris · /retrait · /compte · /contact
334 334 -->
335 335 <!DOCTYPE html>
336 336 <html lang="fr-CA">
@@ -871,6 +871,34 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
871 871 box-shadow:2px 2px 0 rgba(20,24,20,.25)}
872 872 .plat svg{width:15px;height:15px;fill:currentColor}
873 873 .plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}
874 +/* ===== favoris ♥ « Mon univers Ka » (hub groupe-ka.com) ===== */
875 +.favbtn{position:absolute;right:10px;bottom:10px;z-index:2;width:36px;height:36px;
876 + border-radius:50%;border:1.5px solid var(--ink);background:var(--surface);
877 + color:var(--ink-2);display:flex;align-items:center;justify-content:center;
878 + padding:0;box-shadow:2px 2px 0 rgba(20,24,20,.25);transition:.15s}
879 +.favbtn svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2;
880 + stroke-linecap:round;stroke-linejoin:round}
881 +.favbtn:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink);color:var(--accent-deep)}
882 +.favbtn:active{transform:translate(1px,1px);box-shadow:none}
883 +.favbtn.on{background:var(--accent);color:var(--on-accent)}
884 +.favbtn.on svg{fill:currentColor}
885 +.favbtn[disabled]{opacity:.55;pointer-events:none}
886 +.fav-cta{display:inline-flex;align-items:center;gap:9px;border:1.5px solid var(--ink);
887 + background:var(--surface);color:var(--ink);border-radius:var(--r-ctl);
888 + padding:10px 18px;min-height:44px;font-family:var(--font-display);font-size:13px;
889 + font-weight:700;transition:.15s;box-shadow:3px 3px 0 rgba(20,24,20,.25);cursor:pointer}
890 +.fav-cta svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;
891 + stroke-linecap:round;stroke-linejoin:round;flex:none}
892 +.fav-cta:hover{background:var(--accent);color:var(--on-accent)}
893 +.fav-cta:active{transform:translate(2px,2px);box-shadow:none}
894 +.fav-cta.on{background:var(--accent);color:var(--on-accent)}
895 +.fav-cta.on svg{fill:currentColor}
896 +.fav-cta[disabled]{opacity:.55;pointer-events:none}
897 +.fav-empty{padding:50px 0;text-align:center;color:var(--ink-3)}
898 +.fav-empty h2{text-transform:uppercase;color:var(--ink);margin-bottom:10px}
899 +.favoris-page{padding:40px 0 60px}
900 +.favoris-page .kicker+h1{font-size:clamp(26px,4.4vw,42px);margin:12px 0 0;text-transform:uppercase}
901 +.favoris-page .lead{color:var(--ink-2);margin:14px 0 0;max-width:620px}
874 902 .more-wrap{display:flex;justify-content:center;padding:16px 0 44px}
875 903 .empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}
876 904 .count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;
@@ -1233,7 +1261,7 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
1233 1261 @media(max-width:480px){.site-item span{display:none}}
1234 1262 .tabbar{display:none}
1235 1263 @media(max-width:940px){
1236 − .tabbar{display:grid;grid-template-columns:repeat(4,1fr);
1264 + .tabbar{display:grid;grid-template-columns:repeat(5,1fr);
1237 1265 position:fixed;left:0;right:0;bottom:0;z-index:var(--z-bottombar,600);
1238 1266 background:rgba(245,243,238,.94);
1239 1267 backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
@@ -1259,7 +1287,7 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
1259 1287 <div id="app"></div>
1260 1288 <script>
1261 1289 "use strict";
1262 −/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */
1290 +/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /favoris · /retrait · /compte · /contact */
1263 1291 const ICONS={"_author":"Simon-Pierre Boucher <contact@spboucher.ai>","_file":"frontend/src/icons.json","_desc":"Trac\u00e9s SVG des marques de plateformes (simple-icons v13, CC0) + glyphes maison (linkedin/site-web/autre)","instagram":"M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077","tiktok":"M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z","youtube":"M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z","twitch":"M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z","kick":"M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z","x":"M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z","facebook":"M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z","snapchat":"M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z","substack":"M22.539 8.242H1.46V5.406h21.08v2.836zM1.46 10.812V24L12 18.11 22.54 24V10.812H1.46zM22.54 0H1.46v2.836h21.08V0z","patreon":"M22.957 7.21c-.004-3.064-2.391-5.576-5.191-6.482-3.478-1.125-8.064-.962-11.384.604C2.357 3.231 1.093 7.391 1.046 11.54c-.039 3.411.302 12.396 5.369 12.46 3.765.047 4.326-4.804 6.068-7.141 1.24-1.662 2.836-2.132 4.801-2.618 3.376-.836 5.678-3.501 5.673-7.031Z","onlyfans":"M24 4.003h-4.015c-3.45 0-5.3.197-6.748 1.957a7.996 7.996 0 1 0 2.103 9.211c3.182-.231 5.39-2.134 6.085-5.173 0 0-2.399.585-4.43 0 4.018-.777 6.333-3.037 7.005-5.995zM5.61 11.999A2.391 2.391 0 0 1 9.28 9.97a2.966 2.966 0 0 1 2.998-2.528h.008c-.92 1.778-1.407 3.352-1.998 5.263A2.392 2.392 0 0 1 5.61 12Zm2.386-7.996a7.996 7.996 0 1 0 7.996 7.996 7.996 7.996 0 0 0-7.996-7.996Zm0 10.394A2.399 2.399 0 1 1 10.395 12a2.396 2.396 0 0 1-2.399 2.398Z","threads":"M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z","podcast":"M5.34 0A5.328 5.328 0 000 5.34v13.32A5.328 5.328 0 005.34 24h13.32A5.328 5.328 0 0024 18.66V5.34A5.328 5.328 0 0018.66 0zm6.525 2.568c2.336 0 4.448.902 6.056 2.587 1.224 1.272 1.912 2.619 2.264 4.392.12.59.12 2.2.007 2.864a8.506 8.506 0 01-3.24 5.296c-.608.46-2.096 1.261-2.336 1.261-.088 0-.096-.091-.056-.46.072-.592.144-.715.48-.856.536-.224 1.448-.874 2.008-1.435a7.644 7.644 0 002.008-3.536c.208-.824.184-2.656-.048-3.504-.728-2.696-2.928-4.792-5.624-5.352-.784-.16-2.208-.16-3 0-2.728.56-4.984 2.76-5.672 5.528-.184.752-.184 2.584 0 3.336.456 1.832 1.64 3.512 3.192 4.512.304.2.672.408.824.472.336.144.408.264.472.856.04.36.03.464-.056.464-.056 0-.464-.176-.896-.384l-.04-.03c-2.472-1.216-4.056-3.274-4.632-6.012-.144-.706-.168-2.392-.03-3.04.36-1.74 1.048-3.1 2.192-4.304 1.648-1.737 3.768-2.656 6.128-2.656zm.134 2.81c.409.004.803.04 1.106.106 2.784.62 4.76 3.408 4.376 6.174-.152 1.114-.536 2.03-1.216 2.88-.336.43-1.152 1.15-1.296 1.15-.023 0-.048-.272-.048-.603v-.605l.416-.496c1.568-1.878 1.456-4.502-.256-6.224-.664-.67-1.432-1.064-2.424-1.246-.64-.118-.776-.118-1.448-.008-1.02.167-1.81.562-2.512 1.256-1.72 1.704-1.832 4.342-.264 6.222l.413.496v.608c0 .336-.027.608-.06.608-.03 0-.264-.16-.512-.36l-.034-.011c-.832-.664-1.568-1.842-1.872-2.997-.184-.698-.184-2.024.008-2.72.504-1.878 1.888-3.335 3.808-4.019.41-.145 1.133-.22 1.814-.211zm-.13 2.99c.31 0 .62.06.844.178.488.253.888.745 1.04 1.259.464 1.578-1.208 2.96-2.72 2.254h-.015c-.712-.331-1.096-.956-1.104-1.77 0-.733.408-1.371 1.112-1.745.224-.117.534-.176.844-.176zm-.011 4.728c.988-.004 1.706.349 1.97.97.198.464.124 1.932-.218 4.302-.232 1.656-.36 2.074-.68 2.356-.44.39-1.064.498-1.656.288h-.003c-.716-.257-.87-.605-1.164-2.644-.341-2.37-.416-3.838-.218-4.302.262-.616.974-.966 1.97-.97z","linktree":"m13.73635 5.85251 4.00467-4.11665 2.3248 2.3808-4.20064 4.00466h5.9085v3.30473h-5.9365l4.22865 4.10766-2.3248 2.3338L12.0005 12.099l-5.74052 5.76852-2.3248-2.3248 4.22864-4.10766h-5.9375V8.12132h5.9085L3.93417 4.11666l2.3248-2.3808 4.00468 4.11665V0h3.4727zm-3.4727 10.30614h3.4727V24h-3.4727z","linkedin":"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z","site-web":"M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm7.938 9h-3.243a15.6 15.6 0 00-1.4-4.653A8.03 8.03 0 0119.938 9zM12 2.05c.9 1.2 1.9 3.2 2.5 6.95h-5C10.1 5.25 11.1 3.25 12 2.05zM4.062 15A8.06 8.06 0 013.75 12c0-1.05.15-2.05.312-3h3.55A25 25 0 007.5 12c0 1.05.05 2.05.113 3zm.643 2h3.243c.35 1.8.85 3.35 1.4 4.653A8.03 8.03 0 014.705 17zm3.243-10H4.705a8.03 8.03 0 014.643-4.653A15.6 15.6 0 007.948 7zM12 21.95c-.9-1.2-1.9-3.2-2.5-6.95h5c-.6 3.75-1.6 5.75-2.5 6.95zM14.787 13H9.213A23 23 0 019.1 12c0-1.05.037-2.05.113-3h5.574c.076.95.113 1.95.113 3s-.037 2.05-.113 3zm.508 8.653c.55-1.303 1.05-2.853 1.4-4.653h3.243a8.03 8.03 0 01-4.643 4.653zM16.388 15c.062-.95.112-1.95.112-3s-.05-2.05-.112-3h3.55c.162.95.312 1.95.312 3s-.15 2.05-.312 3z","autre":"M10.59 13.41a1 1 0 010-1.41l2.83-2.83a3 3 0 114.24 4.24l-1.41 1.42a1 1 0 11-1.42-1.42l1.42-1.41a1 1 0 10-1.42-1.42L12 13.41a1 1 0 01-1.41 0zm2.82-2.82a1 1 0 010 1.41l-2.82 2.83a3 3 0 11-4.25-4.24l1.42-1.42a1 1 0 111.41 1.42L7.76 12a1 1 0 101.41 1.41L12 10.59a1 1 0 011.41 0z","spotify":"M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z","discord":"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"};
1264 1292 /* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */
1265 1293 const KA_ECO={"org":{"name":"Groupe KA","legalName":"Groupe KA — Simon-Pierre Boucher","tagline":"Holding québécois d'agrégateurs de produits et services entièrement automatisés.","disclaimer":"Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction.","copyrightHolder":"Groupe KA — Simon-Pierre Boucher"},"hub":{"url":"https://www.groupe-ka.com","loginPath":"/connexion","signupNote":"La création de compte KA ID se fait sur le hub groupe-ka.com ; chaque site délègue sa connexion via /api/auth/ka/login."},"contacts":[{"email":"contact@groupe-ka.com","role":"Projets, partenariats & données"},{"email":"info@groupe-ka.com","role":"Médias & questions générales"},{"email":"admin@groupe-ka.com","role":"Légal, vie privée & Loi 25"}],"legal":[{"label":"Conditions d'utilisation","href":"https://www.groupe-ka.com/conditions"},{"label":"Politique de confidentialité","href":"https://www.groupe-ka.com/confidentialite"},{"label":"Protection des renseignements personnels (Loi 25)","href":"https://www.groupe-ka.com/loi-25"},{"label":"Transparence des robots d'indexation","href":"https://www.groupe-ka.com/bots"}],"sites":[{"id":"groupe-ka","wordmark":"Groupe KA","domain":"www.groupe-ka.com","accent":"#d9f26b","accentSoft":"#f0f9d2","accentDeep":"#123f2e","onAccent":"#141814","tagline":"Le portail de l'écosystème ·Ka"},{"id":"trouve-ka","wordmark":"Trouve·Ka","domain":"www.trouve-ka.com","accent":"#1c7ed6","accentSoft":"#e7f2fd","accentDeep":"#14508f","onAccent":"#ffffff","tagline":"Le moteur de recherche du web québécois"},{"id":"lou-ka","wordmark":"Lou·Ka","domain":"www.lou-ka.com","accent":"#ff6a00","accentSoft":"#fff1e6","accentDeep":"#cc5500","onAccent":"#ffffff","tagline":"Tous les logements à louer"},{"id":"immo-ka","wordmark":"Immo·Ka","domain":"www.immo-ka.com","accent":"#e23744","accentSoft":"#fbe0e2","accentDeep":"#a8232e","onAccent":"#ffffff","tagline":"Toutes les propriétés à vendre"},{"id":"vrai-prix","wordmark":"Vrai-Prix","domain":"www.vrai-prix.com","accent":"#ff5148","accentSoft":"#ffe3e0","accentDeep":"#9e2a25","onAccent":"#ffffff","tagline":"La valeur réelle de chaque propriété"},{"id":"auto-ka","wordmark":"Auto·Ka","domain":"www.auto-ka.com","accent":"#ff5a2a","accentSoft":"#ffe8de","accentDeep":"#cc3f16","onAccent":"#ffffff","tagline":"Les voitures usagées du Québec"},{"id":"fabri-ka","wordmark":"Fabri·Ka","domain":"www.fabri-ka.com","accent":"#c4532e","accentSoft":"#f7e3da","accentDeep":"#a94525","onAccent":"#ffffff","tagline":"Les produits fabriqués au Québec"},{"id":"food-ka","wordmark":"Food·Ka","domain":"www.food-ka.com","accent":"#1f9d55","accentSoft":"#e2f5ea","accentDeep":"#157a40","onAccent":"#ffffff","tagline":"Les prix d'épicerie, suivis à la source"},{"id":"resto-ka","wordmark":"Resto·Ka","domain":"www.resto-ka.com","accent":"#f08c00","accentSoft":"#fdeed7","accentDeep":"#b96a00","onAccent":"#141814","tagline":"Chaque resto, chaque plat, chaque prix"},{"id":"sorti-ka","wordmark":"Sorti·Ka","domain":"www.sorti-ka.com","accent":"#d6336c","accentSoft":"#fbe0eb","accentDeep":"#a12551","onAccent":"#ffffff","tagline":"Toutes les sorties, dans les 17 régions"},{"id":"crea-ka","wordmark":"Créa·Ka","domain":"www.crea-ka.com","accent":"#7048e8","accentSoft":"#ece5fc","accentDeep":"#5433b8","onAccent":"#ffffff","tagline":"Les créateurs d'ici, tous leurs liens"},{"id":"api-ka","wordmark":"API·Ka","domain":"www.api-ka.com","accent":"#3b5bdb","accentSoft":"#e4eafb","accentDeep":"#2b44a8","onAccent":"#ffffff","tagline":"La donnée de l'écosystème, par API"},{"id":"job-ka","wordmark":"Job·Ka","domain":"www.job-ka.com","accent":"#0c8599","accentSoft":"#def0f4","accentDeep":"#095c6b","onAccent":"#ffffff","tagline":"Tous les emplois des employeurs québécois"}],"extraFooterLinks":[{"label":"ValoPlex","href":"https://www.valoplex.com"},{"label":"Ka2","href":"https://www.ka2.bot"},{"label":"Ka4","href":"https://www.ka4.bot"},{"label":"Ka6","href":"https://www.ka6.bot"}]};
@@ -1305,6 +1333,63 @@ async function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);
1305 1333 function icon(p,extra){return `<svg class="icn" viewBox="0 0 24 24" aria-hidden="true"${extra||""}><path d="${ICONS[p]||ICONS.autre}"/></svg>`}
1306 1334 const checkSvg=`<svg class="vbadge" viewBox="0 0 24 24" aria-label="vérifié"><path fill="currentColor" d="M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z"/><path fill="#f5f3ee" d="M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z"/></svg>`;
1307 1335
1336 +/* ---------- favoris ♥ « Mon univers Ka » — magasin central : hub groupe-ka.com
1337 + (AUCUN stockage local : toggle synchrone via /api/favorites/toggle, lecture
1338 + /api/favorites — cache serveur 30 s). item_id = id stable du créateur. ---------- */
1339 +const heartSvg=`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5S4.5 15.7 2.6 11.4C1.2 8.2 2.9 4.9 6.1 4.4c2-.3 3.9.6 5.9 2.9 2-2.3 3.9-3.2 5.9-2.9 3.2.5 4.9 3.8 3.5 7-1.9 4.3-9.4 9.1-9.4 9.1z"/></svg>`;
1340 +const FAV={ids:new Set(),loaded:false,src:{}};
1341 +function favItem(c){
1342 + const main=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];
1343 + const sub=main?(PLAT[main.platform]||PLAT.autre).label:(c.region||"");
1344 + return {item_id:c.id,title:c.display_name||c.id,
1345 + subtitle:sub,image_url:avatarSources(c)[0]||"",
1346 + url:"https://www.crea-ka.com/createur/"+encodeURIComponent(c.id)};
1347 +}
1348 +function favBtn(c){
1349 + FAV.src[c.id]=favItem(c);
1350 + const on=FAV.ids.has(c.id);
1351 + return `<button type="button" class="favbtn${on?" on":""}" data-fav="${esc(c.id)}"
1352 + aria-pressed="${on}" aria-label="${on?"Retirer de mes favoris":"Ajouter à mes favoris"}"
1353 + title="${on?"Retirer de mes favoris":"Ajouter à mes favoris"}"
1354 + onclick="event.stopPropagation();favToggle(this.dataset.fav,this)">${heartSvg}</button>`;
1355 +}
1356 +function favPaint(){
1357 + document.querySelectorAll("[data-fav]").forEach(b=>{
1358 + const on=FAV.ids.has(b.dataset.fav);
1359 + b.classList.toggle("on",on);b.setAttribute("aria-pressed",on);
1360 + const lbl=on?"Retirer de mes favoris":"Ajouter à mes favoris";
1361 + b.title=lbl;b.setAttribute("aria-label",lbl);
1362 + const t=b.querySelector(".fav-cta-txt");if(t)t.textContent=lbl;
1363 + });
1364 +}
1365 +async function favLoad(){
1366 + if(FAV.loaded||!state.me)return favPaint();
1367 + try{const r=await fetch("/api/favorites");
1368 + if(r.ok){const j=await r.json();FAV.ids=new Set(j.ids||[]);FAV.loaded=true;}
1369 + }catch(e){}
1370 + favPaint();
1371 +}
1372 +window.favToggle=async(id,el)=>{
1373 + if(state.me===null){location.href="/api/auth/ka/login";return}
1374 + const on=!FAV.ids.has(id);
1375 + if(el)el.disabled=true;
1376 + try{
1377 + const r=await fetch("/api/favorites/toggle",{method:"POST",
1378 + headers:{"Content-Type":"application/json"},
1379 + body:JSON.stringify({on,item:FAV.src[id]||{item_id:id}})});
1380 + if(r.status===401){location.href="/api/auth/ka/login";return}
1381 + if(!r.ok)throw new Error(r.status);
1382 + if(on)FAV.ids.add(id);else FAV.ids.delete(id);
1383 + if(!on&&location.pathname==="/favoris"&&el){
1384 + const card=el.closest(".card");if(card)card.remove();
1385 + const grid=$("#fav-grid");
1386 + if(grid&&!grid.querySelector(".card"))renderFavoris();
1387 + }
1388 + }catch(e){alert("Impossible de synchroniser le favori avec le hub Groupe KA — réessayez.")}
1389 + if(el)el.disabled=false;
1390 + favPaint();
1391 +};
1392 +
1308 1393 /* cascade de photos : avatar capté à la source, puis unavatar par plateforme,
1309 1394 repli final = initiales sur motif encre/violet (déjà en dessous) */
1310 1395 function avatarSources(c){
@@ -1338,6 +1423,7 @@ function header(){
1338 1423 <span class="brand-tag">Tous les créateurs québécois.<br>Tous leurs liens.</span>
1339 1424 <span class="header-spacer"></span>
1340 1425 <a class="hbtn" href="/stats" data-nav>Statistiques</a>
1426 + <a class="hbtn" href="/favoris" data-nav>♥ Favoris</a>
1341 1427 <a class="hbtn" href="/contact" data-nav>Contact</a>
1342 1428 <a class="hbtn primary" href="/#annuaire" data-nav>Explorer l'annuaire</a>
1343 1429 <span id="acct-slot"></span>
@@ -1359,12 +1445,29 @@ async function loadAccount(){
1359 1445 title="Se connecter avec KA ID"><span class="acct-long">Se connecter avec</span><span class="acct-short">Connexion</span> <span class="ka-badge">KA</span></a>`;
1360 1446 }
1361 1447 bindNav(slot);
1448 + if(me){favSlotPaint();favLoad();}
1449 + else{FAV.ids=new Set();FAV.loaded=false;favSlotPaint();favPaint();}
1450 +}
1451 +/* CTA favori de la fiche créateur : ♥ si connecté, sinon « Se connecter avec KA ID » */
1452 +function favSlotPaint(){
1453 + const s=document.getElementById("fav-slot");if(!s)return;
1454 + const c=state.creator;if(!c)return;
1455 + if(state.me){
1456 + FAV.src[c.id]=favItem(c);
1457 + const on=FAV.ids.has(c.id);
1458 + s.innerHTML=`<button type="button" class="fav-cta${on?" on":""}" data-fav="${esc(c.id)}"
1459 + aria-pressed="${on}" onclick="favToggle(this.dataset.fav,this)">${heartSvg}<span class="fav-cta-txt">${on?"Retirer de mes favoris":"Ajouter à mes favoris"}</span></button>`;
1460 + }else if(state.me===null){
1461 + s.innerHTML=`<a class="fav-cta" href="/api/auth/ka/login"
1462 + title="Un seul compte pour tout le Groupe KA — favoris partagés « Mon univers Ka »">${heartSvg}<span>Se connecter avec KA ID</span></a>`;
1463 + }
1362 1464 }
1363 1465 function tabbar(active){
1364 1466 const t=(href,label,svg,key)=>`<a href="${href}" data-nav class="${active===key?"active":""}">${svg}<span>${label}</span></a>`;
1365 1467 return `<nav class="tabbar">
1366 1468 ${t("/","Annuaire",`<svg viewBox="0 0 24 24"><path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/></svg>`,"home")}
1367 1469 ${t("/#annuaire","Recherche",`<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>`,"search")}
1470 + ${t("/favoris","Favoris",`<svg viewBox="0 0 24 24"><path d="M12 20.5S4.5 15.7 2.6 11.4C1.2 8.2 2.9 4.9 6.1 4.4c2-.3 3.9.6 5.9 2.9 2-2.3 3.9-3.2 5.9-2.9 3.2.5 4.9 3.8 3.5 7-1.9 4.3-9.4 9.1-9.4 9.1z"/></svg>`,"fav")}
1368 1471 ${t("/stats","Stats",`<svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>`,"stats")}
1369 1472 ${t("/retrait","Retrait",`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>`,"optout")}
1370 1473 </nav>`;
@@ -1378,6 +1481,7 @@ function footer(){
1378 1481 return `<div class="footer-local"><div class="container"><nav>
1379 1482 <span class="klabel">Créa·Ka</span>
1380 1483 <a href="/compte" data-nav>Mon compte KA ID</a>
1484 + <a href="/favoris" data-nav>Mes favoris</a>
1381 1485 <a href="/stats" data-nav>Statistiques</a>
1382 1486 <a href="/retrait" data-nav>Demander un retrait</a>
1383 1487 <a href="/contact" data-nav>Contact</a>
@@ -1421,7 +1525,7 @@ function cardHtml(c){
1421 1525 return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer">
1422 1526 <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)}
1423 1527 <span class="tier ${esc(c.audience_tier)}">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>
1424 − <span class="nplat">${np} plat.</span></div>
1528 + <span class="nplat">${np} plat.</span>${favBtn(c)}</div>
1425 1529 <div class="card-body">
1426 1530 <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div>
1427 1531 <div class="card-sub">${c.region?`${esc(c.region)} · `:""}${(c.languages||[]).map(l=>({fr:"FR",en:"EN",bilingue:"FR/EN"}[l]||l)).join(", ")}</div>
@@ -1661,6 +1765,7 @@ async function renderCreator(id){
1661 1765 let c;try{c=await api("/api/creators/"+encodeURIComponent(id))}
1662 1766 catch(e){app.innerHTML=header()+`<div class="container"><div class="empty"><h2>Créateur introuvable</h2>
1663 1767 <p style="margin-top:10px"><a href="/" data-nav style="color:var(--green);font-weight:600">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar("");bindNav();return}
1768 + state.creator=c;
1664 1769 document.title=`${c.display_name} — Créa-Ka`;
1665 1770 const verified=(c.platforms||[]).some(a=>a.verified);
1666 1771 const niches=(c.niches||[]).map(n=>`<span class="mchip">${NICHE_LBL[n]||n}</span>`).join("");
@@ -1727,7 +1832,8 @@ async function renderCreator(id){
1727 1832 <div class="meta"><span class="mchip o">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>
1728 1833 ${niches}${c.region?`<span class="mchip">${esc(c.region)}</span>`:""}
1729 1834 ${(c.languages||[]).map(l=>`<span class="mchip">${{fr:"Français",en:"Anglais",bilingue:"Bilingue"}[l]||l}</span>`).join("")}</div>
1730 − ${c.bio?`<p class="cpage-bio">${esc(c.bio)}</p>`:""}</div>
1835 + ${c.bio?`<p class="cpage-bio">${esc(c.bio)}</p>`:""}
1836 + <div id="fav-slot" style="margin-top:16px"></div></div>
1731 1837 </div>
1732 1838 <div class="hkpi-row">${heroKpis}</div>
1733 1839 ${c.notes?`<div class="cpage-notes"><span class="klabel">Contexte public</span><br>${esc(c.notes)}</div>`:""}
@@ -2268,6 +2374,65 @@ async function renderCompte(){
2268 2374 state.me=null;history.pushState(null,"","/");route();});
2269 2375 }
2270 2376
2377 +/* ---------- page favoris — « Mon univers Ka » (hub groupe-ka.com) ---------- */
2378 +function favCardHtml(it){
2379 + const href=(it.url||"").replace(/^https?:\/\/(www\.)?crea-ka\.com/i,"")
2380 + ||("/createur/"+encodeURIComponent(it.item_id));
2381 + FAV.src[it.item_id]={item_id:it.item_id,title:it.title||"",subtitle:it.subtitle||"",
2382 + image_url:it.image_url||"",url:it.url||""};
2383 + return `<div class="card" data-href="${esc(href)}" role="link" tabindex="0" style="cursor:pointer">
2384 + <div class="card-photo"><span class="init">${esc(initials(it.title||"?"))}</span>
2385 + ${it.image_url?`<img loading="lazy" alt="${esc(it.title)}" src="${esc(it.image_url)}" onerror="this.remove()">`:""}
2386 + <button type="button" class="favbtn on" data-fav="${esc(it.item_id)}" aria-pressed="true"
2387 + aria-label="Retirer de mes favoris" title="Retirer de mes favoris"
2388 + onclick="event.stopPropagation();favToggle(this.dataset.fav,this)">${heartSvg}</button></div>
2389 + <div class="card-body">
2390 + <div class="card-name">${esc(it.title||it.item_id)}</div>
2391 + ${it.subtitle?`<div class="card-sub">${esc(it.subtitle)}</div>`:""}
2392 + </div></div>`;
2393 +}
2394 +async function renderFavoris(){
2395 + document.title="Mes favoris — Créa-Ka";
2396 + app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("fav");bindNav();
2397 + if(state.me===undefined){
2398 + try{const r=await fetch("/api/me");state.me=r.ok?await r.json():null}
2399 + catch(e){state.me=null}}
2400 + const shell=inner=>{app.innerHTML=header()+`
2401 + <div class="container favoris-page">
2402 + <span class="kicker">Mon univers Ka — favoris partagés</span>
2403 + <h1>Mes <span class="hl">favoris.</span></h1>
2404 + <p class="lead">Vos créateurs favoris, gardés dans votre compte KA ID sur groupe-ka.com :
2405 + les mêmes favoris vous suivent sur toutes les plateformes du Groupe KA.</p>
2406 + ${inner}
2407 + </div>`+footer()+tabbar("fav");bindNav();};
2408 + if(!state.me){
2409 + shell(`<div class="fav-empty" style="text-align:left;padding:34px 0">
2410 + <div class="compte-actions" style="margin-top:0">
2411 + <a class="btn btn-primary" href="/api/auth/ka/login">Se connecter avec KA ID</a>
2412 + <a class="btn btn-ghost" href="https://www.groupe-ka.com/connexion" target="_blank" rel="noopener">Créer un compte KA ID ↗</a>
2413 + </div>
2414 + <p style="font-size:12.5px;color:var(--ink-3);margin-top:16px">Un seul compte pour tout le
2415 + groupe — vos favoris sont entreposés au hub groupe-ka.com, jamais ici.</p></div>`);
2416 + loadAccount();return;
2417 + }
2418 + let data=null;
2419 + try{const r=await fetch("/api/favorites");if(r.ok)data=await r.json();}catch(e){}
2420 + if(!data){
2421 + shell(`<div class="fav-empty"><h2>Hub injoignable</h2>
2422 + <p>Impossible de lire vos favoris au hub Groupe KA pour le moment — réessayez dans un instant.</p></div>`);
2423 + loadAccount();return;
2424 + }
2425 + FAV.ids=new Set(data.ids||[]);FAV.loaded=true;
2426 + const items=(data.items||[]).filter(i=>i.item_id);
2427 + shell(items.length?`
2428 + <div class="count-line" style="padding-top:26px"><b>${fmtFull.format(items.length)}</b> créateur${items.length>1?"s":""} en favori</div>
2429 + <div class="grid" id="fav-grid">${items.map(favCardHtml).join("")}</div>`
2430 + :`<div class="fav-empty"><h2>Aucun favori pour l'instant</h2>
2431 + <p>Touchez le ♥ sur une carte ou une fiche de créateur pour l'ajouter à votre univers Ka.</p>
2432 + <p style="margin-top:16px"><a class="btn btn-primary" href="/#annuaire" data-nav>Explorer l'annuaire</a></p></div>`);
2433 + loadAccount();window.scrollTo(0,0);
2434 +}
2435 +
2271 2436 /* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */
2272 2437 function renderContact(){
2273 2438 document.title="Contact — Créa-Ka, un service Groupe KA";
@@ -2352,6 +2517,7 @@ function route(){
2352 2517 if(m)r=renderCreator(decodeURIComponent(m[1]));
2353 2518 else if(pl){state.plateforme=decodeURIComponent(pl[1]);r=renderHome();}
2354 2519 else if(p==="/retrait")r=renderOptout();
2520 + else if(p==="/favoris")r=renderFavoris();
2355 2521 else if(p==="/stats")r=renderStats();
2356 2522 else if(p==="/compte")r=renderCompte();
2357 2523 else if(p==="/contact")r=renderContact();
modified frontend/src/index.template.html +171 −5
@@ -11,7 +11,7 @@
11 11 / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET
12 12 (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar
13 13 4 onglets, filtres défilants, anti-zoom iOS).
14 − Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact
14 + Routes : / · /createur/{id} · /stats · /favoris · /retrait · /compte · /contact
15 15 -->
16 16 <!DOCTYPE html>
17 17 <html lang="fr-CA">
@@ -233,6 +233,34 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
233 233 box-shadow:2px 2px 0 rgba(20,24,20,.25)}
234 234 .plat svg{width:15px;height:15px;fill:currentColor}
235 235 .plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}
236 +/* ===== favoris ♥ « Mon univers Ka » (hub groupe-ka.com) ===== */
237 +.favbtn{position:absolute;right:10px;bottom:10px;z-index:2;width:36px;height:36px;
238 + border-radius:50%;border:1.5px solid var(--ink);background:var(--surface);
239 + color:var(--ink-2);display:flex;align-items:center;justify-content:center;
240 + padding:0;box-shadow:2px 2px 0 rgba(20,24,20,.25);transition:.15s}
241 +.favbtn svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2;
242 + stroke-linecap:round;stroke-linejoin:round}
243 +.favbtn:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink);color:var(--accent-deep)}
244 +.favbtn:active{transform:translate(1px,1px);box-shadow:none}
245 +.favbtn.on{background:var(--accent);color:var(--on-accent)}
246 +.favbtn.on svg{fill:currentColor}
247 +.favbtn[disabled]{opacity:.55;pointer-events:none}
248 +.fav-cta{display:inline-flex;align-items:center;gap:9px;border:1.5px solid var(--ink);
249 + background:var(--surface);color:var(--ink);border-radius:var(--r-ctl);
250 + padding:10px 18px;min-height:44px;font-family:var(--font-display);font-size:13px;
251 + font-weight:700;transition:.15s;box-shadow:3px 3px 0 rgba(20,24,20,.25);cursor:pointer}
252 +.fav-cta svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;
253 + stroke-linecap:round;stroke-linejoin:round;flex:none}
254 +.fav-cta:hover{background:var(--accent);color:var(--on-accent)}
255 +.fav-cta:active{transform:translate(2px,2px);box-shadow:none}
256 +.fav-cta.on{background:var(--accent);color:var(--on-accent)}
257 +.fav-cta.on svg{fill:currentColor}
258 +.fav-cta[disabled]{opacity:.55;pointer-events:none}
259 +.fav-empty{padding:50px 0;text-align:center;color:var(--ink-3)}
260 +.fav-empty h2{text-transform:uppercase;color:var(--ink);margin-bottom:10px}
261 +.favoris-page{padding:40px 0 60px}
262 +.favoris-page .kicker+h1{font-size:clamp(26px,4.4vw,42px);margin:12px 0 0;text-transform:uppercase}
263 +.favoris-page .lead{color:var(--ink-2);margin:14px 0 0;max-width:620px}
236 264 .more-wrap{display:flex;justify-content:center;padding:16px 0 44px}
237 265 .empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}
238 266 .count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;
@@ -595,7 +623,7 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
595 623 @media(max-width:480px){.site-item span{display:none}}
596 624 .tabbar{display:none}
597 625 @media(max-width:940px){
598 − .tabbar{display:grid;grid-template-columns:repeat(4,1fr);
626 + .tabbar{display:grid;grid-template-columns:repeat(5,1fr);
599 627 position:fixed;left:0;right:0;bottom:0;z-index:var(--z-bottombar,600);
600 628 background:rgba(245,243,238,.94);
601 629 backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
@@ -621,7 +649,7 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
621 649 <div id="app"></div>
622 650 <script>
623 651 "use strict";
624 −/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */
652 +/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /favoris · /retrait · /compte · /contact */
625 653 const ICONS=__ICONS__;
626 654 /* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */
627 655 const KA_ECO=__KA_ECO__;
@@ -667,6 +695,63 @@ async function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);
667 695 function icon(p,extra){return `<svg class="icn" viewBox="0 0 24 24" aria-hidden="true"${extra||""}><path d="${ICONS[p]||ICONS.autre}"/></svg>`}
668 696 const checkSvg=`<svg class="vbadge" viewBox="0 0 24 24" aria-label="vérifié"><path fill="currentColor" d="M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z"/><path fill="#f5f3ee" d="M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z"/></svg>`;
669 697
698 +/* ---------- favoris ♥ « Mon univers Ka » — magasin central : hub groupe-ka.com
699 + (AUCUN stockage local : toggle synchrone via /api/favorites/toggle, lecture
700 + /api/favorites — cache serveur 30 s). item_id = id stable du créateur. ---------- */
701 +const heartSvg=`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5S4.5 15.7 2.6 11.4C1.2 8.2 2.9 4.9 6.1 4.4c2-.3 3.9.6 5.9 2.9 2-2.3 3.9-3.2 5.9-2.9 3.2.5 4.9 3.8 3.5 7-1.9 4.3-9.4 9.1-9.4 9.1z"/></svg>`;
702 +const FAV={ids:new Set(),loaded:false,src:{}};
703 +function favItem(c){
704 + const main=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];
705 + const sub=main?(PLAT[main.platform]||PLAT.autre).label:(c.region||"");
706 + return {item_id:c.id,title:c.display_name||c.id,
707 + subtitle:sub,image_url:avatarSources(c)[0]||"",
708 + url:"https://www.crea-ka.com/createur/"+encodeURIComponent(c.id)};
709 +}
710 +function favBtn(c){
711 + FAV.src[c.id]=favItem(c);
712 + const on=FAV.ids.has(c.id);
713 + return `<button type="button" class="favbtn${on?" on":""}" data-fav="${esc(c.id)}"
714 + aria-pressed="${on}" aria-label="${on?"Retirer de mes favoris":"Ajouter à mes favoris"}"
715 + title="${on?"Retirer de mes favoris":"Ajouter à mes favoris"}"
716 + onclick="event.stopPropagation();favToggle(this.dataset.fav,this)">${heartSvg}</button>`;
717 +}
718 +function favPaint(){
719 + document.querySelectorAll("[data-fav]").forEach(b=>{
720 + const on=FAV.ids.has(b.dataset.fav);
721 + b.classList.toggle("on",on);b.setAttribute("aria-pressed",on);
722 + const lbl=on?"Retirer de mes favoris":"Ajouter à mes favoris";
723 + b.title=lbl;b.setAttribute("aria-label",lbl);
724 + const t=b.querySelector(".fav-cta-txt");if(t)t.textContent=lbl;
725 + });
726 +}
727 +async function favLoad(){
728 + if(FAV.loaded||!state.me)return favPaint();
729 + try{const r=await fetch("/api/favorites");
730 + if(r.ok){const j=await r.json();FAV.ids=new Set(j.ids||[]);FAV.loaded=true;}
731 + }catch(e){}
732 + favPaint();
733 +}
734 +window.favToggle=async(id,el)=>{
735 + if(state.me===null){location.href="/api/auth/ka/login";return}
736 + const on=!FAV.ids.has(id);
737 + if(el)el.disabled=true;
738 + try{
739 + const r=await fetch("/api/favorites/toggle",{method:"POST",
740 + headers:{"Content-Type":"application/json"},
741 + body:JSON.stringify({on,item:FAV.src[id]||{item_id:id}})});
742 + if(r.status===401){location.href="/api/auth/ka/login";return}
743 + if(!r.ok)throw new Error(r.status);
744 + if(on)FAV.ids.add(id);else FAV.ids.delete(id);
745 + if(!on&&location.pathname==="/favoris"&&el){
746 + const card=el.closest(".card");if(card)card.remove();
747 + const grid=$("#fav-grid");
748 + if(grid&&!grid.querySelector(".card"))renderFavoris();
749 + }
750 + }catch(e){alert("Impossible de synchroniser le favori avec le hub Groupe KA — réessayez.")}
751 + if(el)el.disabled=false;
752 + favPaint();
753 +};
754 +
670 755 /* cascade de photos : avatar capté à la source, puis unavatar par plateforme,
671 756 repli final = initiales sur motif encre/violet (déjà en dessous) */
672 757 function avatarSources(c){
@@ -700,6 +785,7 @@ function header(){
700 785 <span class="brand-tag">Tous les créateurs québécois.<br>Tous leurs liens.</span>
701 786 <span class="header-spacer"></span>
702 787 <a class="hbtn" href="/stats" data-nav>Statistiques</a>
788 + <a class="hbtn" href="/favoris" data-nav>♥ Favoris</a>
703 789 <a class="hbtn" href="/contact" data-nav>Contact</a>
704 790 <a class="hbtn primary" href="/#annuaire" data-nav>Explorer l'annuaire</a>
705 791 <span id="acct-slot"></span>
@@ -721,12 +807,29 @@ async function loadAccount(){
721 807 title="Se connecter avec KA ID"><span class="acct-long">Se connecter avec</span><span class="acct-short">Connexion</span> <span class="ka-badge">KA</span></a>`;
722 808 }
723 809 bindNav(slot);
810 + if(me){favSlotPaint();favLoad();}
811 + else{FAV.ids=new Set();FAV.loaded=false;favSlotPaint();favPaint();}
812 +}
813 +/* CTA favori de la fiche créateur : ♥ si connecté, sinon « Se connecter avec KA ID » */
814 +function favSlotPaint(){
815 + const s=document.getElementById("fav-slot");if(!s)return;
816 + const c=state.creator;if(!c)return;
817 + if(state.me){
818 + FAV.src[c.id]=favItem(c);
819 + const on=FAV.ids.has(c.id);
820 + s.innerHTML=`<button type="button" class="fav-cta${on?" on":""}" data-fav="${esc(c.id)}"
821 + aria-pressed="${on}" onclick="favToggle(this.dataset.fav,this)">${heartSvg}<span class="fav-cta-txt">${on?"Retirer de mes favoris":"Ajouter à mes favoris"}</span></button>`;
822 + }else if(state.me===null){
823 + s.innerHTML=`<a class="fav-cta" href="/api/auth/ka/login"
824 + title="Un seul compte pour tout le Groupe KA — favoris partagés « Mon univers Ka »">${heartSvg}<span>Se connecter avec KA ID</span></a>`;
825 + }
724 826 }
725 827 function tabbar(active){
726 828 const t=(href,label,svg,key)=>`<a href="${href}" data-nav class="${active===key?"active":""}">${svg}<span>${label}</span></a>`;
727 829 return `<nav class="tabbar">
728 830 ${t("/","Annuaire",`<svg viewBox="0 0 24 24"><path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/></svg>`,"home")}
729 831 ${t("/#annuaire","Recherche",`<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>`,"search")}
832 + ${t("/favoris","Favoris",`<svg viewBox="0 0 24 24"><path d="M12 20.5S4.5 15.7 2.6 11.4C1.2 8.2 2.9 4.9 6.1 4.4c2-.3 3.9.6 5.9 2.9 2-2.3 3.9-3.2 5.9-2.9 3.2.5 4.9 3.8 3.5 7-1.9 4.3-9.4 9.1-9.4 9.1z"/></svg>`,"fav")}
730 833 ${t("/stats","Stats",`<svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>`,"stats")}
731 834 ${t("/retrait","Retrait",`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>`,"optout")}
732 835 </nav>`;
@@ -740,6 +843,7 @@ function footer(){
740 843 return `<div class="footer-local"><div class="container"><nav>
741 844 <span class="klabel">Créa·Ka</span>
742 845 <a href="/compte" data-nav>Mon compte KA ID</a>
846 + <a href="/favoris" data-nav>Mes favoris</a>
743 847 <a href="/stats" data-nav>Statistiques</a>
744 848 <a href="/retrait" data-nav>Demander un retrait</a>
745 849 <a href="/contact" data-nav>Contact</a>
@@ -783,7 +887,7 @@ function cardHtml(c){
783 887 return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer">
784 888 <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)}
785 889 <span class="tier ${esc(c.audience_tier)}">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>
786 − <span class="nplat">${np} plat.</span></div>
890 + <span class="nplat">${np} plat.</span>${favBtn(c)}</div>
787 891 <div class="card-body">
788 892 <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div>
789 893 <div class="card-sub">${c.region?`${esc(c.region)} · `:""}${(c.languages||[]).map(l=>({fr:"FR",en:"EN",bilingue:"FR/EN"}[l]||l)).join(", ")}</div>
@@ -1023,6 +1127,7 @@ async function renderCreator(id){
1023 1127 let c;try{c=await api("/api/creators/"+encodeURIComponent(id))}
1024 1128 catch(e){app.innerHTML=header()+`<div class="container"><div class="empty"><h2>Créateur introuvable</h2>
1025 1129 <p style="margin-top:10px"><a href="/" data-nav style="color:var(--green);font-weight:600">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar("");bindNav();return}
1130 + state.creator=c;
1026 1131 document.title=`${c.display_name} — Créa-Ka`;
1027 1132 const verified=(c.platforms||[]).some(a=>a.verified);
1028 1133 const niches=(c.niches||[]).map(n=>`<span class="mchip">${NICHE_LBL[n]||n}</span>`).join("");
@@ -1089,7 +1194,8 @@ async function renderCreator(id){
1089 1194 <div class="meta"><span class="mchip o">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>
1090 1195 ${niches}${c.region?`<span class="mchip">${esc(c.region)}</span>`:""}
1091 1196 ${(c.languages||[]).map(l=>`<span class="mchip">${{fr:"Français",en:"Anglais",bilingue:"Bilingue"}[l]||l}</span>`).join("")}</div>
1092 − ${c.bio?`<p class="cpage-bio">${esc(c.bio)}</p>`:""}</div>
1197 + ${c.bio?`<p class="cpage-bio">${esc(c.bio)}</p>`:""}
1198 + <div id="fav-slot" style="margin-top:16px"></div></div>
1093 1199 </div>
1094 1200 <div class="hkpi-row">${heroKpis}</div>
1095 1201 ${c.notes?`<div class="cpage-notes"><span class="klabel">Contexte public</span><br>${esc(c.notes)}</div>`:""}
@@ -1630,6 +1736,65 @@ async function renderCompte(){
1630 1736 state.me=null;history.pushState(null,"","/");route();});
1631 1737 }
1632 1738
1739 +/* ---------- page favoris — « Mon univers Ka » (hub groupe-ka.com) ---------- */
1740 +function favCardHtml(it){
1741 + const href=(it.url||"").replace(/^https?:\/\/(www\.)?crea-ka\.com/i,"")
1742 + ||("/createur/"+encodeURIComponent(it.item_id));
1743 + FAV.src[it.item_id]={item_id:it.item_id,title:it.title||"",subtitle:it.subtitle||"",
1744 + image_url:it.image_url||"",url:it.url||""};
1745 + return `<div class="card" data-href="${esc(href)}" role="link" tabindex="0" style="cursor:pointer">
1746 + <div class="card-photo"><span class="init">${esc(initials(it.title||"?"))}</span>
1747 + ${it.image_url?`<img loading="lazy" alt="${esc(it.title)}" src="${esc(it.image_url)}" onerror="this.remove()">`:""}
1748 + <button type="button" class="favbtn on" data-fav="${esc(it.item_id)}" aria-pressed="true"
1749 + aria-label="Retirer de mes favoris" title="Retirer de mes favoris"
1750 + onclick="event.stopPropagation();favToggle(this.dataset.fav,this)">${heartSvg}</button></div>
1751 + <div class="card-body">
1752 + <div class="card-name">${esc(it.title||it.item_id)}</div>
1753 + ${it.subtitle?`<div class="card-sub">${esc(it.subtitle)}</div>`:""}
1754 + </div></div>`;
1755 +}
1756 +async function renderFavoris(){
1757 + document.title="Mes favoris — Créa-Ka";
1758 + app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("fav");bindNav();
1759 + if(state.me===undefined){
1760 + try{const r=await fetch("/api/me");state.me=r.ok?await r.json():null}
1761 + catch(e){state.me=null}}
1762 + const shell=inner=>{app.innerHTML=header()+`
1763 + <div class="container favoris-page">
1764 + <span class="kicker">Mon univers Ka — favoris partagés</span>
1765 + <h1>Mes <span class="hl">favoris.</span></h1>
1766 + <p class="lead">Vos créateurs favoris, gardés dans votre compte KA ID sur groupe-ka.com :
1767 + les mêmes favoris vous suivent sur toutes les plateformes du Groupe KA.</p>
1768 + ${inner}
1769 + </div>`+footer()+tabbar("fav");bindNav();};
1770 + if(!state.me){
1771 + shell(`<div class="fav-empty" style="text-align:left;padding:34px 0">
1772 + <div class="compte-actions" style="margin-top:0">
1773 + <a class="btn btn-primary" href="/api/auth/ka/login">Se connecter avec KA ID</a>
1774 + <a class="btn btn-ghost" href="https://www.groupe-ka.com/connexion" target="_blank" rel="noopener">Créer un compte KA ID ↗</a>
1775 + </div>
1776 + <p style="font-size:12.5px;color:var(--ink-3);margin-top:16px">Un seul compte pour tout le
1777 + groupe — vos favoris sont entreposés au hub groupe-ka.com, jamais ici.</p></div>`);
1778 + loadAccount();return;
1779 + }
1780 + let data=null;
1781 + try{const r=await fetch("/api/favorites");if(r.ok)data=await r.json();}catch(e){}
1782 + if(!data){
1783 + shell(`<div class="fav-empty"><h2>Hub injoignable</h2>
1784 + <p>Impossible de lire vos favoris au hub Groupe KA pour le moment — réessayez dans un instant.</p></div>`);
1785 + loadAccount();return;
1786 + }
1787 + FAV.ids=new Set(data.ids||[]);FAV.loaded=true;
1788 + const items=(data.items||[]).filter(i=>i.item_id);
1789 + shell(items.length?`
1790 + <div class="count-line" style="padding-top:26px"><b>${fmtFull.format(items.length)}</b> créateur${items.length>1?"s":""} en favori</div>
1791 + <div class="grid" id="fav-grid">${items.map(favCardHtml).join("")}</div>`
1792 + :`<div class="fav-empty"><h2>Aucun favori pour l'instant</h2>
1793 + <p>Touchez le ♥ sur une carte ou une fiche de créateur pour l'ajouter à votre univers Ka.</p>
1794 + <p style="margin-top:16px"><a class="btn btn-primary" href="/#annuaire" data-nav>Explorer l'annuaire</a></p></div>`);
1795 + loadAccount();window.scrollTo(0,0);
1796 +}
1797 +
1633 1798 /* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */
1634 1799 function renderContact(){
1635 1800 document.title="Contact — Créa-Ka, un service Groupe KA";
@@ -1714,6 +1879,7 @@ function route(){
1714 1879 if(m)r=renderCreator(decodeURIComponent(m[1]));
1715 1880 else if(pl){state.plateforme=decodeURIComponent(pl[1]);r=renderHome();}
1716 1881 else if(p==="/retrait")r=renderOptout();
1882 + else if(p==="/favoris")r=renderFavoris();
1717 1883 else if(p==="/stats")r=renderStats();
1718 1884 else if(p==="/compte")r=renderCompte();
1719 1885 else if(p==="/contact")r=renderContact();
1720 1886