spb/forma-ka Public
Python 65.1%
TypeScript 17.9%
CSS 16.4%
HTML 0.5%
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