favoris unifiés « Mon univers Ka » : le hub Groupe KA devient le magasin central
- restoka/hubfav.py : client HMAC du hub (calqué sur foodka/hubfav.py) — toggle synchrone, lecture avec cache mémoire 30 s invalidé au toggle - web.py : GET /api/favorites (liste du hub + fiches locales) et POST /api/favorites/toggle, protégés par la session KA ID (401 sinon) - auth.py : retrait des anciennes routes locales /api/favoris (plus aucun stockage local des favoris) - frontend : favorites.tsx (FavProvider/FavButton), cœur ♥ sur les cartes ET la fiche détail (non connecté : clic → connexion KA ID), page /favoris branchée au hub, entrée Favoris dans la nav, icône IcoHeart, .fav-btn.big Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 changed files +370 −107
modified
frontend/src/App.tsx
+5 −2
@@ -13,8 +13,9 @@ import { | |||||
| 13 | registerSourceNames, sourceName, | 13 | registerSourceNames, sourceName, | ||
| 14 | } from "./api"; | 14 | } from "./api"; | ||
| 15 | import { | 15 | import { | ||
| 16 | − IcoChart, IcoCompass, IcoFolder, IcoPlate, IcoSearch, | 16 | + IcoChart, IcoCompass, IcoFolder, IcoHeart, IcoPlate, IcoSearch, | ||
| 17 | } from "./components/Icons"; | 17 | } from "./components/Icons"; | ||
| 18 | +import { FavProvider } from "./favorites"; | ||||
| 18 | import { LogoIcon } from "./components/Logo"; | 19 | import { LogoIcon } from "./components/Logo"; | ||
| 19 | import GroupeKaBadge from "./ka/GroupeKaBadge"; | 20 | import GroupeKaBadge from "./ka/GroupeKaBadge"; | ||
| 20 | import KaFooter from "./ka/KaFooter"; | 21 | import KaFooter from "./ka/KaFooter"; | ||
@@ -139,6 +140,7 @@ function Ticker() { | |||||
| 139 | 140 | ||||
| 140 | const NAV_LINKS = [ | 141 | const NAV_LINKS = [ | ||
| 141 | { to: "/", label: "Restos", icon: <IcoPlate size={17} />, end: true }, | 142 | { to: "/", label: "Restos", icon: <IcoPlate size={17} />, end: true }, | ||
| 143 | + { to: "/favoris", label: "Favoris", icon: <IcoHeart size={17} />, end: false }, | ||||
| 142 | { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false }, | 144 | { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false }, | ||
| 143 | { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false }, | 145 | { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false }, | ||
| 144 | { to: "/contact", label: "Contact", icon: <IcoCompass size={17} />, end: false }, | 146 | { to: "/contact", label: "Contact", icon: <IcoCompass size={17} />, end: false }, | ||
@@ -224,7 +226,6 @@ function Footer() { | |||||
| 224 | <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink> | 226 | <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink> | ||
| 225 | ))} | 227 | ))} | ||
| 226 | <NavLink to="/villes">Villes</NavLink> | 228 | <NavLink to="/villes">Villes</NavLink> | ||
| 227 | − <NavLink to="/favoris">Favoris</NavLink> | ||||
| 228 | </nav> | 229 | </nav> | ||
| 229 | <div className="prefoot-osm"> | 230 | <div className="prefoot-osm"> | ||
| 230 | Données de découverte ©{" "} | 231 | Données de découverte ©{" "} | ||
@@ -261,6 +262,7 @@ function MobileTabBar() { | |||||
| 261 | export default function App() { | 262 | export default function App() { | ||
| 262 | return ( | 263 | return ( | ||
| 263 | <AccountProvider> | 264 | <AccountProvider> | ||
| 265 | + <FavProvider> | ||||
| 264 | <Header /> | 266 | <Header /> | ||
| 265 | <main> | 267 | <main> | ||
| 266 | <Routes> | 268 | <Routes> | ||
@@ -286,6 +288,7 @@ export default function App() { | |||||
| 286 | </main> | 288 | </main> | ||
| 287 | <Footer /> | 289 | <Footer /> | ||
| 288 | <MobileTabBar /> | 290 | <MobileTabBar /> | ||
| 291 | + </FavProvider> | ||||
| 289 | </AccountProvider> | 292 | </AccountProvider> | ||
| 290 | ); | 293 | ); | ||
| 291 | } | 294 | } | ||
modified
frontend/src/account.tsx
+6 −25
@@ -2,51 +2,32 @@ | |||||
| 2 | // Author: Simon-Pierre Boucher <contact@spboucher.ai> | 2 | // Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||
| 3 | // File: account.tsx | 3 | // File: account.tsx | ||
| 4 | // Desc: Contexte de compte membre (« Se connecter avec KA » — hub Groupe KA) : | 4 | // Desc: Contexte de compte membre (« Se connecter avec KA » — hub Groupe KA) : | ||
| 5 | −// profil de session + favoris partagés, comme le account.tsx de Lou·Ka. | 5 | +// profil de session, comme le account.tsx de Lou·Ka. Les favoris ♥ | ||
| 6 | +// vivent au hub Groupe KA (« Mon univers Ka ») — voir favorites.tsx. | ||||
| 6 | // ============================================================================== | 7 | // ============================================================================== | ||
| 7 | import { | 8 | import { | ||
| 8 | createContext, useCallback, useContext, useEffect, useMemo, useState, | 9 | createContext, useCallback, useContext, useEffect, useMemo, useState, | ||
| 9 | } from "react"; | 10 | } from "react"; | ||
| 10 | −import { Me, fetchFavoris, fetchMe, toggleFavori } from "./api"; | 11 | +import { Me, fetchMe } from "./api"; | ||
| 11 | 12 | ||||
| 12 | interface AccountCtx { | 13 | interface AccountCtx { | ||
| 13 | me: Me | null; | 14 | me: Me | null; | ||
| 14 | − favs: Set<string>; | ||||
| 15 | refresh: () => void; | 15 | refresh: () => void; | ||
| 16 | − toggleFav: (uid: string) => Promise<void>; | ||||
| 17 | } | 16 | } | ||
| 18 | 17 | ||||
| 19 | −const Ctx = createContext<AccountCtx>({ | 18 | +const Ctx = createContext<AccountCtx>({ me: null, refresh: () => {} }); | ||
| 20 | − me: null, favs: new Set(), refresh: () => {}, toggleFav: async () => {}, | ||||
| 21 | −}); | ||||
| 22 | 19 | ||||
| 23 | export const useAccount = () => useContext(Ctx); | 20 | export const useAccount = () => useContext(Ctx); | ||
| 24 | 21 | ||||
| 25 | export function AccountProvider({ children }: { children: React.ReactNode }) { | 22 | export function AccountProvider({ children }: { children: React.ReactNode }) { | ||
| 26 | const [me, setMe] = useState<Me | null>(null); | 23 | const [me, setMe] = useState<Me | null>(null); | ||
| 27 | − const [favs, setFavs] = useState<Set<string>>(new Set()); | ||||
| 28 | 24 | ||||
| 29 | const refresh = useCallback(() => { | 25 | const refresh = useCallback(() => { | ||
| 30 | − fetchMe().then((m) => { | 26 | + fetchMe().then((m) => setMe(m)); | ||
| 31 | − setMe(m); | ||||
| 32 | − if (m) fetchFavoris().then((f) => setFavs(new Set(f.uids))).catch(() => {}); | ||||
| 33 | − else setFavs(new Set()); | ||||
| 34 | − }); | ||||
| 35 | }, []); | 27 | }, []); | ||
| 36 | 28 | ||||
| 37 | useEffect(() => { refresh(); }, [refresh]); | 29 | useEffect(() => { refresh(); }, [refresh]); | ||
| 38 | 30 | ||||
| 39 | − const toggleFav = useCallback(async (uid: string) => { | 31 | + const value = useMemo(() => ({ me, refresh }), [me, refresh]); | ||
| 40 | − const on = await toggleFavori(uid); | ||||
| 41 | − setFavs((prev) => { | ||||
| 42 | − const next = new Set(prev); | ||||
| 43 | − if (on) next.add(uid); | ||||
| 44 | − else next.delete(uid); | ||||
| 45 | − return next; | ||||
| 46 | − }); | ||||
| 47 | − }, []); | ||||
| 48 | − | ||||
| 49 | − const value = useMemo(() => ({ me, favs, refresh, toggleFav }), | ||||
| 50 | − [me, favs, refresh, toggleFav]); | ||||
| 51 | return <Ctx.Provider value={value}>{children}</Ctx.Provider>; | 32 | return <Ctx.Provider value={value}>{children}</Ctx.Provider>; | ||
| 52 | } | 33 | } | ||
modified
frontend/src/api.ts
+23 −5
@@ -306,11 +306,29 @@ export async function logout(): Promise<void> { | |||||
| 306 | await fetch("/api/auth/logout", { method: "POST" }); | 306 | await fetch("/api/auth/logout", { method: "POST" }); | ||
| 307 | } | 307 | } | ||
| 308 | 308 | ||||
| 309 | −export const fetchFavoris = () => | 309 | +// --- Favoris « Mon univers Ka » (magasin central : hub Groupe KA) ------------ | ||
| 310 | − get<{ count: number; restaurants: Restaurant[]; uids: string[] }>("/api/favoris"); | 310 | + | ||
| 311 | +/** Item de favori tel que poussé au hub Groupe KA (groupe-ka.com). */ | ||||
| 312 | +export interface FavItem { | ||||
| 313 | + item_id: string; | ||||
| 314 | + title: string; | ||||
| 315 | + subtitle?: string; | ||||
| 316 | + price_label?: string; | ||||
| 317 | + image_url?: string; | ||||
| 318 | + url?: string; | ||||
| 319 | +} | ||||
| 311 | 320 | ||||
| 312 | −export async function toggleFavori(uid: string): Promise<boolean> { | 321 | +/** Favoris du membre (lus au hub) + fiches locales correspondantes. */ | ||
| 313 | − const resp = await fetch(`/api/favoris/${encodeURIComponent(uid)}`, { method: "POST" }); | 322 | +export const fetchFavorites = () => | ||
| 323 | + get<{ ids: string[]; items: FavItem[]; restaurants: Restaurant[] }>("/api/favorites"); | ||||
| 324 | + | ||||
| 325 | +/** Pousse un ♥ (on=true : ajout ; on=false : retrait) au hub, via l'API locale. */ | ||||
| 326 | +export async function toggleFavorite(on: boolean, item: FavItem): Promise<void> { | ||||
| 327 | + const resp = await fetch("/api/favorites/toggle", { | ||||
| 328 | + method: "POST", | ||||
| 329 | + credentials: "same-origin", | ||||
| 330 | + headers: { "Content-Type": "application/json" }, | ||||
| 331 | + body: JSON.stringify({ on, item }), | ||||
| 332 | + }); | ||||
| 314 | if (!resp.ok) throw new Error(`API ${resp.status}`); | 333 | if (!resp.ok) throw new Error(`API ${resp.status}`); | ||
| 315 | − return (await resp.json()).favori as boolean; | ||||
| 316 | } | 334 | } | ||
modified
frontend/src/components/Icons.tsx
+6 −0
@@ -78,3 +78,9 @@ export const IcoBag = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |||||
| 78 | <path d="M9 8a3 3 0 0 1 6 0" /> | 78 | <path d="M9 8a3 3 0 0 1 6 0" /> | ||
| 79 | </svg> | 79 | </svg> | ||
| 80 | ); | 80 | ); | ||
| 81 | + | ||||
| 82 | +export const IcoHeart = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | ||||
| 83 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | ||||
| 84 | + <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" /> | ||||
| 85 | + </svg> | ||||
| 86 | +); | ||||
modified
frontend/src/components/RestoCard.tsx
+2 −13
@@ -6,18 +6,16 @@ | |||||
| 6 | // TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte. | 6 | // TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte. | ||
| 7 | // ============================================================================== | 7 | // ============================================================================== | ||
| 8 | import { Link } from "react-router-dom"; | 8 | import { Link } from "react-router-dom"; | ||
| 9 | −import { useAccount } from "../account"; | ||||
| 10 | import { | 9 | import { | ||
| 11 | CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel, | 10 | CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel, | ||
| 12 | } from "../api"; | 11 | } from "../api"; | ||
| 12 | +import { FavButton } from "../favorites"; | ||||
| 13 | import { IcoPlate } from "./Icons"; | 13 | import { IcoPlate } from "./Icons"; | ||
| 14 | 14 | ||||
| 15 | export default function RestoCard({ r }: { r: Restaurant }) { | 15 | export default function RestoCard({ r }: { r: Restaurant }) { | ||
| 16 | const ms = r.menu_summary; | 16 | const ms = r.menu_summary; | ||
| 17 | const img = ms?.image || (r.images ?? [])[1] || null; // photo de plat d'abord | 17 | const img = ms?.image || (r.images ?? [])[1] || null; // photo de plat d'abord | ||
| 18 | const logo = (r.images ?? [])[0] || null; | 18 | const logo = (r.images ?? [])[0] || null; | ||
| 19 | − const { me, favs, toggleFav } = useAccount(); | ||||
| 20 | − const fav = favs.has(r.uid); | ||||
| 21 | return ( | 19 | return ( | ||
| 22 | <Link to={`/resto/${encodeURIComponent(r.uid)}`} className="card"> | 20 | <Link to={`/resto/${encodeURIComponent(r.uid)}`} className="card"> | ||
| 23 | <div className="card-img"> | 21 | <div className="card-img"> | ||
@@ -34,16 +32,7 @@ export default function RestoCard({ r }: { r: Restaurant }) { | |||||
| 34 | {CONTEXT_SHORT[ms.price_context] ?? ms.price_context} | 32 | {CONTEXT_SHORT[ms.price_context] ?? ms.price_context} | ||
| 35 | </span> | 33 | </span> | ||
| 36 | )} | 34 | )} | ||
| 37 | − {me && ( | 35 | + <FavButton r={r} /> | ||
| 38 | − <button | ||||
| 39 | − className={`fav-btn ${fav ? "on" : ""}`} | ||||
| 40 | − aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} | ||||
| 41 | − aria-pressed={fav} | ||||
| 42 | − onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(r.uid); }} | ||||
| 43 | − > | ||||
| 44 | − {fav ? "♥" : "♡"} | ||||
| 45 | − </button> | ||||
| 46 | − )} | ||||
| 47 | </div> | 36 | </div> | ||
| 48 | <div className="card-body"> | 37 | <div className="card-body"> | ||
| 49 | <div className="card-price"> | 38 | <div className="card-price"> | ||
added
frontend/src/favorites.tsx
+114 −0
@@ -0,0 +1,114 @@ | |||||
| 1 | +// ============================================================================== | ||||
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +// File: favorites.tsx | ||||
| 4 | +// Desc: Favoris ♥ « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) est | ||||
| 5 | +// le magasin central des favoris du groupe (aucun stockage local ni | ||||
| 6 | +// navigateur) : lecture/écriture via l'API locale /api/favorites qui | ||||
| 7 | +// relaie au hub. FavProvider charge la liste une fois ; FavButton = | ||||
| 8 | +// cœur sur les cartes de restos et la fiche détail. Non connecté → | ||||
| 9 | +// clic = « Se connecter avec KA ID ». Calqué sur foodka/favorites.tsx. | ||||
| 10 | +// ============================================================================== | ||||
| 11 | +import { | ||||
| 12 | + ReactNode, createContext, useCallback, useContext, | ||||
| 13 | + useEffect, useMemo, useState, | ||||
| 14 | +} from "react"; | ||||
| 15 | +import { FavItem, Restaurant, fmtPrice, toggleFavorite } from "./api"; | ||||
| 16 | + | ||||
| 17 | +interface FavState { | ||||
| 18 | + ready: boolean; // premier chargement terminé | ||||
| 19 | + connected: boolean; // session KA ID active | ||||
| 20 | + ids: Set<string>; // uid des restos en favoris | ||||
| 21 | + items: FavItem[]; | ||||
| 22 | + toggle: (item: FavItem) => void; | ||||
| 23 | +} | ||||
| 24 | + | ||||
| 25 | +const FavContext = createContext<FavState>({ | ||||
| 26 | + ready: false, connected: false, ids: new Set(), items: [], toggle: () => {}, | ||||
| 27 | +}); | ||||
| 28 | + | ||||
| 29 | +/** Restaurant -> item de favori du hub (nom, ville, prix médian, image, url). */ | ||||
| 30 | +export function restoFavItem(r: Restaurant): FavItem { | ||||
| 31 | + const ms = r.menu_summary; | ||||
| 32 | + return { | ||||
| 33 | + item_id: r.uid, | ||||
| 34 | + title: r.name || "Restaurant", | ||||
| 35 | + subtitle: [r.city, r.region].filter(Boolean).join(" · ") | ||||
| 36 | + || r.address || "", | ||||
| 37 | + price_label: ms?.price_median != null | ||||
| 38 | + ? `${fmtPrice(ms.price_median)} · plat médian` | ||||
| 39 | + : (r.price_range || ""), | ||||
| 40 | + image_url: ms?.image || (r.images ?? [])[1] || (r.images ?? [])[0] || "", | ||||
| 41 | + url: `https://www.resto-ka.com/resto/${encodeURIComponent(r.uid)}`, | ||||
| 42 | + }; | ||||
| 43 | +} | ||||
| 44 | + | ||||
| 45 | +export function FavProvider({ children }: { children: ReactNode }) { | ||||
| 46 | + const [ready, setReady] = useState(false); | ||||
| 47 | + const [connected, setConnected] = useState(false); | ||||
| 48 | + const [items, setItems] = useState<FavItem[]>([]); | ||||
| 49 | + | ||||
| 50 | + useEffect(() => { | ||||
| 51 | + fetch("/api/favorites", { credentials: "same-origin" }) | ||||
| 52 | + .then(async (res) => { | ||||
| 53 | + if (res.status === 401) return; // pas connecté : cœurs vides | ||||
| 54 | + if (!res.ok) throw new Error(`API ${res.status}`); | ||||
| 55 | + const data = (await res.json()) as { items?: FavItem[] }; | ||||
| 56 | + setConnected(true); | ||||
| 57 | + setItems(data.items ?? []); | ||||
| 58 | + }) | ||||
| 59 | + .catch(() => {}) // hub muet : cœurs vides | ||||
| 60 | + .finally(() => setReady(true)); | ||||
| 61 | + }, []); | ||||
| 62 | + | ||||
| 63 | + const toggle = useCallback((item: FavItem) => { | ||||
| 64 | + if (!connected) { | ||||
| 65 | + // non connecté : passage par la connexion KA ID (hub Groupe KA) | ||||
| 66 | + window.location.assign("/api/auth/ka/login"); | ||||
| 67 | + return; | ||||
| 68 | + } | ||||
| 69 | + const prev = items; | ||||
| 70 | + const on = !prev.some((i) => i.item_id === item.item_id); | ||||
| 71 | + // optimiste : le hub confirme (sinon on rétablit) | ||||
| 72 | + setItems(on ? [...prev, item] | ||||
| 73 | + : prev.filter((i) => i.item_id !== item.item_id)); | ||||
| 74 | + toggleFavorite(on, item).catch(() => setItems(prev)); | ||||
| 75 | + }, [connected, items]); | ||||
| 76 | + | ||||
| 77 | + const ids = useMemo( | ||||
| 78 | + () => new Set(items.map((i) => i.item_id)), [items]); | ||||
| 79 | + | ||||
| 80 | + return ( | ||||
| 81 | + <FavContext.Provider value={{ ready, connected, ids, items, toggle }}> | ||||
| 82 | + {children} | ||||
| 83 | + </FavContext.Provider> | ||||
| 84 | + ); | ||||
| 85 | +} | ||||
| 86 | + | ||||
| 87 | +export const useFav = () => useContext(FavContext); | ||||
| 88 | + | ||||
| 89 | +/** Cœur ♥ — cartes de resto (par défaut) et fiche détail (`big`). */ | ||||
| 90 | +export function FavButton({ r, big = false }: { r: Restaurant; big?: boolean }) { | ||||
| 91 | + const { connected, ids, toggle } = useFav(); | ||||
| 92 | + const on = ids.has(r.uid); | ||||
| 93 | + const label = !connected | ||||
| 94 | + ? "Se connecter avec KA ID pour ajouter aux favoris" | ||||
| 95 | + : on | ||||
| 96 | + ? "Retirer de mes favoris (Mon univers Ka)" | ||||
| 97 | + : "Ajouter à mes favoris (Mon univers Ka)"; | ||||
| 98 | + return ( | ||||
| 99 | + <button | ||||
| 100 | + type="button" | ||||
| 101 | + className={`fav-btn${on ? " on" : ""}${big ? " big" : ""}`} | ||||
| 102 | + aria-label={label} | ||||
| 103 | + aria-pressed={on} | ||||
| 104 | + title={label} | ||||
| 105 | + onClick={(e) => { | ||||
| 106 | + e.preventDefault(); // la carte entière est un lien | ||||
| 107 | + e.stopPropagation(); | ||||
| 108 | + toggle(restoFavItem(r)); | ||||
| 109 | + }} | ||||
| 110 | + > | ||||
| 111 | + {on ? "♥" : "♡"} | ||||
| 112 | + </button> | ||||
| 113 | + ); | ||||
| 114 | +} | ||||
modified
frontend/src/pages/Favoris.tsx
+47 −14
@@ -1,56 +1,89 @@ | |||||
| 1 | // ============================================================================== | 1 | // ============================================================================== | ||
| 2 | // Author: Simon-Pierre Boucher <contact@spboucher.ai> | 2 | // Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||
| 3 | // File: pages/Favoris.tsx | 3 | // File: pages/Favoris.tsx | ||
| 4 | −// Desc: Restos favoris du membre connecté (« Se connecter avec KA »). | 4 | +// Desc: Restos favoris ♥ « Mon univers Ka » du membre connecté — lus au hub | ||
| 5 | +// Groupe KA (groupe-ka.com), magasin central des favoris du groupe : | ||||
| 6 | +// les mêmes ♥ suivent le membre sur toutes les plateformes ·Ka. | ||||
| 5 | // ============================================================================== | 7 | // ============================================================================== | ||
| 6 | import { useEffect, useState } from "react"; | 8 | import { useEffect, useState } from "react"; | ||
| 9 | +import { Link } from "react-router-dom"; | ||||
| 7 | import { useAccount } from "../account"; | 10 | import { useAccount } from "../account"; | ||
| 8 | −import { Restaurant, fetchFavoris } from "../api"; | 11 | +import { Restaurant, fetchFavorites } from "../api"; | ||
| 9 | import RestoCard from "../components/RestoCard"; | 12 | import RestoCard from "../components/RestoCard"; | ||
| 13 | +import { useFav } from "../favorites"; | ||||
| 10 | 14 | ||||
| 11 | export default function FavorisPage() { | 15 | export default function FavorisPage() { | ||
| 12 | − const { me, favs } = useAccount(); | 16 | + const { me } = useAccount(); | ||
| 17 | + const { ready, connected, ids, items } = useFav(); | ||||
| 13 | const [restos, setRestos] = useState<Restaurant[]>([]); | 18 | const [restos, setRestos] = useState<Restaurant[]>([]); | ||
| 14 | const [loading, setLoading] = useState(true); | 19 | const [loading, setLoading] = useState(true); | ||
| 15 | 20 | ||||
| 16 | useEffect(() => { | 21 | useEffect(() => { | ||
| 17 | − if (!me) { setLoading(false); return; } | 22 | + if (!ready) return; | ||
| 18 | − fetchFavoris() | 23 | + if (!connected) { setLoading(false); return; } | ||
| 24 | + fetchFavorites() | ||||
| 19 | .then((f) => setRestos(f.restaurants)) | 25 | .then((f) => setRestos(f.restaurants)) | ||
| 20 | .catch(() => {}) | 26 | .catch(() => {}) | ||
| 21 | .finally(() => setLoading(false)); | 27 | .finally(() => setLoading(false)); | ||
| 22 | − }, [me, favs.size]); | 28 | + }, [ready, connected]); | ||
| 23 | 29 | ||||
| 24 | − if (!me) { | 30 | + if (ready && !connected) { | ||
| 25 | return ( | 31 | return ( | ||
| 26 | <div className="notice container"> | 32 | <div className="notice container"> | ||
| 27 | <h2>Connectez-vous pour vos favoris</h2> | 33 | <h2>Connectez-vous pour vos favoris</h2> | ||
| 28 | <p> | 34 | <p> | ||
| 29 | <a className="login-btn" href="/api/auth/ka/login" style={{ display: "inline-flex" }}> | 35 | <a className="login-btn" href="/api/auth/ka/login" style={{ display: "inline-flex" }}> | ||
| 30 | <span className="ka-badge" aria-hidden="true">KA</span> | 36 | <span className="ka-badge" aria-hidden="true">KA</span> | ||
| 31 | − Se connecter avec KA | 37 | + Se connecter avec KA ID | ||
| 32 | </a> | 38 | </a> | ||
| 33 | </p> | 39 | </p> | ||
| 34 | − <p>Un seul compte Groupe KA pour Lou·Ka, Resto·Ka et toutes les plateformes ·Ka.</p> | 40 | + <p> | ||
| 41 | + Un seul compte Groupe KA : vos ♥ vous suivent sur Resto·Ka, Lou·Ka, | ||||
| 42 | + Food·Ka et toutes les plateformes ·Ka — c'est « Mon univers Ka ». | ||||
| 43 | + </p> | ||||
| 35 | </div> | 44 | </div> | ||
| 36 | ); | 45 | ); | ||
| 37 | } | 46 | } | ||
| 38 | 47 | ||||
| 48 | + // fiches locales encore en favoris (le toggle retire la carte sans recharger) | ||||
| 49 | + const shown = restos.filter((r) => ids.has(r.uid)); | ||||
| 50 | + // favoris du hub sans fiche locale (resto retiré du catalogue) : carte simple | ||||
| 51 | + const known = new Set(restos.map((r) => r.uid)); | ||||
| 52 | + const extras = items.filter((i) => i.item_id && !known.has(i.item_id)); | ||||
| 53 | + | ||||
| 39 | return ( | 54 | return ( | ||
| 40 | <div className="container sources"> | 55 | <div className="container sources"> | ||
| 41 | − <span className="kicker">Mon compte — {me.ka_id}</span> | 56 | + <span className="kicker">Mon univers Ka{me?.ka_id ? ` — ${me.ka_id}` : ""}</span> | ||
| 42 | <h1>Mes restos favoris</h1> | 57 | <h1>Mes restos favoris</h1> | ||
| 43 | − <p className="sub">Les restos que vous avez épinglés, sur tous vos appareils.</p> | 58 | + <p className="sub"> | ||
| 44 | − {loading ? ( | 59 | + Vos ♥ sont conservés au hub Groupe KA : les mêmes favoris, sur tous vos | ||
| 60 | + appareils et toutes les plateformes ·Ka. | ||||
| 61 | + </p> | ||||
| 62 | + {loading || !ready ? ( | ||||
| 45 | <p>Chargement…</p> | 63 | <p>Chargement…</p> | ||
| 46 | − ) : restos.length === 0 ? ( | 64 | + ) : shown.length === 0 && extras.length === 0 ? ( | ||
| 47 | <div className="notice"> | 65 | <div className="notice"> | ||
| 48 | <h2>Aucun favori pour l'instant</h2> | 66 | <h2>Aucun favori pour l'instant</h2> | ||
| 49 | <p>Touchez le ♥ sur une fiche de resto pour l'épingler ici.</p> | 67 | <p>Touchez le ♥ sur une fiche de resto pour l'épingler ici.</p> | ||
| 50 | </div> | 68 | </div> | ||
| 51 | ) : ( | 69 | ) : ( | ||
| 52 | <div className="grid"> | 70 | <div className="grid"> | ||
| 53 | − {restos.map((r) => <RestoCard key={r.uid} r={r} />)} | 71 | + {shown.map((r) => <RestoCard key={r.uid} r={r} />)} | ||
| 72 | + {extras.map((i) => ( | ||||
| 73 | + <Link key={i.item_id} className="card" | ||||
| 74 | + to={`/resto/${encodeURIComponent(i.item_id)}`}> | ||||
| 75 | + <div className="card-img"> | ||||
| 76 | + {i.image_url | ||||
| 77 | + ? <img src={i.image_url} alt={i.title} loading="lazy" /> | ||||
| 78 | + : <div className="noimg" />} | ||||
| 79 | + </div> | ||||
| 80 | + <div className="card-body"> | ||||
| 81 | + {i.price_label && <div className="card-price">{i.price_label}</div>} | ||||
| 82 | + <div className="card-title">{i.title}</div> | ||||
| 83 | + {i.subtitle && <div className="card-meta"><span>{i.subtitle}</span></div>} | ||||
| 84 | + </div> | ||||
| 85 | + </Link> | ||||
| 86 | + ))} | ||||
| 54 | </div> | 87 | </div> | ||
| 55 | )} | 88 | )} | ||
| 56 | </div> | 89 | </div> | ||
modified
frontend/src/pages/Resto.tsx
+2 −0
@@ -13,6 +13,7 @@ import { | |||||
| 13 | fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel, | 13 | fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel, | ||
| 14 | } from "../api"; | 14 | } from "../api"; | ||
| 15 | import { IcoCompass } from "../components/Icons"; | 15 | import { IcoCompass } from "../components/Icons"; | ||
| 16 | +import { FavButton } from "../favorites"; | ||||
| 16 | 17 | ||||
| 17 | function MenuBlock({ menu }: { menu: Menu }) { | 18 | function MenuBlock({ menu }: { menu: Menu }) { | ||
| 18 | const [openSections, setOpenSections] = useState<Set<number>>( | 19 | const [openSections, setOpenSections] = useState<Set<number>>( | ||
@@ -162,6 +163,7 @@ export default function RestoPage() { | |||||
| 162 | <img className="panel-logo" src={resto.images[0]} alt="" loading="lazy" /> | 163 | <img className="panel-logo" src={resto.images[0]} alt="" loading="lazy" /> | ||
| 163 | )} | 164 | )} | ||
| 164 | {resto.price_range && <div className="price">{resto.price_range}</div>} | 165 | {resto.price_range && <div className="price">{resto.price_range}</div>} | ||
| 166 | + <FavButton r={resto} big /> | ||||
| 165 | </div> | 167 | </div> | ||
| 166 | <h1>{resto.name}</h1> | 168 | <h1>{resto.name}</h1> | ||
| 167 | <div className="loc"> | 169 | <div className="loc"> | ||
modified
frontend/src/styles.css
+5 −0
@@ -793,6 +793,11 @@ img { display: block; } | |||||
| 793 | } | 793 | } | ||
| 794 | .fav-btn:hover { transform: scale(1.08); } | 794 | .fav-btn:hover { transform: scale(1.08); } | ||
| 795 | .fav-btn.on { background: var(--accent); color: var(--ink); } | 795 | .fav-btn.on { background: var(--accent); color: var(--ink); } | ||
| 796 | +/* fiche détail : le cœur dans l'en-tête du panneau, en flux (pas absolu) */ | ||||
| 797 | +.fav-btn.big { | ||||
| 798 | + position: static; flex-shrink: 0; margin-left: auto; | ||||
| 799 | + width: 44px; height: 44px; font-size: 21px; | ||||
| 800 | +} | ||||
| 796 | 801 | ||||
| 797 | /* --- Menu déroulant mobile ------------------------------------------------- */ | 802 | /* --- Menu déroulant mobile ------------------------------------------------- */ | ||
| 798 | .menu-btn { | 803 | .menu-btn { | ||
modified
restoka/auth.py
+5 −46
@@ -12,7 +12,7 @@ | |||||
| 12 | # GET /api/me -> profil (fusionné avec le hub) | 12 | # GET /api/me -> profil (fusionné avec le hub) | ||
| 13 | # POST /api/auth/logout -> efface le cookie | 13 | # POST /api/auth/logout -> efface le cookie | ||
| 14 | # GET /api/auth/config -> {"ka": bool} | 14 | # GET /api/auth/config -> {"ka": bool} | ||
| 15 | −# + FAVORIS : GET /api/favoris, POST /api/favoris/{uid} (toggle). | 15 | +# (FAVORIS : au hub Groupe KA — voir hubfav.py + routes dans web.py.) | ||
| 16 | # Session : jeton HMAC-SHA256 (stdlib) dans un cookie httpOnly. | 16 | # Session : jeton HMAC-SHA256 (stdlib) dans un cookie httpOnly. | ||
| 17 | # Config .env : KA_SSO_SECRET, SESSION_SECRET, RESTOKA_BASE_URL. | 17 | # Config .env : KA_SSO_SECRET, SESSION_SECRET, RESTOKA_BASE_URL. | ||
| 18 | # ============================================================================== | 18 | # ============================================================================== | ||
@@ -249,48 +249,7 @@ def me(request: Request): | |||||
| 249 | 249 | ||||
| 250 | 250 | ||||
| 251 | # -- favoris ------------------------------------------------------------------------ | 251 | # -- favoris ------------------------------------------------------------------------ | ||
| 252 | − | 252 | +# Les favoris « Mon univers Ka » vivent au HUB Groupe KA (groupe-ka.com), | ||
| 253 | −@router.get("/favoris") | 253 | +# magasin central du groupe — plus AUCUN stockage local. Voir restoka/hubfav.py | ||
| 254 | −def favoris(request: Request): | 254 | +# et les routes /api/favorites + /api/favorites/toggle dans restoka/web.py. | ||
| 255 | − """Fiches complètes des restos favoris du membre connecté.""" | 255 | +# (Les anciennes routes locales /api/favoris ont été retirées le 2026-08-23.) | ||
| 256 | − user = current_user(request) | ||||
| 257 | − if user is None: | ||||
| 258 | − raise HTTPException(401, "non connecté") | ||||
| 259 | − from .web import _menu_summaries, _row_to_dict | ||||
| 260 | − con = db.connect() | ||||
| 261 | − rows = con.execute( | ||||
| 262 | − """SELECT r.* FROM favorites f JOIN restaurants r ON r.uid=f.uid | ||||
| 263 | − WHERE f.user_id=? ORDER BY f.ts DESC""", (user["uid"],)).fetchall() | ||||
| 264 | − out = [_row_to_dict(r) for r in rows] | ||||
| 265 | − menus = _menu_summaries(con, [r["uid"] for r in out]) | ||||
| 266 | − for r in out: | ||||
| 267 | − r["menu_summary"] = menus.get(r["uid"]) | ||||
| 268 | − con.close() | ||||
| 269 | − return {"count": len(out), "restaurants": out, | ||||
| 270 | − "uids": [r["uid"] for r in out]} | ||||
| 271 | − | ||||
| 272 | − | ||||
| 273 | −@router.post("/favoris/{uid:path}") | ||||
| 274 | −def toggle_favori(uid: str, request: Request): | ||||
| 275 | − """Ajoute/retire un resto des favoris. Retourne {favori: bool}.""" | ||||
| 276 | − user = current_user(request) | ||||
| 277 | − if user is None: | ||||
| 278 | − raise HTTPException(401, "non connecté") | ||||
| 279 | − con = db.connect() | ||||
| 280 | − if con.execute("SELECT 1 FROM restaurants WHERE uid=?", (uid,)).fetchone() is None: | ||||
| 281 | − con.close() | ||||
| 282 | − raise HTTPException(404, "resto introuvable") | ||||
| 283 | − existing = con.execute( | ||||
| 284 | − "SELECT 1 FROM favorites WHERE user_id=? AND uid=?", | ||||
| 285 | − (user["uid"], uid)).fetchone() | ||||
| 286 | − if existing: | ||||
| 287 | − con.execute("DELETE FROM favorites WHERE user_id=? AND uid=?", | ||||
| 288 | − (user["uid"], uid)) | ||||
| 289 | − fav = False | ||||
| 290 | − else: | ||||
| 291 | − con.execute("INSERT INTO favorites (user_id, uid, ts) VALUES (?,?,?)", | ||||
| 292 | − (user["uid"], uid, time.time())) | ||||
| 293 | − fav = True | ||||
| 294 | − con.commit() | ||||
| 295 | − con.close() | ||||
| 296 | − return {"favori": fav} | ||||
added
restoka/hubfav.py
+104 −0
@@ -0,0 +1,104 @@ | |||||
| 1 | +# ============================================================================== | ||||
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +# File: restoka/hubfav.py | ||||
| 4 | +# Desc: Favoris « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) est le | ||||
| 5 | +# MAGASIN CENTRAL des favoris du groupe : Resto·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 | +# Calqué sur foodka/hubfav.py (implémentation de référence du groupe). | ||||
| 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 = "resto-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("resto-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 Resto·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
restoka/web.py
+51 −2
@@ -13,13 +13,13 @@ import statistics | |||||
| 13 | import threading | 13 | import threading | ||
| 14 | from pathlib import Path | 14 | from pathlib import Path | ||
| 15 | 15 | ||||
| 16 | −from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query | 16 | +from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request | ||
| 17 | from fastapi.middleware.cors import CORSMiddleware | 17 | from fastapi.middleware.cors import CORSMiddleware | ||
| 18 | from fastapi.middleware.gzip import GZipMiddleware | 18 | from fastapi.middleware.gzip import GZipMiddleware | ||
| 19 | from fastapi.responses import FileResponse, Response | 19 | from fastapi.responses import FileResponse, Response | ||
| 20 | from fastapi.staticfiles import StaticFiles | 20 | from fastapi.staticfiles import StaticFiles | ||
| 21 | 21 | ||||
| 22 | −from . import db, ingest | 22 | +from . import db, hubfav, ingest | ||
| 23 | from .normalize import PRICE_CONTEXTS | 23 | from .normalize import PRICE_CONTEXTS | ||
| 24 | from .regions import REGIONS | 24 | from .regions import REGIONS | ||
| 25 | 25 | ||||
@@ -535,6 +535,55 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None): | |||||
| 535 | return {"status": "démarré", "source": source or "toutes"} | 535 | return {"status": "démarré", "source": source or "toutes"} | ||
| 536 | 536 | ||||
| 537 | 537 | ||||
| 538 | +# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ----- | ||||
| 539 | +@app.get("/api/favorites") | ||||
| 540 | +def favorites(request: Request): | ||||
| 541 | + """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage | ||||
| 542 | + local). Retourne aussi les fiches locales correspondantes (ordre du hub) | ||||
| 543 | + pour l'affichage de la page /favoris.""" | ||||
| 544 | + user = auth.current_user(request) | ||||
| 545 | + if not user: | ||||
| 546 | + raise HTTPException(401, "Connexion KA ID requise") | ||||
| 547 | + items = hubfav.hub_list(user.get("ka_id") or "") | ||||
| 548 | + if items is None: | ||||
| 549 | + raise HTTPException(502, "Hub Groupe KA injoignable — réessayez") | ||||
| 550 | + ids = [i["item_id"] for i in items if i.get("item_id")] | ||||
| 551 | + restos: list[dict] = [] | ||||
| 552 | + if ids: | ||||
| 553 | + con = db.connect() | ||||
| 554 | + marks = ",".join("?" * len(ids)) | ||||
| 555 | + rows = {r["uid"]: _row_to_dict(r) for r in con.execute( | ||||
| 556 | + f"SELECT * FROM restaurants WHERE uid IN ({marks})", | ||||
| 557 | + ids).fetchall()} | ||||
| 558 | + menus = _menu_summaries(con, list(rows)) | ||||
| 559 | + con.close() | ||||
| 560 | + for uid in ids: | ||||
| 561 | + r = rows.get(uid) | ||||
| 562 | + if r is not None: | ||||
| 563 | + r["menu_summary"] = menus.get(uid) | ||||
| 564 | + restos.append(r) | ||||
| 565 | + return {"ids": ids, "items": items, "restaurants": restos} | ||||
| 566 | + | ||||
| 567 | + | ||||
| 568 | +@app.post("/api/favorites/toggle") | ||||
| 569 | +def toggle_favorite(request: Request, body: dict = Body(...)): | ||||
| 570 | + """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub | ||||
| 571 | + Groupe KA de façon synchrone : le hub est la seule source de vérité.""" | ||||
| 572 | + user = auth.current_user(request) | ||||
| 573 | + if not user: | ||||
| 574 | + raise HTTPException(401, "Connexion KA ID requise") | ||||
| 575 | + ka_id = user.get("ka_id") or "" | ||||
| 576 | + if not hubfav.linked(ka_id): | ||||
| 577 | + raise HTTPException(403, "Compte non relié au hub Groupe KA") | ||||
| 578 | + on = bool(body.get("on")) | ||||
| 579 | + item = hubfav.clean_item(body.get("item") or {}) | ||||
| 580 | + if not item.get("item_id"): | ||||
| 581 | + raise HTTPException(422, "item.item_id requis") | ||||
| 582 | + if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): | ||||
| 583 | + raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") | ||||
| 584 | + return {"ok": True, "on": on} | ||||
| 585 | + | ||||
| 586 | + | ||||
| 538 | # --- Frontend React (build Vite) -------------------------------------------- | 587 | # --- Frontend React (build Vite) -------------------------------------------- | ||
| 539 | if FRONTEND_DIST.exists(): | 588 | if FRONTEND_DIST.exists(): | ||
| 540 | app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), | 589 | app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), | ||
| 541 | 590 | ||||