HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
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