SPB Git forge

spb/trouve-ka

Public ★ Pinned

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

40commits 1branches 0releases
9.2 MBsize
maindefault branch
23 days agolast push
Python 51.9% TypeScript 32.7% JavaScript 4.8% CSS 4.1% Shell 2.9% HTML 1.8% SQL 1.5%
3.9 KB · 131 lines typescript
Raw Blame History
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