SPB Git forge

spb/job-ka

Public
226commits 1branches 0releases
37.5 MBsize
maindefault branch
10 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
9.1 KB · 251 lines tsx
Raw Blame History
1/**2 * =============================================================================3 * Job·Ka — Groupe KA4 * Auteur  : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * Fichier : frontend/src/App.tsx7 * Rôle    : Gabarit global (en-tête + badge Groupe KA + connexion KA ID,8 *           navigation, routes, pied de page commun Groupe KA)9 * Créé    : 2026-08-17   Modifié : 2026-08-2510 * =============================================================================11 */12import { lazy, Suspense, useEffect, useState } from "react";13import { NavLink, Route, Routes, useLocation } from "react-router-dom";14import Home from "./pages/Home";15import JobPage from "./pages/Job";16import Sources from "./pages/Sources";17import Stats from "./pages/Stats";18import Contact from "./pages/Contact";19import Employeurs from "./pages/Employeurs";20import VillePage, { VillesPage } from "./pages/Ville";21import FavorisPage from "./pages/Favoris";22import { FavProvider, jobFavItem, useFav } from "./favorites";23import { Ico, IcoHeart } from "./components/Icons";24import { useCurrentListing } from "./fiche/current";25import GroupeKaBadge from "./ka/GroupeKaBadge";26import KaFooter from "./ka/KaFooter";2728// MapLibre pèse ~700 kB : chargé uniquement quand on visite /carte29const MapPage = lazy(() => import("./pages/MapPage"));3031interface Me { name: string; email: string; picture: string; ka_id: string }3233/** Connexion KA ID (hub groupe-ka.com) — bouton ou profil connecté. */34function KaAuth() {35  const [enabled, setEnabled] = useState(false);36  const [me, setMe] = useState<Me | null>(null);3738  useEffect(() => {39    fetch("/api/auth/config")40      .then((r) => r.json())41      .then((c) => setEnabled(Boolean(c.ka)))42      .catch(() => {});43    fetch("/api/me")44      .then((r) => (r.ok ? r.json() : null))45      .then(setMe)46      .catch(() => {});47  }, []);4849  const logout = () => {50    fetch("/api/auth/logout", { method: "POST" })51      .then(() => setMe(null))52      .catch(() => {});53  };5455  if (!enabled) return null;56  if (me) {57    return (58      <span className="auth-user">59        {me.picture ? <img src={me.picture} alt="" referrerPolicy="no-referrer" /> : null}60        <span className="name">{me.name || me.email}</span>61        <button type="button" onClick={logout}>Déconnexion</button>62      </span>63    );64  }65  return (66    <a className="btn-ka" href="/api/auth/ka/login">67      Se connecter avec <b>KA</b>68    </a>69  );70}7172/** Actions du header sur une fiche d'offre : favoris + partage (compactes).73    L'offre affichée est publiée par la page fiche (fiche/current.ts). */74function FicheHeaderActions() {75  const job = useCurrentListing();76  const { ids, toggle } = useFav();77  if (!job) return null;78  const fav = ids.has(job.uid);79  const share = async () => {80    const url = `https://www.job-ka.com/emploi/${encodeURIComponent(job.uid)}`;81    try {82      if (navigator.share) await navigator.share({ title: document.title, url });83      else await navigator.clipboard.writeText(url);84    } catch { /* annulé */ }85  };86  return (87    <div className="hdr-actions">88      <button type="button" className={`hdr-btn ${fav ? "on" : ""}`} aria-pressed={fav}89              aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={() => toggle(jobFavItem(job))}>90        <IcoHeart size={18} filled={fav} />91      </button>92      <button type="button" className="hdr-btn" aria-label="Partager" onClick={share}>93        <Ico name="share" size={17} />94      </button>95    </div>96  );97}9899/** Liens de navigation principaux (header desktop + menu mobile). */100const NAV_LINKS: Array<{ to: string; label: string; end?: boolean }> = [101  { to: "/", label: "Offres", end: true },102  { to: "/carte", label: "Carte" },103  { to: "/favoris", label: "Favoris ♥" },104  { to: "/sources", label: "Employeurs" },105  { to: "/stats", label: "Stats" },106  { to: "/employeurs", label: "Publier" },107  { to: "/contact", label: "Contact" },108];109110export default function App() {111  const [menuOpen, setMenuOpen] = useState(false);112  const location = useLocation();113  // fiche d'offre : header compact (56 px), sans badge ; favoris + partage114  const isFiche = location.pathname.startsWith("/emploi/");115116  // Hauteur réelle du header → --header-h sur <html> (la carte s'y cale :117  // le 58 px codé en dur était faux quand le header wrappe sur 2 lignes).118  useEffect(() => {119    const el = document.querySelector<HTMLElement>("header.site");120    if (!el) return;121    const set = () =>122      document.documentElement.style.setProperty("--header-h", `${el.offsetHeight}px`);123    set();124    const ro = new ResizeObserver(set);125    ro.observe(el);126    return () => ro.disconnect();127  }, []);128129  // Menu mobile : fermeture au changement de route.130  useEffect(() => {131    setMenuOpen(false);132  }, [location.pathname]);133134  // Menu ouvert : verrou du scroll d'arrière-plan, bulle KA Agent masquée135  // (z-index maximal chez elle) + fermeture à Escape (avec cleanup).136  useEffect(() => {137    if (!menuOpen) return;138    const prev = document.body.style.overflow;139    document.body.style.overflow = "hidden";140    document.body.classList.add("ka-hide-agent");141    const onKey = (e: KeyboardEvent) => {142      if (e.key === "Escape") setMenuOpen(false);143    };144    window.addEventListener("keydown", onKey);145    return () => {146      document.body.style.overflow = prev;147      document.body.classList.remove("ka-hide-agent");148      window.removeEventListener("keydown", onKey);149    };150  }, [menuOpen]);151152  return (153    <FavProvider>154      <header className={`site ${menuOpen ? "menu-open" : ""} ${isFiche ? "header--fiche" : ""}`}>155        <div className="container">156          <NavLink to="/" className="logo">Job<em>·</em>Ka</NavLink>157          {!isFiche && <GroupeKaBadge dark />}158          {isFiche && <FicheHeaderActions />}159          <nav className="main">160            {NAV_LINKS.map((l) => (161              <NavLink162                key={l.to}163                to={l.to}164                end={l.end}165                className={({ isActive }) => (isActive ? "active" : "")}166              >167                {l.label}168              </NavLink>169            ))}170          </nav>171          <div className="auth-box">172            <KaAuth />173          </div>174          <button175            type="button"176            className="menu-btn"177            aria-label={menuOpen ? "Fermer le menu" : "Ouvrir le menu"}178            aria-expanded={menuOpen}179            onClick={() => setMenuOpen((o) => !o)}180          >181            <span />182            <span />183            <span />184          </button>185        </div>186        <div className={menuOpen ? "mobile-menu open" : "mobile-menu"}>187          {/* ✕ fixe du panneau : visible peu importe le scroll, le burger du188              header est masqué à l'ouverture */}189          <button190            type="button"191            className="mm-close"192            aria-label="Fermer le menu"193            onClick={() => setMenuOpen(false)}194            tabIndex={menuOpen ? undefined : -1}195          >196            ✕197          </button>198          {NAV_LINKS.map((l) => (199            <NavLink200              key={l.to}201              to={l.to}202              end={l.end}203              className={({ isActive }) => (isActive ? "active" : "")}204              onClick={() => setMenuOpen(false)}205              tabIndex={menuOpen ? undefined : -1}206            >207              {l.label}208            </NavLink>209          ))}210        </div>211      </header>212      {menuOpen && <div className="mm-backdrop" onClick={() => setMenuOpen(false)} />}213      <Routes>214        <Route path="/" element={<Home />} />215        <Route path="/carte" element={216          <Suspense fallback={<div className="container empty">Chargement de la carte…</div>}>217            <MapPage />218          </Suspense>219        } />220        <Route path="/emploi/:uid" element={<JobPage />} />221        <Route path="/favoris" element={<FavorisPage />} />222        <Route path="/villes" element={<VillesPage />} />223        <Route path="/ville/:ville" element={<VillePage />} />224        <Route path="/sources" element={<Sources />} />225        <Route path="/stats" element={<Stats />} />226        <Route path="/contact" element={<Contact />} />227        <Route path="/employeurs" element={<Employeurs />} />228        <Route path="*" element={<div className="container empty"><h2>Page introuvable</h2></div>} />229      </Routes>230      <div className="prefooter">231        <div className="container">232          <span className="baseline">233            <b>Job-Ka</b> — Tous les emplois des employeurs québécois, directement234            à la source. Chaque offre est traçable à sa source originale, zéro235            boîte noire.236          </span>237          <NavLink to="/">Offres</NavLink>238          <NavLink to="/favoris">Mes favoris</NavLink>239          <NavLink to="/villes">Villes</NavLink>240          <NavLink to="/carte">Carte</NavLink>241          <NavLink to="/sources">Employeurs</NavLink>242          <NavLink to="/stats">Stats</NavLink>243          <NavLink to="/employeurs">Publier une offre</NavLink>244          <NavLink to="/contact">Contact</NavLink>245        </div>246      </div>247      <KaFooter siteId="job-ka" localLegal={[{ label: "Documentation", href: "/doc/" }]} />248    </FavProvider>249  );250}251