SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
6.7 KB · 185 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Forma-Ka — Agrégateur de formations (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// App.tsx : layout global (header + ticker en direct + footer encre) et routage5// -----------------------------------------------------------------------------6import { useEffect, useState } from "react";7import { NavLink, Route, Routes, useLocation } from "react-router-dom";8import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";9import CookieConsent from "./components/CookieConsent";10import Home from "./pages/Home";11import FormationPage from "./pages/Formation";12import PrivacyPage from "./pages/Privacy";13import SourcesPage from "./pages/Sources";14import StatsPage from "./pages/Stats";1516function Ticker() {17  const [items, setItems] = useState<string[]>([]);1819  useEffect(() => {20    Promise.all([fetchStats(), fetchFacets(), fetchSources()])21      .then(([stats, facets, src]) => {22        registerSourceNames(src.sources);23        const parts: string[] = [`${stats.total} formations actives`];24        if ((stats.gratuites ?? 0) > 0) parts.push(`${stats.gratuites} gratuites`);25        if ((stats.en_ligne ?? 0) > 0) parts.push(`${stats.en_ligne} en ligne`);26        for (const t of (stats.par_type ?? []).slice(0, 5))27          parts.push(`${t.t} · ${t.n}`);28        for (const s of facets.sources.slice(0, 8))29          parts.push(`${sourceName(s.source)} · ${s.n}`);30        parts.push("Mise à jour automatique");31        setItems(parts);32      })33      .catch(() => setItems(["Forma-Ka — toutes les formations du Québec"]));34  }, []);3536  if (items.length === 0) return null;37  // contenu doublé pour une boucle de défilement continue38  return (39    <div className="ticker" aria-hidden="true">40      <div className="ticker-track">41        {[...items, ...items].map((t, i) => (42          <span key={i}>{t}</span>43        ))}44      </div>45    </div>46  );47}4849const NAV_LINKS = [50  { to: "/", label: "Formations", icon: "🎓", end: true },51  { to: "/stats", label: "Stats", icon: "📊", end: false },52  { to: "/sources", label: "Sources", icon: "🗂", end: false },53  { to: "/confidentialite", label: "Confidentialité", icon: "🔒", end: false },54];5556function Header() {57  const [open, setOpen] = useState(false);58  const location = useLocation();5960  // fermer le menu à chaque navigation + verrouiller le défilement en dessous61  useEffect(() => { setOpen(false); }, [location]);62  useEffect(() => {63    document.body.style.overflow = open ? "hidden" : "";64    return () => { document.body.style.overflow = ""; };65  }, [open]);6667  return (68    <>69      <header className="header">70        <div className="container header-inner">71          <NavLink to="/" className="brand" aria-label="Forma-Ka — accueil">72            Forma<span className="ka">Ka</span>73            <span className="brand-tag">Cours · séminaires · ateliers — tout le Québec</span>74          </NavLink>75          <nav className="nav" aria-label="Navigation principale">76            <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>77              Formations78            </NavLink>79            <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>80              Stats81            </NavLink>82            <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>83              Sources84            </NavLink>85          </nav>86          <button87            className={`menu-btn ${open ? "open" : ""}`}88            aria-expanded={open}89            aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}90            onClick={() => setOpen(!open)}91          >92            <span /><span /><span />93          </button>94        </div>9596        {/* menu déroulant mobile */}97        <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">98          {NAV_LINKS.map((l, i) => (99            <NavLink100              key={l.to}101              to={l.to}102              end={l.end}103              style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}104              className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`}105              onClick={() => setOpen(false)}106            >107              <span className="mm-ico" aria-hidden="true">{l.icon}</span>108              {l.label}109              <span className="mm-arrow" aria-hidden="true">→</span>110            </NavLink>111          ))}112          <div className="mm-foot">113            Agrégateur indépendant — mis à jour automatiquement, chaque fiche114            renvoie à la formation originale.115          </div>116        </div>117      </header>118      {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}119      <Ticker />120    </>121  );122}123124function Footer() {125  return (126    <footer className="footer">127      <div className="container">128        <div className="fbrand">129          Forma<span className="ka">Ka</span>130        </div>131        <div className="frow">132          <div>133            <b>Agrégateur indépendant</b> de formations dans la province de Québec —134            cours en ligne, cours universitaires et collégiaux, séminaires, ateliers,135            certifications. Les fiches proviennent des sites publics des établissements136            et sont rafraîchies automatiquement — chaque fiche renvoie vers la page137            originale de la formation.138          </div>139        </div>140        <div className="fmono">141          © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai142          {" · "}143          <NavLink to="/confidentialite">Confidentialité</NavLink>144          {" · "}145          <button146            className="flink"147            onClick={() => window.dispatchEvent(new Event("formaka:openConsent"))}148          >149            Gérer mes témoins150          </button>151        </div>152      </div>153    </footer>154  );155}156157export default function App() {158  return (159    <>160      <Header />161      <main>162        <Routes>163          <Route path="/" element={<Home />} />164          <Route path="/formation/:uid" element={<FormationPage />} />165          <Route path="/stats" element={<StatsPage />} />166          <Route path="/sources" element={<SourcesPage />} />167          <Route path="/confidentialite" element={<PrivacyPage />} />168          <Route169            path="*"170            element={171              <div className="notice container">172                <div className="big">🧭</div>173                <h2>Page introuvable</h2>174                <p>Le lien demandé n'existe pas.</p>175              </div>176            }177          />178        </Routes>179      </main>180      <Footer />181      <CookieConsent />182    </>183  );184}185