Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com
Python 51.9%
TypeScript 32.7%
JavaScript 4.8%
CSS 4.1%
Shell 2.9%
HTML 1.8%
SQL 1.5%
1/**2 * Trouve-KA — magasin client des favoris « Mon univers Ka »3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 *6 * Singleton partagé par tous les cœurs ♥ de la page (useSyncExternalStore) :7 * une seule lecture de /api/favorites (le hub, via le serveur), toggles8 * optimistes annulés si le hub refuse. Aucun stockage local — le hub9 * groupe-ka.com est la source de vérité, ce magasin n'est qu'un miroir.10 */1112"use client";1314import { useSyncExternalStore } from "react";1516export type Fav = {17 item_id: string;18 title: string;19 subtitle?: string;20 price_label?: string;21 image_url?: string;22 url?: string;23};2425export type FavStatus = "idle" | "loading" | "ready" | "anon" | "error";2627export type FavState = {28 status: FavStatus;29 items: readonly Fav[];30};3132let state: FavState = { status: "idle", items: [] };33const listeners = new Set<() => void>();34let loadPromise: Promise<void> | null = null;3536function setState(next: FavState) {37 state = next;38 for (const l of listeners) l();39}4041function subscribe(listener: () => void): () => void {42 listeners.add(listener);43 return () => listeners.delete(listener);44}4546const getSnapshot = () => state;47const SERVER_SNAPSHOT: FavState = { status: "idle", items: [] };48const getServerSnapshot = () => SERVER_SNAPSHOT;4950/** Charge la liste des favoris (une seule requête partagée par page). */51export function ensureLoaded(force = false): Promise<void> {52 if (!force && (loadPromise || state.status === "ready" || state.status === "anon")) {53 return loadPromise ?? Promise.resolve();54 }55 if (state.status === "idle" || force) {56 setState({ ...state, status: "loading" });57 }58 loadPromise = fetch("/api/favorites", { cache: "no-store" })59 .then(async (r) => {60 if (r.status === 401) {61 setState({ status: "anon", items: [] });62 return;63 }64 if (!r.ok) throw new Error(`Erreur ${r.status}`);65 const data = (await r.json()) as { favorites?: Fav[] };66 setState({ status: "ready", items: data.favorites ?? [] });67 })68 .catch(() => {69 setState({ status: "error", items: state.items });70 })71 .finally(() => {72 loadPromise = null;73 });74 return loadPromise;75}7677function hasFav(items: readonly Fav[], url: string): boolean {78 return items.some((f) => f.url === url);79}8081/**82 * Toggle optimiste d'un favori (par URL — identité d'un résultat Trouve-KA).83 * Rendu : "added" | "removed" en cas de succès, "error" si le hub refuse84 * (l'état local est alors restauré), "anon" si non connecté.85 */86export async function toggleFav(item: {87 url: string;88 title: string;89 subtitle?: string;90 image_url?: string;91}): Promise<"added" | "removed" | "error" | "anon"> {92 if (state.status === "anon") return "anon";93 const before = state.items;94 const removing = hasFav(before, item.url);95 const action = removing ? "remove" : "add";9697 // Optimiste : on reflète le geste tout de suite, le hub tranche ensuite.98 setState({99 status: "ready",100 items: removing101 ? before.filter((f) => f.url !== item.url)102 : [{ item_id: "", ...item }, ...before],103 });104105 try {106 const r = await fetch("/api/favorites/toggle", {107 method: "POST",108 headers: { "Content-Type": "application/json" },109 body: JSON.stringify({ action, item }),110 });111 if (r.status === 401) {112 setState({ status: "anon", items: [] });113 return "anon";114 }115 if (!r.ok) throw new Error(`Erreur ${r.status}`);116 return removing ? "removed" : "added";117 } catch {118 setState({ status: state.status, items: before }); // annule l'optimisme119 return "error";120 }121}122123/** État réactif des favoris — à consommer dans les composants clients. */124export function useFavorites(): FavState {125 return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);126}127128export function isFavorite(stateNow: FavState, url: string): boolean {129 return hasFav(stateNow.items, url);130}131