Compare commits

...
Sign in to create a new pull request.

22 commits
main ... gms

Author SHA1 Message Date
isUnknown
54c705143b zod -> arktype 2026-05-25 18:29:11 +02:00
isUnknown
ac60ea63ad Note technique : modèle de données révisé, RLS complets, décisions en suspens
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:11:53 +02:00
isUnknown
a549c08240 Activités réelles, bibliothèque dép., note technique, fix btn-p
- tabActiviteClasse : 3ème section "Activités dans la vie réelle" (8 toggles + code déverrouillage offline par item activé)
- Codes de déverrouillage sur les modules quiz activés (colonne Code / Stats)
- generateUnlockCode() dans helpers.js — algo déterministe pour usage offline
- Bibliothèque du département dans Mes modules (5 modules pré-faits, import indépendant)
- showImportActivityModal() implémenté (import par ID)
- docs/NOTE_TECHNIQUE.md : note technique architecture/RGPD/anonymat pour le client
- CSS : btn-p/btn-s/btn-d redéclarés après .btn pour corriger la cascade gms.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:34:13 +02:00
isUnknown
9fab755cf2 Mes classes : colonne actions avec renommer / supprimer (GMS btn--square, Remix Icons)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:17:28 +02:00
isUnknown
c283e0a798 Mes modules : actions-cell GMS sur les boutons icône (gap natif, pas de div wrapper)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:15:28 +02:00
isUnknown
92848488f2 Mes modules : boutons actions en icône seule, style GMS (btn--square)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:10:57 +02:00
isUnknown
f1f071118b CSS : refactorise les variables GMS vers les noms natifs du design system
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:06:25 +02:00
isUnknown
fb4eadcc58 Layout : wrapper fond blanc sur les vues liste GMS
- Ajoute .gms-page-content (fond blanc, bordure, radius, padding, scroll) dans gms.css
- renderGmsShell : paramètre wrapContent (défaut true) — enveloppe frontContent dans .gms-page-content
- Vues liste (accueil, mes-activites, suivi-eleves) : wrapContent true → fond blanc appliqué
- Vues détail (une-classe, une-activite) : wrapContent false → conservent leur .gms-une-classe-content existant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:00:06 +02:00
isUnknown
eb786d09fa Routeur : corrige navigation nav tabs, back button et doubles render()
- Nav tab "Mes classes" : ajoute _flipped:true pour afficher l'interface et non le guide
- Tabs une-classe et une-activite : pushState au lieu de replaceState pour que le back button remonte onglet par onglet
- CSS : routes mes-activites, une-activite, suivi-eleves masquent l'ancienne sidebar (déjà dans le commit précédent, clean)
- Supprime tous les render() redondants après S.navigate() dans modals.js et les vues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:41:53 +02:00
isUnknown
66a9701bd8 GMS reskin : Mes modules et Suivi des élèves intégrés dans le shell GMS
- viewMesActivites : data-table avec thumbnail colorée, badge statut toggle
- viewUneActivite : tabs GMS connectés, data-table pour l'onglet Classes, metric-cards sur Résultats
- viewSuiviEleves : 3 metric-cards + accordion avec data-table intérieur
- Nav tabs GMS branchés sur les 3 routes (accueil, mes-activites, suivi-eleves)
- CSS : règles route-* étendues à mes-activites, une-activite, suivi-eleves pour masquer l'ancienne sidebar
- CLAUDE.md : proto.html est une référence à lire avant d'implémenter, ne jamais modifier

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:43:03 +02:00
isUnknown
39994992a3 Guide contextuel par vue + onglet Activités GMS
- Extrait le contenu du guide dans 3 fichiers indépendants (js/guides/)
  un par vue : classes, modules, suivi — faciles à éditer, supporte les images
- renderGmsShell accepte guideKey pour charger le bon guide dynamiquement
- tabActiviteClasse : remplace les .card par .gms-section (style metric-card)
  avec h2 pour les titres de section et h3 pour les noms de modules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:18:34 +02:00
isUnknown
cf2a88e8b0 Routing URL : associe chaque espace et onglet à une URL propre
- state.js : routeToPath/pathToRoute, navigate via pushState/replaceState,
  popstate handler, initRoute depuis l'URL au chargement, S.setYear()
- accueil.js : goToInterface() navigue ou flippe selon contexte et historique,
  toggleFlip() revient via history.back() quand _flipped, viewAccueil() démarre
  en mode interface si _flipped dans les params
- helpers.js : sélecteur d'année utilise S.setYear()
- index.html : <base href="/"> pour résoudre les assets sur les sous-URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:49:01 +02:00
isUnknown
a7eb201d21 GMS design system : intègre composants natifs (btn, data-table, metric-card, status-badge, tabs)
Porte dans gms.css les composants GMS réels : variables --color-*, color-palettes,
btn (filled/bordered/in-between), data-table, status-badge, container__tabs/tab,
metric-card, overview-grid. Supprime les classes maison gms-table, gms-badge, gms-btn.
Ajoute Remix Icons via CDN. Override CTV orange pour .tab.active et .metric-icon.
Vues accueil et une-classe mises à jour pour utiliser les classes GMS natives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:55:55 +02:00
isUnknown
db527d0322 une-classe : intègre le GMS shell (header, languettes, flip)
viewUneClasse utilise désormais renderGmsShell avec startOnInterface:true.
Tabs Activités/Progression/Élèves dans le flip-front avec styles gms-une-classe-*.
body.route-une-classe hérite des règles CSS de body.route-accueil.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:46:30 +02:00
isUnknown
8b7ec553e7 Accueil : nav tabs hors flip card, copy-to-clipboard sur code classe
- Languettes gauche sorties du contexte 3D, animation translateX
- Copie du code classe au clic avec feedback icône check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:29:03 +02:00
isUnknown
9363facf1f Accueil : guide revu — scroll sur les cards, tab accéder avec texte + picto
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:36:42 +02:00
isUnknown
183d4865f4 CLAUDE.md : ajout des évolutions fonctionnelles prévues
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:21:42 +02:00
isUnknown
e97eb3fac5 Accueil : colonne code classe dans le tableau
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:20:46 +02:00
isUnknown
10b70cca91 Accueil : languette guide animée en deux phases
Remplace le bouton "Voir le guide" par une languette orange (question-line.svg)
qui glisse + disparaît avant le flip, et réapparaît après dans l'autre sens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:10:44 +02:00
isUnknown
c30dd935bb Accueil GMS : skin, logo image, fond noir, flip card guide/interface
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:21:49 +02:00
isUnknown
c313d83332 Refactor proto en architecture multi-fichiers pour le vibe coding
Split du fichier HTML monolithique (1533 lignes, 884KB) en modules séparés :
CSS découpé en 4 fichiers (variables, layout, components, features),
JS découpé en 13 fichiers (db, state, helpers, render, modals, 7 vues).
Ajout CLAUDE.md documentant l'architecture.
Correction : routes tableau-de-bord et acces-libre absentes du dispatch render().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 09:20:02 +02:00
isUnknown
dd479a39b1 add gitignore 2026-05-14 08:47:50 +02:00
40 changed files with 4518 additions and 0 deletions

3
.gitignore vendored
View file

@ -0,0 +1,3 @@
# Claude settings
# -----------
.claude

107
CLAUDE.md Normal file
View file

@ -0,0 +1,107 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Single-page prototype of a teacher-facing interface for "Conquiers Ta Vie", a history gamification app for French middle school students. No build step, no framework, no npm — open `index.html` directly in a browser or via `python3 -m http.server`.
## Architecture
Vanilla JS SPA with a hand-rolled reactive pattern: one global state object (`S`) + a single `render()` function that rewrites `#pageContent` via `innerHTML` on every state change.
### Data flow
```
User action (onclick inline handler)
→ mutates DB and/or S.params
→ calls S.navigate(route, params) or render()
→ render() calls the matching view function
→ view returns an HTML string
→ innerHTML replaces page content
```
No virtual DOM, no diffing. Every `render()` call is a full page repaint.
### File map
| File | Responsibility |
|------|---------------|
| `js/db.js` | `DB` object (mock data: classes, students, activities, results, progression, freeAccess) + `CHAP_CONTENT` + `seedExtra()` (generates extra students at startup) |
| `js/state.js` | `S` object — current route, params, navigation history, quiz editor state. `S.navigate(route, params)` pushes history and calls `render()` |
| `js/helpers.js` | Pure utilities: `cls(id)`, `act(id)`, `filteredClasses()`, `ansScore()`, `totalSteps()`, `escHtml()`, `jsSQ()`, CSV export, `chipFilter()`, `renderYearSelector()`, `progStatusBadge()`, `qPct()`, `pctBadge()`, `showToast()`, sidebar open/close |
| `js/render.js` | `render()` dispatcher, `renderSidebar()`, `renderBackButton()`. Last line calls `render()` to boot the app |
| `js/modals.js` | All `showXxxModal()` / `confirmXxx()` / `doXxx()` functions. Each calls `showModal(html)` which injects into `#modalContainer` |
| `js/views/accueil.js` | `viewAccueil()` — landing page with hero image and navigation tiles |
| `js/views/classes.js` | `viewMesClasses()`, `viewUneClasse()` and its three tabs (`tabEleves`, `tabActiviteClasse`, `tabProgressionClasse`). Student name persistence via `localStorage` |
| `js/views/activites.js` | `viewMesActivites()`, `viewUneActivite()` and its three tabs. `renderHeatGrid()` for per-student answer breakdown |
| `js/views/quiz-builder.js` | `viewCreerActivite()`, `renderQCard()`, drag-and-drop reordering (`_dragSrc`, `_cardSrc`), `saveQuiz()` |
| `js/views/suivi.js` | `viewSuiviEleves()` — cross-class student progression view with accordion and CSV export |
| `js/views/acces-libre.js` | `viewAccesLibre()` (legacy, kept for `toggleFreeAccess`), free chapter access management per class |
| `js/views/tableau-de-bord.js` | `viewTableauDeBord()` with three sub-views (`dashV1/V2/V3`) |
### CSS map
| File | Contains |
|------|---------|
| `css/variables.css` | CSS custom properties, reset, body |
| `css/layout.css` | Sidebar, header, main area, back button, responsive breakpoints |
| `css/components.css` | Buttons, cards, grids, stats, tables, badges, tabs, forms, modals, alerts, chips, toggle switches, empty states, context menu, utilities |
| `css/features.css` | Accueil tiles, class/activity cards, student tags, progress bars, heat grid, accordion, quiz builder, accès libre, dashboard KPIs |
### Script load order (index.html)
`db.js``state.js``helpers.js` → all views → `modals.js``render.js`
`render.js` must be last — it calls `render()` at boot and depends on all view functions being defined.
## Key conventions
**Naming** — `DB` is the global mock database. `S` is global state/router. Helper shorthands: `cls(id)` finds a class, `act(id)` finds an activity.
**Inline event handlers** — all user interactions are `onclick="functionName()"` strings embedded in the HTML returned by view functions. There are no `addEventListener` calls in view code (except drag-and-drop via `initDragDrop()`).
**`jsSQ(value)`** — serialises a JS value to a single-quoted JSON string safe for inline onclick attributes.
**`S.quizEditor`** — transient state for the quiz builder form. Set by `initNewQuiz()` or when entering `creer-activite` with an existing activity. Cleared on save or cancel.
**Progression model** — `DB.progression[studentId] = {c1, c2, c3, c4}` where each chapter value is 04 (steps completed). Total 16 = finished.
**Free access** — `DB.freeAccess[classId]` is a `Set` of strings like `'c2s1'` (chapter 2, step 1) that are unlocked for that class.
**Student names** — stored in `localStorage` as `sname_<code>`. Never sent to a server. Applied after render via `initStudentNames()`.
## Known issues in the original proto
- `viewTableauDeBord` and `viewAccesLibre` routes were missing from `render()`'s dispatch table — fixed in this refactor.
- `viewAccesLibre` is marked "ANCIEN" in a comment; the free access UI was moved into the `tabActiviteClasse` tab of `une-classe`. The function and `toggleFreeAccess` helper are kept because they may still be navigated to.
## GMS reskin — état d'avancement
**Objectif** : couler le proto dans le design de `/Users/adrienpayet/Documents/code/en-cours/world-game-gms` (Svelte/TS, mais on copie uniquement le design system CSS + tokens).
**Référence de design** : Le code source GMS complet est à `/Users/adrienpayet/Documents/code/en-cours/world-game-gms`. C'est là qu'on puise les composants CSS, les patterns de layout et les interactions.
**Fichier proto.html** : `proto.html` (à la racine du projet) est un fichier de référence fonctionnelle — il contient des prototypes de vues avec leur contenu et structure. **Ne jamais le modifier.** Avant d'implémenter une nouvelle vue GMS, lire `proto.html` pour comprendre quelles fonctionnalités et quelle structure sont attendues, puis les adapter au design system GMS dans les fichiers JS correspondants.
**Fait** :
- `css/gms.css` — design system GMS adapté CTV (orange primaire, police Danzza, variables, composants)
- `js/views/accueil.js` — refait avec skin GMS : header `.gms-header`, tableau `.gms-table` des classes avec initiales colorées et badges progression
**Reste** :
- `js/views/classes.js` (`viewMesClasses`, `viewUneClasse`)
- `js/views/activites.js` (`viewMesActivites`, `viewUneActivite`)
- `js/views/quiz-builder.js`
- `js/views/suivi.js`
- `js/views/tableau-de-bord.js`
- `js/render.js` sidebar (`.sb-item` → classes GMS)
- Modals (`js/modals.js`)
## Évolutions fonctionnelles prévues
### Activation de modules pour une classe
- En plus des 8 toggles quiz et 8 toggles aventure, ajouter **8 toggles "activités dans la vie réelle"** (ex : visites culturelles). Même pattern que les autres types.
- Pour chaque module **coché/activé**, afficher un **code de déverrouillage** que l'enseignant peut communiquer à l'élève. Ce code permet à l'élève de débloquer le module **sans connexion internet** (usage hors-ligne). Côté élève : un bouton "Débloquer par code" avec un champ de saisie.
### Modules personnalisés — bibliothèque départementale
- Dans la section de création/gestion des modules personnalisés, ajouter une **bibliothèque de modules fournis par le département**. Les enseignants peuvent y piocher des modules prêts à l'emploi et les réutiliser sans tout créer eux-mêmes.

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"/><meta name="theme-color" content="#000000"/><meta id="metaDescription" name="description" content="World Game conçoit des expériences de gamification sur-mesure pour booster l'engagement client, la fidélité et la productivité interne."/><link rel="apple-touch-icon" href="/apple-touch-icon.png"/><script>!function(){var e=navigator.language||navigator.userLanguage;e=e.substring(0,2),document.documentElement.lang=e;var t={en:{title:"World Game - Gamification Studio",description:"World Game designs custom gamification experiences to boost customer engagement, loyalty and internal productivity."},fr:{title:"World Game Studio de Gamification",description:"World Game conçoit des expériences de gamification sur-mesure pour booster l'engagement client, la fidélité et la productivité interne."}},i=t[e]||t.fr;document.title=i.title,document.getElementById("metaDescription").setAttribute("content",i.description)}()</script><script>!function(){function e(){let e=.01*window.innerHeight;document.documentElement.style.setProperty("--vh",e+"px")}e(),window.addEventListener("resize",e),window.addEventListener("orientationchange",(function(){setTimeout(e,100)}))}()</script><link rel="manifest" href="/manifest.json"/><title>World Game Studio de Gamification</title><script defer="defer" src="/static/js/main.abe4973f.js"></script><link href="/static/css/main.b55722c0.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"/><meta name="theme-color" content="#000000"/><meta id="metaDescription" name="description" content="World Game conçoit des expériences de gamification sur-mesure pour booster l'engagement client, la fidélité et la productivité interne."/><link rel="apple-touch-icon" href="/apple-touch-icon.png"/><script>!function(){var e=navigator.language||navigator.userLanguage;e=e.substring(0,2),document.documentElement.lang=e;var t={en:{title:"World Game - Gamification Studio",description:"World Game designs custom gamification experiences to boost customer engagement, loyalty and internal productivity."},fr:{title:"World Game Studio de Gamification",description:"World Game conçoit des expériences de gamification sur-mesure pour booster l'engagement client, la fidélité et la productivité interne."}},i=t[e]||t.fr;document.title=i.title,document.getElementById("metaDescription").setAttribute("content",i.description)}()</script><script>!function(){function e(){let e=.01*window.innerHeight;document.documentElement.style.setProperty("--vh",e+"px")}e(),window.addEventListener("resize",e),window.addEventListener("orientationchange",(function(){setTimeout(e,100)}))}()</script><link rel="manifest" href="/manifest.json"/><title>World Game Studio de Gamification</title><script defer="defer" src="/static/js/main.abe4973f.js"></script><link href="/static/css/main.b55722c0.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"/><meta name="theme-color" content="#000000"/><meta id="metaDescription" name="description" content="World Game conçoit des expériences de gamification sur-mesure pour booster l'engagement client, la fidélité et la productivité interne."/><link rel="apple-touch-icon" href="/apple-touch-icon.png"/><script>!function(){var e=navigator.language||navigator.userLanguage;e=e.substring(0,2),document.documentElement.lang=e;var t={en:{title:"World Game - Gamification Studio",description:"World Game designs custom gamification experiences to boost customer engagement, loyalty and internal productivity."},fr:{title:"World Game Studio de Gamification",description:"World Game conçoit des expériences de gamification sur-mesure pour booster l'engagement client, la fidélité et la productivité interne."}},i=t[e]||t.fr;document.title=i.title,document.getElementById("metaDescription").setAttribute("content",i.description)}()</script><script>!function(){function e(){let e=.01*window.innerHeight;document.documentElement.style.setProperty("--vh",e+"px")}e(),window.addEventListener("resize",e),window.addEventListener("orientationchange",(function(){setTimeout(e,100)}))}()</script><link rel="manifest" href="/manifest.json"/><title>World Game Studio de Gamification</title><script defer="defer" src="/static/js/main.abe4973f.js"></script><link href="/static/css/main.b55722c0.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

BIN
assets/images/CTV-mood.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

8
assets/images/Check.svg Normal file
View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" id="Check--Streamline-Sharp-Remix">
<desc>
Check Streamline Icon: https://streamlinehq.com
</desc>
<g id="check--check-form-validation-checkmark-success-add-addition-tick">
<path id="Union" fill="#000000" fill-rule="evenodd" d="M23.76 3.6009 10.0667 20.8454l-0.8622 1.0858 -0.9768 -0.9841L0.24 12.8997l1.7486 -1.7356 7.011 7.0633L21.8305 2.0688 23.76 3.6009Z" clip-rule="evenodd" stroke-width="1"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 504 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6.9998 6V3C6.9998 2.44772 7.44752 2 7.9998 2H19.9998C20.5521 2 20.9998 2.44772 20.9998 3V17C20.9998 17.5523 20.5521 18 19.9998 18H16.9998V20.9991C16.9998 21.5519 16.5499 22 15.993 22H4.00666C3.45059 22 3 21.5554 3 20.9991L3.0026 7.00087C3.0027 6.44811 3.45264 6 4.00942 6H6.9998ZM5.00242 8L5.00019 20H14.9998V8H5.00242ZM8.9998 6H16.9998V16H18.9998V4H8.9998V6Z"></path></svg>

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM10.6219 8.41459C10.5562 8.37078 10.479 8.34741 10.4 8.34741C10.1791 8.34741 10 8.52649 10 8.74741V15.2526C10 15.3316 10.0234 15.4088 10.0672 15.4745C10.1897 15.6583 10.4381 15.708 10.6219 15.5854L15.5008 12.3328C15.5447 12.3035 15.5824 12.2658 15.6117 12.2219C15.7343 12.0381 15.6846 11.7897 15.5008 11.6672L10.6219 8.41459Z"></path></svg>

After

Width:  |  Height:  |  Size: 542 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path></svg>

After

Width:  |  Height:  |  Size: 634 B

View file

@ -0,0 +1,20 @@
Échéance : novembre / décembre 2026
Une histoire dont vous êtes le héro. Principalement narratif. Module quizz / module introspectif, mini-jeu.
Lenseignant a un outil de suivi et de création de contenu perso (GMS).
Enjeu RGPD : lenseignant peut créer une classe, ça génère des IDs anonymes quil distribue à ses élèves. Mais il ne faut pas quil y ait de noms ou quoi que ce soit dans la plateforme.
- Création de classe
- suivi : par élève, par classe. Intégré au GMS ou pas ? Peut-être un bloc dans le GMS mais quon peut intégrer à linterface de conquiers ta vie.
- question : les quizzs (modules introspectifs) ne vont pas changer. Mais est-ce quon ne les intègre pas quand même au GMS pour pouvoir à lavenir
- Il faut que les enseignants puissent se saisir dun catalogue de jeu (5 jeux) et créer leurs parties
Postes :
- Back-end serveur
- RGPD
- suivi
- création dID
- création de parties

View file

@ -0,0 +1,31 @@
- [ ] Pour le 22 mai : note technique décrivant stack, stratégie danonymat, architecture, stratégie RGPD, API (authentification), sécurisation des formulaires, injection, structure de donnée, communication avec la BDD…
- [ ] Déplacer le GMS sur lhébergement world.game (domaine chez Namecheap)
⟶ À intégrer à une note plus large qui spécifie également le fonctionnement des jeux.
# Atelier design
## Page daccueil
Dashboard + wiki
### Mes classes
Cards :
- Supprimer lannée
### Une classe
# Merge GMS
# RGPD / point de vigilance technique
Il faut que un chemin API permettant de supprimer un élève (son code et toutes les données associées). Cest côté interface élève quon fait la demande. Mais il faut une fonction supabase permettant de supprimer.
De mon côté, il faut que les profs puissent aussi supprimer leur compte complètement. Et ça il faut pouvoir le faire de mon côté.
Pour créer un compte, il faut que chaque collège ait un code créé et donné par le world game. Le collège le donne a ses enseignants, ce qui leur permet de créer un compte. De cette manière, pas besoin de faire un formulaire exposé (que les robots pourrait matraquer). Et on conserve lanonymat.
Il faut que les profs puissent changer le nom dune classe (ex 5eme A). Problème : est-ce que ça change pour les autres aussi ? Est-ce quon fait une BDD qui fait correspondre 1 prof, 1 classe (code), 1 nom ?

579
css/components.css Normal file
View file

@ -0,0 +1,579 @@
/* ===== BUTTONS ===== */
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s;
white-space: nowrap;
}
.btn-p {
background: var(--accent);
color: #fff;
box-shadow: 0 1px 4px rgba(232, 146, 42, 0.3);
}
.btn-p:hover:not(:disabled) {
background: var(--accent-l);
}
.btn-a {
background: var(--primary);
color: #fff;
}
.btn-a:hover {
background: var(--primary-l);
}
.btn-s {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
.btn-s:hover {
background: var(--bg);
}
.btn-g {
background: transparent;
color: var(--muted);
border: none;
}
.btn-g:hover {
background: var(--bg);
color: var(--text);
}
.btn-d {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fecaca;
}
.btn-d:hover {
background: #fecaca;
}
.btn-teal {
background: #e6f7f0;
color: #1a9a55;
border: 1px solid #bbe8d3;
}
.btn-teal:hover {
background: #bbe8d3;
}
.btn-sm {
padding: 5px 12px;
font-size: 12px;
}
.btn-ico {
padding: 7px;
border-radius: 6px;
background: transparent;
border: 1px solid var(--border);
cursor: pointer;
font-size: 14px;
transition: all 0.15s;
line-height: 1;
}
.btn-ico:hover {
background: var(--bg);
}
.btn:disabled {
opacity: 0.38;
cursor: not-allowed;
}
/* ===== CARDS ===== */
.card {
background: var(--surface);
border-radius: 12px;
border: 1px solid var(--border);
overflow: hidden;
}
.card-hd {
padding: 15px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.card-title {
font-weight: 700;
font-size: 14px;
}
/* ===== GRIDS ===== */
.g2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.g3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
.g4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
/* ===== STATS ===== */
.stat {
background: var(--surface);
border-radius: 12px;
border: 1px solid var(--border);
padding: 18px 20px;
}
.stat-label {
font-size: 10px;
color: var(--muted);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.stat-val {
font-size: 26px;
font-weight: 800;
line-height: 1;
color: var(--text);
}
.stat-sub {
font-size: 11px;
color: var(--muted);
margin-top: 4px;
}
/* ===== TABLES ===== */
.tw {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
padding: 9px 14px;
text-align: left;
font-size: 10px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f8f5ef;
border-bottom: 1px solid var(--border);
}
tbody td {
padding: 10px 14px;
font-size: 13px;
border-bottom: 1px solid var(--border);
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover {
background: #f8f5ef;
}
/* ===== BADGES ===== */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
}
.b-ok {
background: #d1fae5;
color: #065f46;
}
.b-warn {
background: #fef9c3;
color: #854d0e;
}
.b-info {
background: #dbeafe;
color: #1d4ed8;
}
.b-gray {
background: #f1f5f9;
color: #475569;
}
.b-draft {
background: #f3f4f6;
color: #6b7280;
}
.b-amber {
background: #fef3c7;
color: #92400e;
}
.badge-toggle {
cursor: pointer;
transition: filter 0.15s;
}
.badge-toggle:hover {
filter: brightness(0.9);
}
/* ===== TABS ===== */
.tabs {
display: flex;
border-bottom: 2px solid var(--border);
margin-bottom: 22px;
}
.tab {
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.15s;
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ===== FORMS ===== */
.fg {
margin-bottom: 16px;
}
label {
display: block;
font-size: 12px;
font-weight: 700;
color: var(--text);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.label-hint {
font-size: 11px;
color: var(--muted);
font-weight: 400;
text-transform: none;
letter-spacing: 0;
margin-left: 5px;
}
input[type='text'],
textarea,
select.form-sel {
width: 100%;
padding: 9px 13px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
color: var(--text);
background: var(--surface);
transition: border-color 0.15s;
font-family: inherit;
}
input:focus,
textarea:focus,
select.form-sel:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(232, 146, 42, 0.12);
}
textarea {
resize: vertical;
min-height: 70px;
}
/* ===== MODALS ===== */
.ov {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal {
background: var(--surface);
border-radius: 14px;
width: 100%;
max-width: 520px;
max-height: 92vh;
overflow-y: auto;
}
.modal-lg {
max-width: 660px;
}
.mhd {
padding: 16px 22px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.mhd h2 {
font-size: 16px;
font-weight: 700;
}
.mbody {
padding: 22px;
}
.mfoot {
padding: 13px 22px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* ===== TIPS & ALERTS ===== */
.tip {
background: #fff8ec;
border: 1px solid #f5d99a;
border-radius: 10px;
padding: 13px 15px;
font-size: 13px;
color: #92400e;
margin-bottom: 12px;
}
.tip-title {
font-weight: 700;
margin-bottom: 4px;
}
.tip-body {
color: #78350f;
line-height: 1.5;
}
.tip-blue {
background: #eff6ff;
border-color: #bfdbfe;
color: #1d4ed8;
}
.tip-blue .tip-body {
color: #1e40af;
}
.alert {
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 14px;
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 1.5;
}
.alert-info {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.alert-warn {
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
}
.alert-ok {
background: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}
/* ===== CHIPS ===== */
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.chip {
padding: 5px 13px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 2px solid var(--border);
background: var(--surface);
color: var(--muted);
transition: all 0.15s;
user-select: none;
}
.chip:hover {
border-color: var(--accent);
color: var(--accent);
}
.chip.on {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* ===== TOGGLE SWITCH ===== */
.toggle-sw {
position: relative;
width: 36px;
height: 20px;
cursor: pointer;
flex-shrink: 0;
}
.toggle-sw input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-track {
position: absolute;
inset: 0;
background: #d1d5db;
border-radius: 20px;
transition: background 0.2s;
}
.toggle-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.toggle-sw input:checked + .toggle-track {
background: var(--ok);
}
.toggle-sw input:checked ~ .toggle-thumb {
transform: translateX(16px);
}
/* ===== EMPTY STATE ===== */
.empty {
text-align: center;
padding: 52px 20px;
color: var(--muted);
}
.empty-ico {
font-size: 42px;
margin-bottom: 12px;
}
.empty h3 {
font-size: 16px;
font-weight: 700;
color: var(--text);
margin-bottom: 5px;
}
.empty p {
font-size: 13px;
margin-bottom: 16px;
}
/* ===== CONTEXT MENU ===== */
.ctx-item {
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
color: var(--text);
transition: background 0.1s;
}
.ctx-item:hover {
background: var(--bg);
}
.ctx-danger {
color: #cc3333;
}
.ctx-danger:hover {
background: #fff0f0;
}
/* ===== UTILITIES ===== */
.flex {
display: flex;
}
.fbet {
display: flex;
justify-content: space-between;
align-items: center;
}
.g8 {
gap: 8px;
}
.g10 {
gap: 10px;
}
.g12 {
gap: 12px;
}
.g16 {
gap: 16px;
}
.mb4 {
margin-bottom: 4px;
}
.mb8 {
margin-bottom: 8px;
}
.mb12 {
margin-bottom: 12px;
}
.mb14 {
margin-bottom: 14px;
}
.mb16 {
margin-bottom: 16px;
}
.mb20 {
margin-bottom: 20px;
}
.mb24 {
margin-bottom: 24px;
}
.mb28 {
margin-bottom: 28px;
}
.mt8 {
margin-top: 8px;
}
.mt12 {
margin-top: 12px;
}
.mt16 {
margin-top: 16px;
}
.mt20 {
margin-top: 20px;
}
.muted {
color: var(--muted);
}
.sm {
font-size: 13px;
}
.xs {
font-size: 11px;
}
.semi {
font-weight: 600;
}
.bold {
font-weight: 700;
}
.center {
text-align: center;
}
.w100 {
width: 100%;
}
.ms {
margin-left: 6px;
}
.divider {
border: none;
border-top: 1px solid var(--border);
margin: 18px 0;
}

95
css/features.css Normal file
View file

@ -0,0 +1,95 @@
/* ===== ACCUEIL / LANDING ===== */
.hero-wrap{border-radius:16px;overflow:hidden;margin-bottom:24px;position:relative;height:190px;}
.hero-tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;}
.hero-tile{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:24px 20px;cursor:pointer;transition:all .2s;display:flex;flex-direction:column;gap:10px;}
.hero-tile:hover{transform:translateY(-2px);box-shadow:0 6px 24px rgba(0,0,0,.1);}
.landing-hero{width:100%;max-height:70vh;object-fit:cover;display:block;border-radius:16px;margin-bottom:32px;}
.landing-section{max-width:700px;margin:0 auto 40px;}
.landing-section h2{font-size:22px;font-weight:700;margin-bottom:12px;color:var(--text);}
.landing-section p{font-size:15px;line-height:1.7;color:#555;margin-bottom:10px;}
/* ===== CLASS & ACTIVITY CARDS ===== */
.cls-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:20px;cursor:pointer;transition:all .2s;}
.cls-card:hover{border-color:var(--accent);box-shadow:0 4px 20px rgba(232,146,42,.12);transform:translateY(-1px);}
.cls-icon{width:42px;height:42px;border-radius:10px;background:linear-gradient(135deg,var(--primary),#3A3020);display:flex;align-items:center;justify-content:center;color:#fff;font-size:19px;}
.cls-name{font-size:17px;font-weight:700;margin:12px 0 3px;}
.cls-meta{font-size:12px;color:var(--muted);}
.cls-stats{display:flex;margin-top:14px;padding-top:14px;border-top:1px solid var(--border);}
.cls-stat{flex:1;text-align:center;}
.cls-stat-v{font-size:18px;font-weight:800;color:var(--accent);}
.cls-stat-l{font-size:10px;color:var(--muted);margin-top:2px;}
.act-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px;cursor:pointer;transition:all .2s;display:flex;flex-direction:column;gap:10px;}
.act-card:hover{border-color:var(--accent);box-shadow:0 4px 20px rgba(232,146,42,.12);}
.act-name{font-size:15px;font-weight:700;}
.module-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);}
.module-row:last-child{border-bottom:none;}
/* ===== STUDENT TAGS ===== */
.stu-tags{display:flex;flex-wrap:wrap;gap:8px;padding:4px 0;}
.stu-tag{display:inline-flex;align-items:center;gap:6px;background:#F5F0EA;border:1px solid var(--border);border-radius:20px;padding:4px 10px 4px 12px;position:relative;transition:border-color .15s;}
.stu-tag:hover{border-color:#C4A882;}
.stu-code{font-family:monospace;font-size:13px;font-weight:700;color:var(--text);background:none;padding:0;}
.stu-name{font-size:12px;color:var(--muted);cursor:pointer;transition:color .15s;white-space:nowrap;}
.stu-name:hover{color:var(--primary);}
.stu-name.stu-name-set{color:#6B4F2A;font-style:italic;}
.stu-del{display:none;background:none;border:none;cursor:pointer;color:#CC3333;font-size:14px;padding:0 2px;line-height:1;}
.stu-tag:hover .stu-del{display:inline;}
/* ===== PROGRESS BAR ===== */
.prog-bar{height:7px;background:var(--border);border-radius:4px;overflow:hidden;width:100%;min-width:70px;}
.prog-fill{height:100%;border-radius:4px;background:var(--accent);transition:width .3s;}
/* ===== HEAT GRID ===== */
.heat-wrap{overflow-x:auto;padding:12px 0;}
.heat-table{border-collapse:separate;border-spacing:3px;font-size:12px;}
.heat-table th{padding:4px 6px;text-align:center;font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;white-space:nowrap;}
.heat-table th.ht-code{text-align:left;min-width:90px;}
.heat-table td.ht-code{font-family:monospace;font-size:12px;font-weight:700;padding:3px 8px 3px 0;color:var(--text);white-space:nowrap;}
.hcell{width:26px;height:26px;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;cursor:default;transition:transform .1s,box-shadow .1s;position:relative;}
.hcell:hover{transform:scale(1.35);z-index:10;box-shadow:0 3px 8px rgba(0,0,0,.2);}
.hc-ok{background:#22A05E;color:#fff;}
.hc-no{background:#E05050;color:#fff;}
.hc-none{background:#EDE8E0;color:#9A8A78;}
.ht-score{font-weight:700;font-size:12px;text-align:right;padding:3px 0 3px 8px;}
.pct-row-heat th,.pct-row-heat td{padding:2px 0;font-size:11px;}
.pct-badge{display:inline-block;width:26px;height:26px;border-radius:4px;text-align:center;line-height:26px;font-size:10px;font-weight:700;}
.pb-hi{background:#C7F0DC;color:#065F46;}
.pb-mid{background:#DBEAFE;color:#1D4ED8;}
.pb-lo{background:#FFD9D9;color:#991B1B;}
.pb-na{background:#EDE8E0;color:#9A8A78;}
/* ===== ACCORDION ===== */
.acc-cls{background:var(--surface);border:1px solid var(--border);border-radius:12px;margin-bottom:10px;overflow:hidden;}
.acc-hd{padding:14px 18px;display:flex;align-items:center;justify-content:space-between;cursor:pointer;transition:background .15s;gap:12px;}
.acc-hd:hover{background:#F8F5EF;}
.acc-hd.open{border-bottom:1px solid var(--border);}
/* ===== QUIZ BUILDER ===== */
.q-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px;margin-bottom:13px;position:relative;}
.q-card.drag-over{border-color:var(--accent);background:#FFF8EC;}
.q-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:13px;}
.q-num{font-size:11px;font-weight:700;color:var(--accent);background:#FFF0D9;padding:3px 10px;border-radius:20px;}
.q-drag{cursor:grab;color:var(--muted);font-size:16px;padding:2px 5px;}.q-drag:active{cursor:grabbing;}
.ans-row{display:flex;align-items:center;gap:8px;margin-bottom:8px;}
.ans-letter{width:26px;height:26px;border-radius:50%;background:var(--bg);border:2px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0;}
.ans-row.correct .ans-letter{background:#D1FAE5;border-color:var(--ok);color:#065F46;}
.ans-row input[type=text]{flex:1;}
.char-count{font-size:10px;color:var(--muted);text-align:right;margin-top:2px;}
.char-count.near{color:#92400E;}.char-count.over{color:var(--danger);font-weight:700;}
/* ===== ACCÈS LIBRE ===== */
.al-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px;}
.al-chap{background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;}
.al-chap-hd{padding:10px 14px;background:#F8F5EF;border-bottom:1px solid var(--border);font-weight:700;font-size:13px;}
.al-item{display:flex;align-items:center;justify-content:space-between;padding:9px 14px;border-bottom:1px solid var(--border);font-size:12px;}
.al-item:last-child{border-bottom:none;}
.al-item-label{display:flex;align-items:center;gap:7px;}
/* ===== DASHBOARD ===== */
.dash-tab-btns{display:flex;gap:8px;margin-bottom:22px;}
.dash-tab-btn{padding:8px 18px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:2px solid var(--border);background:var(--surface);color:var(--muted);transition:all .15s;}
.dash-tab-btn.active{border-color:var(--accent);background:#FFF8EC;color:var(--accent);}
.kpi-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;margin-bottom:22px;}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:16px;}
.kpi-val{font-size:28px;font-weight:800;color:var(--text);line-height:1;}
.kpi-label{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:4px;}

1024
css/gms.css Normal file

File diff suppressed because it is too large Load diff

40
css/layout.css Normal file
View file

@ -0,0 +1,40 @@
/* ===== SIDEBAR ===== */
.sb{position:fixed;left:0;top:0;bottom:0;width:var(--sw);background:var(--primary);display:flex;flex-direction:column;z-index:200;transition:transform .25s;}
.sb-logo{padding:0;border-bottom:1px solid rgba(255,255,255,.08);cursor:pointer;transition:opacity .15s;display:block;}
.sb-logo:hover{opacity:.85;}
.sb-year{padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.08);}
.sb-year label{display:block;font-size:10px;font-weight:600;color:rgba(255,255,255,.38);letter-spacing:.06em;text-transform:uppercase;margin-bottom:4px;}
.sb-year select{width:100%;background:rgba(255,255,255,.07);color:#fff;border:1px solid rgba(255,255,255,.12);border-radius:7px;padding:6px 10px;font-size:13px;font-weight:500;cursor:pointer;outline:none;}
.sb-year select option{background:#2a2318;color:#fff;}
.sb-nav{flex:1;padding:8px 0;overflow-y:auto;}
.sb-section{padding:8px 16px 3px;font-size:10px;font-weight:700;color:rgba(255,255,255,.28);letter-spacing:.07em;text-transform:uppercase;}
.sb-item{display:flex;align-items:center;gap:11px;padding:9px 20px;color:rgba(255,255,255,.6);cursor:pointer;transition:all .15s;font-size:13px;border-left:3px solid transparent;}
.sb-item:hover{background:rgba(255,255,255,.06);color:rgba(255,255,255,.9);}
.sb-item.active{background:rgba(232,146,42,.12);color:#fff;border-left-color:var(--accent);}
.sb-item .ico{width:19px;text-align:center;font-size:15px;flex-shrink:0;}
.sb-foot{padding:12px 16px;border-top:1px solid rgba(255,255,255,.08);}
.sb-user{display:flex;align-items:center;gap:10px;}
.sb-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:12px;flex-shrink:0;}
.sb-uname{color:rgba(255,255,255,.85);font-size:12px;font-weight:600;}
.sb-support-btn{display:flex;align-items:center;gap:9px;padding:9px 18px;cursor:pointer;color:rgba(255,255,255,.45);font-size:13px;border-top:1px solid rgba(255,255,255,.08);transition:color .15s,background .15s;margin-bottom:0;}
.sb-support-btn:hover{color:rgba(255,255,255,.85);background:rgba(255,255,255,.06);}
.overlay-sb{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:150;}
/* ===== HEADER & MAIN ===== */
.main{margin-left:var(--sw);min-height:100vh;display:flex;flex-direction:column;}
.hdr{background:var(--surface);border-bottom:1px solid var(--border);padding:0 28px;height:var(--hh);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;}
.bc{display:flex;align-items:center;gap:6px;font-size:13px;}
.bc-item{color:var(--muted);cursor:pointer;}.bc-item:hover{color:var(--text);text-decoration:underline;}
.bc-cur{color:var(--text);font-weight:600;cursor:default;}.bc-sep{color:var(--border);}
.proto-badge{background:#FEF3C7;color:#92400E;font-size:10px;font-weight:700;padding:3px 9px;border-radius:20px;border:1px solid #FDE68A;letter-spacing:.03em;}
.hdr-menu-btn{display:none;background:none;border:none;font-size:20px;cursor:pointer;padding:6px;}
.page{flex:1;padding:28px 32px;}
.ph{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:24px;gap:16px;}
.pt{font-size:22px;font-weight:700;color:var(--text);}
.ps{font-size:13px;color:var(--muted);margin-top:3px;}
.back-btn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:13px;padding:4px 0;display:inline-flex;align-items:center;gap:5px;font-weight:500;transition:color .15s;}
.back-btn:hover{color:var(--text);}
/* ===== RESPONSIVE ===== */
@media(max-width:1100px){.g3{grid-template-columns:repeat(2,1fr);}.g4{grid-template-columns:repeat(2,1fr);}.kpi-strip{grid-template-columns:repeat(3,1fr);}.al-grid{grid-template-columns:repeat(2,1fr);}}
@media(max-width:768px){:root{--sw:0px;}.sb{transform:translateX(-252px);width:252px;}.sb.open{transform:translateX(0);}.overlay-sb.open{display:block;}.main{margin-left:0;}.page{padding:18px 14px;}.hdr{padding:0 14px;}.hdr-menu-btn{display:block;}.g2,.g3,.g4,.kpi-strip{grid-template-columns:1fr;}.ph{flex-direction:column;gap:10px;}.al-grid{grid-template-columns:1fr 1fr;}}

3
css/variables.css Normal file
View file

@ -0,0 +1,3 @@
:root{--primary:#1A1510;--primary-l:#2A2318;--accent:#E8922A;--accent-l:#F0AA50;--accent-d:#C47820;--bg:#F7F4EF;--surface:#FFF;--text:#1C1A15;--muted:#7A7060;--border:#E3DDD5;--ok:#1A9A55;--danger:#D94040;--sw:252px;--hh:58px;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;}

494
docs/NOTE_TECHNIQUE.md Normal file
View file

@ -0,0 +1,494 @@
# Note technique : Conquiers Ta Vie
## Interface enseignant & API · Architecture, sécurité & conformité RGPD
**Version** : 1.0, mai 2026
---
## Décisions en suspens
Les points suivants sont identifiés dans ce document comme nécessitant un arbitrage avant finalisation ou mise en production.
| # | Sujet | Section | Statut |
| --- | ------------------------------------------------------------------------------------------------------------------ | ------- | ------------------------------------ |
| 1 | Durée de conservation des données de progression élève | 5.3 | À définir |
| 2 | Structure de la table de progression (chapitres, étapes, conditions) | 3.7 | À préciser avec l'équipe World Game |
| 3 | Désactivation sans suppression d'un code élève (`is_active`) | 3.4 | À décider |
| 4 | Tentatives multiples sur une activité (contrainte UNIQUE sur `activity_results`) | 3.8 | À décider |
| 5 | Rédaction et signature du DPA | 5.1 | À produire avant mise en production |
| 6 | Séparation `free_access` (aventure principale) et accès activités vie réelle (actuellement mélangés dans le proto) | 3.9 | À modéliser |
| 7 | Spécifications API à transmettre à l'équipe app tierce | 6.2 | À rédiger (Arthur Deleye) |
| 8 | Choix du domaine : `conquiers-ta-vie.com` (Infomaniak) ou sous-domaine `world-game` | 10.4 | À confirmer |
| 9 | Signature DPA World Game / Département | 5.1 | À planifier (Jules Zimmermann / DSI) |
| 10 | Périmètre et calendrier audit RGPD DRAM | 5.5 | Prévu septembre (DRAM) |
| 11 | Stratégie de monitoring applicatif (seuils, astreinte, escalade) | 11.2 | À définir avant mise en production |
---
## Résumé exécutif
World Game développe deux composants : une **interface web enseignant** (SvelteKit) permettant de gérer les classes, créer des modules pédagogiques et suivre la progression des élèves, ainsi qu'une **API REST** que les applications élèves tierces (développées indépendamment, déployées sur les tablettes JAMF) consomment pour authentifier les élèves, récupérer les contenus assignés et soumettre la progression.
L'architecture retenue repose sur des services managés éprouvés : **Supabase** pour la base de données et l'authentification, **Infomaniak** pour l'hébergement, conformément aux échanges menés lors de la réunion avec la DSI et la DTNI.
**Points clés :**
- **Anonymat total côté élève** : aucun nom, email ou identifiant nominatif n'est stocké dans le système. Les élèves sont identifiés uniquement par un code opaque (ex. `BR37-14763`) généré aléatoirement par l'enseignant. La correspondance code ↔ élève reste dans la classe, hors de tout système numérique.
- **Inscription verrouillée par code établissement** : la page d'inscription est accessible publiquement, mais la création de compte est impossible sans un code fourni par World Game à l'établissement. Un formulaire sans ce code est rejeté côté serveur : aucun compte non autorisé ne peut être créé.
- **Hébergement souverain** : Infomaniak (Suisse, certifié ISO 27001), données non soumises au Cloud Act américain.
- **Conformité RGPD** : la seule donnée personnelle traitée côté serveur est l'email de l'enseignant. Les données élèves relèvent de la pseudonymisation et ne permettent pas de réidentifier un individu, même en cas d'accès complet à la base de données.
- **Intégration native** : whitelist Netskope confirmée, déploiement via JAMF School, auto-sauvegarde avant la coupure automatique de 21h.
---
## 1. Stack technique
| Couche | Technologie | Justification |
| ---------------------------------- | ------------------------------ | ---------------------------------------------------------- |
| Interface enseignant (web) | SvelteKit · TypeScript | Framework moderne, typage fort, SSR natif |
| API publique (apps élèves tierces) | Supabase Edge Functions · Deno | Endpoints REST documentés, auth JWT, validation serveur |
| Base de données | Supabase · PostgreSQL | Managé, Row Level Security natif, sauvegardes automatiques |
| Authentification enseignant | Supabase Auth | JWT signé, gestion sessions, compatible PKCE |
| Hébergement | Infomaniak | Suisse · ISO 27001 · RGPD-natif · forte politique RSE |
| CDN / assets statiques | Infomaniak ou Cloudflare | TLS strict, cache distribué |
---
## 2. Architecture générale
```
┌──────────────────────┐ ┌─────────────────────────────┐
│ Apps élèves │ API REST · HTTPS │ Supabase Edge Functions │
│ (tierces, tablettes │ ───────────────────────▶ │ API publique documentée │
│ JAMF, hors scope) │ └──────────────┬──────────────┘
└──────────────────────┘ │
┌──────────────────────┐ Supabase JS · HTTPS ┌──────────────▼──────────────┐
│ Interface SvelteKit │ ───────────────────────▶ │ PostgreSQL + Auth │
│ (Enseignant · web) │ │ Row Level Security │
└──────────────────────┘ └─────────────────────────────┘
```
**Points d'attention infrastructure :**
- **Domaine API** : `api.conquiers-ta-vie.com` (sous-réserve de confirmation, voir point 8 des décisions en suspens), à whitelister dans Netskope (interlocuteur : Fabien Benard).
- **Trois environnements** :
- **Développement** : environnement local des développeurs World Game.
- **Pré-production** : projet Supabase dédié, données fictives. Accès partagé avec le client à des moments clés (démonstrations, tests d'intégration) selon les besoins.
- **Production** : projet Supabase production + hébergement Infomaniak, accès restreint à l'équipe World Game.
La DSI/DTNI dispose d'un environnement de test existant (JAMF + Netskope, faux collège virtuel) qui pourrait en théorie servir aux tests d'intégration. World Game choisit néanmoins de maintenir son propre environnement de pré-production, configuré à l'identique de la production : cela garantit que les tests se déroulent dans des conditions maîtrisées et reproductibles, indépendamment des évolutions de l'infrastructure cliente.
---
## 3. Modèle de données
### 3.1 Codes d'inscription établissement
Chaque établissement reçoit de World Game un code d'inscription unique. La page d'inscription est accessible publiquement, mais ce code est obligatoire pour créer un compte (sans lui, la création est rejetée côté serveur).
```sql
school_registration_codes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text UNIQUE NOT NULL, -- ex: "DEPAR-2026-CLGVAUBAN"
label text, -- nom du collège, usage interne WG
expires_at timestamptz
)
```
Le nombre d'enseignants inscrits avec un code donné est retrouvable via `teachers.registration_code_id` : pas besoin de le dénormaliser ici.
### 3.2 Enseignants
L'email et le mot de passe sont gérés par Supabase Auth (stockés chiffrés, jamais en clair). La table `teachers` ne contient que les métadonnées de profil.
```sql
-- Supabase Auth gère : email, hash mot de passe, sessions JWT
teachers (
id uuid PRIMARY KEY REFERENCES auth.users(id),
display_name text,
registration_code_id uuid REFERENCES school_registration_codes(id),
created_at timestamptz DEFAULT now()
)
```
### 3.3 Classes
Une classe peut être partagée entre plusieurs enseignants. Un enseignant rejoint une classe existante en saisissant son `class_code` dans l'interface : une ligne est alors insérée dans `teacher_classes`.
```sql
classes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
class_code text UNIQUE NOT NULL, -- ex: "BR37", partageable entre enseignants
school_year text NOT NULL, -- ex: "2025-2026"
created_by uuid REFERENCES teachers(id), -- créateur (information, pas contrainte d'accès)
created_at timestamptz DEFAULT now()
)
-- Table de jonction : relation many-to-many entre enseignants et classes
teacher_classes (
teacher_id uuid NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
class_id uuid NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
joined_at timestamptz DEFAULT now(),
PRIMARY KEY (teacher_id, class_id)
)
```
Supprimer un enseignant retire ses lignes dans `teacher_classes` (cascade) mais laisse la classe intacte si d'autres enseignants y sont rattachés.
### 3.4 Codes élèves — aucune donnée nominative
C'est le cœur de la stratégie d'anonymat. Un code élève est une chaîne opaque générée aléatoirement. Il n'est lié à aucun nom, email, date de naissance ou tout autre identifiant. La correspondance entre ce code et l'identité de l'élève n'existe que chez l'enseignant, sur papier ou tableur local, et n'est jamais transmise au système.
```sql
student_codes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
class_id uuid NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
code text UNIQUE NOT NULL, -- ex: "BR37-14763"
first_used_at timestamptz, -- null = code jamais utilisé
is_active boolean DEFAULT true, -- À décider : désactivation sans suppression
created_at timestamptz DEFAULT now()
-- Aucun champ : name, email, birth_date, student_id, national_id.
-- La liste papier (ou tableur) chez l'enseignant est le seul endroit
-- où le lien code ↔ élève existe. Elle n'est jamais transmise au serveur.
)
```
### 3.5 Activités (modules pédagogiques)
```sql
activities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
teacher_id uuid NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
name text NOT NULL,
type text NOT NULL CHECK (type IN ('timeline','map','vocabulary','quiz')),
status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published')),
content jsonb NOT NULL DEFAULT '{}', -- structure variable selon le type
import_id text UNIQUE, -- identifiant partageable pour duplication
source_import_id text, -- traçage de l'original si importé
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
)
```
Le champ `import_id` permet à un enseignant de partager une activité avec un collègue via un identifiant unique. L'import crée un **duplicata indépendant** : chaque enseignant possède sa propre copie, modifiable librement.
### 3.6 Assignation classes / activités
```sql
activity_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
class_id uuid NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
assigned_at timestamptz DEFAULT now(),
UNIQUE (activity_id, class_id)
)
```
### 3.7 Progression (histoire principale)
> ⚠️ **À préciser** — La structure de progression dépend du modèle narratif définitif (nombre de chapitres, étapes, conditions de déblocage). Cette table sera spécifiée après arbitrage avec l'équipe World Game.
### 3.8 Résultats d'activités
```sql
activity_results (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
student_code_id uuid NOT NULL REFERENCES student_codes(id) ON DELETE CASCADE,
activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
result jsonb NOT NULL, -- structure libre selon le type d'activité
completed_at timestamptz DEFAULT now(),
UNIQUE (student_code_id, activity_id) -- une tentative par élève ; à retirer si on autorise plusieurs essais
)
```
La structure du champ `result` est définie par l'app tierce et validée dans l'Edge Function à la soumission. Exemple pour un quiz : `{ "answers": [1, 0, 2, 1], "score": 3, "duration_seconds": 120 }`.
### 3.9 Accès libre par classe
Permet à l'enseignant de déverrouiller un contenu normalement progressif pour une classe entière (rattrapage, différenciation pédagogique).
```sql
free_access (
class_id uuid NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
content_key text NOT NULL, -- ex: "c2s1" (chapitre 2, étape 1)
granted_at timestamptz DEFAULT now(),
PRIMARY KEY (class_id, content_key)
)
```
---
## 4. Stratégie d'anonymat
### 4.1 Tableau des flux de données
| Donnée | Stockée où | Accessible par | Donnée personnelle ? |
| ------------------------- | --------------------------------------- | ----------------------- | --------------------------- |
| Email enseignant | Supabase Auth (chiffré) | L'enseignant lui-même | **Oui** |
| Code élève (`BR37-14763`) | Base de données | Enseignant de la classe | **Non** (opaque, aléatoire) |
| Prénom élève | `localStorage` du navigateur enseignant | Personne d'autre | Non (hors système) |
| Progression / résultats | Base de données | Enseignant de la classe | **Non** (lié au code) |
| Avatar, personnalisation | Stockage local app tierce (hors scope) | L'élève uniquement | Non (hors système) |
### 4.2 Principes fondamentaux
**Aucun compte élève.** L'élève ne crée pas de compte, ne saisit pas d'email, ne fournit aucune information personnelle. Il utilise uniquement un code fourni par son enseignant.
**Le code est opaque.** `BR37-14763` est généré aléatoirement. Il ne contient aucun segment dérivé du nom, prénom, date de naissance ou numéro de l'élève. Il est non-devinable et non-corrélable à un individu.
**Le chaînon manquant reste hors système.** La correspondance entre le code et l'identité de l'élève (ex. "BR37-14763 = Thomas Dupont") existe uniquement dans un document tenu par l'enseignant (tableur local, liste papier) qui n'est jamais transmis au serveur de World Game.
**L'anonymat total s'applique aux élèves, pas aux enseignants.** L'email de l'enseignant est conservé pour la récupération de mot de passe : à 3 000 à 4 000 comptes, un reset manuel par World Game est intenable, seul l'email permet un flux autonome et sécurisé. L'email est déclaré dans le registre des traitements, base légale « contrat de service », et ne sort jamais du périmètre Supabase Auth.
**Résistance à une compromission de la base de données.** Même en cas d'accès non autorisé à l'intégralité de la base de données PostgreSQL, il est impossible de remonter à l'identité d'un élève. Il ne manque pas un mot de passe, il manque un document qui n'a jamais existé dans le système.
**Données locales et données API sont séparées.** L'avatar, les préférences et la personnalisation de l'élève sont gérés localement par l'application tierce et ne transitent pas par l'API World Game. Seules la progression dans l'histoire et les réponses aux activités sont transmises à l'API. En cas de perte de tablette, les données purement locales (avatar, personnalisation) ne sont pas récupérables depuis le serveur, ce qui est voulu.
---
## 5. Conformité RGPD
### 5.1 Rôles
Le RGPD impose d'identifier deux acteurs distincts dès lors que des données personnelles sont traitées. Le **responsable de traitement** est l'entité qui décide pourquoi et comment les données sont collectées : ici l'établissement ou le département, qui déploie l'application auprès de ses élèves. Le **sous-traitant** est l'entité qui traite ces données pour son compte : ici World Game, qui les héberge et les gère sur ses serveurs. Cette distinction implique la signature d'un contrat formel entre les deux parties (appelé DPA, _Data Processing Agreement_). Ce document est rédigé par World Game (qui connaît ses propres pratiques de traitement, son infrastructure et ses sous-traitants en cascade : Supabase, Infomaniak) puis soumis à la validation du département avant signature. Il sera produit une fois la structure de données et les fonctionnalités définitivement arrêtées, avant le déploiement en production.
| Rôle | Entité |
| ------------------------- | ------------------------------------------- |
| Responsable de traitement | Établissement scolaire / Département |
| Sous-traitant | World Game (DPA à signer avant déploiement) |
### 5.2 Données à caractère personnel traitées
| Catégorie | Données | Base légale |
| ----------- | --------------------------------------- | ------------------ |
| Enseignants | Email, nom d'affichage | Contrat de service |
| Élèves | Aucune (codes pseudonymisés uniquement) | s.o. |
Les codes élèves sont des **données pseudonymisées** au sens du RGPD : ils ne permettent pas, par eux-mêmes ou par croisement avec d'autres données du système, de réidentifier une personne physique. La clé de correspondance (liste enseignant) est externe au système.
### 5.3 Durée de conservation
- **Données de progression élève** : durée de conservation à définir.
- **Compte enseignant** : conservé jusqu'à la résiliation, puis supprimé sur demande.
- **Sauvegardes PostgreSQL** : rétention Supabase par défaut (7 jours pour le plan Pro, configurable).
### 5.4 Droits des personnes
- **Enseignants** : accès, rectification et suppression du compte via l'interface ou sur demande à World Game.
- **Élèves** : aucune donnée nominative côté serveur. Les droits RGPD sont exercés indirectement via l'enseignant (suppression du code = suppression des données serveur de l'élève).
### 5.5 Audit prévu
Un audit de conformité RGPD est prévu en septembre par la DRAM. Cette note technique constitue le support de conformité principal. World Game tient à jour un registre interne des traitements (obligatoire au titre de l'article 30 du RGPD) et fournira le DPA au département avant le déploiement en production.
---
## 6. Authentification et contrôle d'accès
### 6.1 Enseignant
**Inscription :**
1. L'enseignant accède à la page d'inscription de l'interface web.
2. Il saisit : email, mot de passe, **code établissement** (fourni par World Game à son collège).
3. Une Edge Function valide le code d'établissement côté serveur.
4. Si le code est valide et non expiré : création du compte Supabase Auth + création du profil `teachers`.
5. Si le code est invalide ou expiré : refus, aucun compte n'est créé.
Ce mécanisme rend impossible toute inscription non autorisée. La page d'inscription est accessible publiquement, mais sans code établissement valide aucun compte ne peut être créé, ce qui élimine le vecteur d'attaque par bots.
**Connexion :**
- Email + mot de passe → Supabase Auth → JWT signé (algorithme RS256, expiration 1h) + refresh token (rotation automatique).
### 6.2 Élève — contrat d'API
L'élève n'a **aucun compte**. Les apps tierces s'authentifient auprès de l'API World Game au nom de l'élève via son code.
**Endpoint d'authentification : `POST /api/student/auth`**
```json
// Corps de la requête
{ "code": "BR37-14763" }
// Réponse (200 OK)
{ "token": "<jwt>", "student_code_id": "<uuid>" }
// Erreurs
// 401 — code invalide ou inactif
```
- Le JWT retourné contient `student_code_id` en claim et est signé par Supabase.
- L'app tierce est responsable du stockage sécurisé de ce JWT.
- Expiration du JWT : configurable (recommandé : 7 jours sur tablette gérée JAMF).
### 6.3 Row Level Security (RLS)
Toutes les tables Supabase sont protégées par des politiques RLS. Elles constituent une deuxième ligne de défense indépendante du code applicatif. Les opérations qui requièrent des droits étendus (inscription, jonction d'une classe par code, import d'une activité) sont traitées par des Edge Functions avec la `service_role` et ne sont jamais exposées directement au client.
| Table | Enseignant authentifié | Élève authentifié (JWT) | Non authentifié |
| --------------------------- | -------------------------------------------------- | ---------------------------------------------------- | --------------- |
| `teachers` | Lecture/écriture sur son propre profil | Aucun accès | Aucun accès |
| `classes` | Lecture/écriture sur ses classes | Aucun accès | Aucun accès |
| `teacher_classes` | Lecture de ses propres adhésions | Aucun accès | Aucun accès |
| `student_codes` | Lecture/création/suppression pour ses classes | Aucun accès direct | Aucun accès |
| `activities` | Lecture/écriture sur ses propres activités | Lecture des activités publiées assignées à sa classe | Aucun accès |
| `activity_assignments` | Lecture/écriture pour ses activités et ses classes | Lecture pour sa classe | Aucun accès |
| `activity_results` | Lecture pour ses classes | Écriture sur ses propres résultats | Aucun accès |
| `free_access` | Lecture/écriture pour ses classes | Lecture pour sa classe | Aucun accès |
| `school_registration_codes` | Aucun accès direct | Aucun accès | Aucun accès |
**Opérations via Edge Function (`service_role`) :**
- Inscription enseignant (validation du code établissement + création du compte)
- Jonction d'une classe par code (`class_code`)
- Import d'une activité par identifiant de partage (`import_id`)
- Authentification élève (`POST /api/student/auth`)
---
## 7. Sécurisation des formulaires et protection contre les abus
### 7.1 Protection anti-bots à l'inscription
La page d'inscription est publique, mais l'endpoint de création de compte valide systématiquement le code établissement côté serveur avant toute création. Un bot ne peut pas créer de comptes en masse faute de posséder un code valide.
### 7.2 Rate limiting
Les Edge Functions appliquent un rate limiting sur les tentatives de codes invalides (élève ou établissement). Au-delà d'un seuil d'échecs consécutifs depuis un même IP, l'endpoint retourne une erreur HTTP 429 (Too Many Requests) avec un délai d'attente croissant avant de pouvoir réessayer. Côté app, cela se traduit par un message explicite à l'utilisateur et un blocage temporaire du formulaire.
### 7.3 Validation côté serveur
Toutes les données entrantes sont validées dans les Edge Functions, indépendamment de la validation côté client. Un payload malformé est rejeté avant tout traitement.
Les payloads de l'API CTV étant simples (un code élève, un objet résultat), la validation est réalisée par des vérifications explicites dans le code (type, présence des champs obligatoires). Si les payloads venaient à se complexifier, l'adoption d'une bibliothèque de validation de schéma (par exemple ArkType) serait à envisager.
### 7.4 Protection CSRF
SvelteKit implémente nativement la protection CSRF via des cookies `SameSite=Strict` et la vérification de l'en-tête `Origin`. Aucune configuration manuelle n'est requise pour les formulaires standard.
### 7.5 Entropie des codes élèves
Le format `XX##-#####` (lettres + chiffres) génère plusieurs millions de combinaisons valides. La combinaison de l'entropie et du rate limiting rend une attaque par bruteforce non-viable.
---
## 8. Protection contre les injections
### 8.1 Injection SQL
Le client Supabase JS utilise exclusivement des **requêtes paramétrées** : il n'y a jamais de concaténation de chaînes pour construire des requêtes SQL. Aucune entrée utilisateur n'est interpolée dans une requête brute.
Les politiques RLS constituent une deuxième ligne de défense : même si une requête malformée parvenait à contourner la validation applicative, elle resterait contrainte par les politiques de sécurité définies directement dans la base de données.
### 8.2 XSS (Cross-Site Scripting)
SvelteKit échappe automatiquement toutes les variables interpolées dans les templates, côté élève comme côté enseignant. Aucune des deux applications ne rend de HTML arbitraire fourni par l'utilisateur.
### 8.3 Validation des contenus des modules
Les contenus de modules (questions, réponses, éléments de timeline…) sont stockés en JSONB et validés à la création et à la modification. Un enseignant ne peut pas injecter de code via le contenu d'une activité.
---
## 9. Communication avec la base de données
### 9.1 Flux de données
```
Client (app / interface web)
│ anon key + JWT utilisateur
Supabase REST API (HTTPS strict) → RLS filtre les requêtes
PostgreSQL (privé, non exposé directement)
Edge Functions
│ service_role key (côté serveur uniquement)
PostgreSQL (accès complet, protégé par isolation réseau)
```
### 9.2 Clés d'accès
Supabase utilise deux types de clés (nouvelle nomenclature 2025) :
| Clé | Utilisée où | Droits |
| ---------------------------------------- | ------------------------ | ---------------------------- |
| `publishable key` (`sb_publishable_xxx`) | Client (app, navigateur) | Limités par RLS |
| `secret key` (`sb_secret_xxx`) | Edge Functions (serveur) | Complets, jamais côté client |
La `secret key` n'est jamais incluse dans le code source du client, jamais dans un bundle JS téléchargeable, jamais dans les variables d'environnement exposées au navigateur.
### 9.3 Transport
Toutes les communications sont chiffrées via TLS (protocoles obsolètes TLS 1.0 et 1.1 désactivés). Aucune donnée ne transite en clair.
---
## 10. Intégration JAMF / Netskope
### 10.1 Périmètre World Game côté tablette
L'application élève (app tierce, hors périmètre World Game) consomme l'API via HTTPS. Du côté World Game, la seule exigence réseau est que le domaine `api.conquiers-ta-vie.com` soit accessible depuis les tablettes. La distribution et la gestion de l'app tierce sur les tablettes JAMF restent de la responsabilité de l'équipe client.
### 10.2 Gestion des sessions
La plage d'utilisation (jusqu'à 21h) est gérée exclusivement par JAMF School. L'application World Game n'implémente pas de logique de déconnexion forcée : JAMF s'en charge nativement.
**Auto-save à 20h55 :** l'application déclenche une sauvegarde automatique basée sur l'heure serveur Supabase 5 minutes avant la coupure, pour prévenir toute perte de progression.
### 10.3 Accessibilité
Les recommandations d'accessibilité (OpenDyslexic, menu options, VoiceOver) concernent l'application élève tierce et sont documentées séparément dans les spécifications fonctionnelles transmises à l'équipe de développement de cette app. L'interface enseignant World Game suit les bonnes pratiques RGAA dans la mesure du possible.
### 10.4 Réseau
- **Domaines envisagés** : interface enseignant sur `conquiers-ta-vie.com`, API sur `api.conquiers-ta-vie.com`, hébergés chez Infomaniak sur un domaine géré par World Game. À confirmer (voir point 8 des décisions en suspens).
- **Domaine à whitelister dans Netskope** : `api.conquiers-ta-vie.com` (et sous-domaines Supabase associés)
- **Interlocuteur** : Fabien Benard (administrateur réseau)
- **Connectivité** : les tablettes ont accès au Wi-Fi en établissement (fibré) et peuvent se connecter depuis le domicile
---
## 11. Infrastructure et environnements
### 11.1 Hébergement
**Infomaniak** (Genève, Suisse) :
- Certification ISO 27001 (management de la sécurité de l'information)
- Données hébergées en Suisse, non soumises au Cloud Act américain
- Politique RSE engagée (énergie renouvelable, neutralité carbone)
- Conforme RGPD
**Supabase** (infrastructure PostgreSQL managée) :
- Chiffrement au repos (AES-256)
- Chiffrement en transit (TLS)
- Sauvegardes automatiques PostgreSQL avec rétention configurable et point-in-time recovery
- SOC 2 Type II
### 11.2 Monitoring et alertes
**Supabase** fournit nativement via son dashboard :
- Logs des requêtes base de données, des Edge Functions et des événements d'authentification
- Métriques de performance (CPU, RAM, connexions actives)
- Alertes automatiques en cas de dépassement des quotas du plan souscrit
**Infomaniak** fournit nativement :
- Surveillance de l'uptime et alertes par email en cas d'indisponibilité
- Alertes espace disque
- Logs serveur accessibles depuis le panneau de gestion
La stratégie de monitoring applicatif (seuils d'alerte personnalisés, interlocuteur d'astreinte, procédure d'escalade en cas d'incident) reste à définir avant la mise en production.
---
_Document rédigé par World Game. Toute question technique peut être adressée à Arthur Deleye._

56
index.html Normal file

File diff suppressed because one or more lines are too long

193
js/db.js Normal file
View file

@ -0,0 +1,193 @@
// ===================== DATA =====================
const DB={
classes:[
{id:"c1",name:"5ème A",code:"GC-5A-2025",year:"2025-2026",
students:[
{id:"s01",code:"A3K7"},{id:"s02",code:"B2M9"},{id:"s03",code:"C5R1"},
{id:"s04",code:"D8L4"},{id:"s05",code:"E1N6"},{id:"s06",code:"F4P2"},
{id:"s07",code:"G7S8"},{id:"s08",code:"H6T3"},{id:"s09",code:"I2V7"},
{id:"s10",code:"J5W1"},{id:"s11",code:"K9X4"},{id:"s12",code:"L3Y6"},
],activities:["a1","a2"]},
{id:"c2",name:"5ème B",code:"GC-5B-2025",year:"2025-2026",
students:[
{id:"s13",code:"M8Z2"},{id:"s14",code:"N4A5"},{id:"s15",code:"O7B8"},
{id:"s16",code:"P1C3"},{id:"s17",code:"Q6D9"},{id:"s18",code:"R2E4"},
{id:"s19",code:"S5F7"},{id:"s20",code:"T9G1"},{id:"s21",code:"U3H6"},
{id:"s22",code:"V8I2"},
],activities:["a1"]},
{id:"c3",name:"4ème C",code:"GC-4C-2025",year:"2025-2026",
students:[
{id:"s23",code:"W4J5"},{id:"s24",code:"X7K8"},{id:"s25",code:"Y2L3"},
{id:"s26",code:"Z6M9"},{id:"s27",code:"AA1N"},{id:"s28",code:"BB5O"},
{id:"s29",code:"CC9P"},{id:"s30",code:"DD3Q"},
],activities:[]},
{id:"c4",name:"5ème D",code:"GC-5D-2024",year:"2024-2025",
students:[
{id:"p01",code:"PA3K"},{id:"p02",code:"PB2M"},{id:"p03",code:"PC5R"},
{id:"p04",code:"PD8L"},{id:"p05",code:"PE1N"},{id:"p06",code:"PF4P"},
{id:"p07",code:"PG7S"},{id:"p08",code:"PH6T"},{id:"p09",code:"PI2V"},
{id:"p10",code:"PJ5W"},{id:"p11",code:"PK9X"},{id:"p12",code:"PL3Y"},
{id:"p13",code:"PM8Z"},{id:"p14",code:"PN4A"},{id:"p15",code:"PO7B"},
{id:"p16",code:"PP1C"},{id:"p17",code:"PQ6D"},{id:"p18",code:"PR2E"},
{id:"p19",code:"PS5F"},{id:"p20",code:"PT9G"},{id:"p21",code:"PU3H"},
],activities:["a1"]},
{id:"c5",name:"4ème A",code:"GC-4A-2024",year:"2024-2025",
students:[
{id:"q01",code:"QA3K"},{id:"q02",code:"QB2M"},{id:"q03",code:"QC5R"},
{id:"q04",code:"QD8L"},{id:"q05",code:"QE1N"},{id:"q06",code:"QF4P"},
{id:"q07",code:"QG7S"},{id:"q08",code:"QH6T"},{id:"q09",code:"QI2V"},
{id:"q10",code:"QJ5W"},{id:"q11",code:"QK9X"},{id:"q12",code:"QL3Y"},
{id:"q13",code:"QM8Z"},{id:"q14",code:"QN4A"},{id:"q15",code:"QO7B"},
{id:"q16",code:"QP1C"},{id:"q17",code:"QQ6D"},{id:"q18",code:"QR2E"},
],activities:[]},
],
activities:[
{id:"a1",name:"La conquête de l'Angleterre",code:"MOD-A1-GC25",format:"quiz",status:"published",createdAt:"20 sept. 2025",assignedClasses:["c1","c2"],
questions:[
{text:"En quelle année Guillaume le Conquérant envahit-il l'Angleterre ?",answers:["1066","1154","1035","1099"],correct:0,feedback:"La bataille de Hastings a eu lieu le 14 octobre 1066."},
{text:"Quel roi anglais Guillaume affronte-t-il lors de la bataille de Hastings ?",answers:["Harold II","Édouard le Confesseur","Richard Ier","Étienne de Blois"],correct:0,feedback:"Harold II Godwinson s'était fait couronner roi à la mort d'Édouard le Confesseur."},
{text:"La tapisserie de Bayeux est une peinture sur soie.",answers:["Faux : c'est une broderie sur lin.","Vrai : réalisée à la peinture.","Faux : c'est une fresque.","Vrai : sur soie d'Orient."],correct:0,feedback:"La tapisserie de Bayeux est une broderie sur lin de 70 m."},
{text:"Où Guillaume est-il couronné roi d'Angleterre le 25 décembre 1066 ?",answers:["L'abbaye de Westminster","La cathédrale de Canterbury","La Tour de Londres","Winchester"],correct:0,feedback:"L'abbaye de Westminster est le lieu traditionnel du couronnement."},
{text:"Comment Guillaume est-il souvent désigné en raison de sa naissance ?",answers:["Le Bâtard","Le Brave","Le Normand","Le Pieux"],correct:0,feedback:"Né hors mariage, Guillaume fut longtemps surnommé 'le Bâtard'."},
{text:"Quelle ville normande est associée à la naissance de Guillaume ?",answers:["Falaise","Caen","Rouen","Bayeux"],correct:0,feedback:"Guillaume est né à Falaise (Calvados)."},
{text:"Qui est le père de Guillaume le Conquérant ?",answers:["Robert le Magnifique","Richard le Courageux","Henri le Grand","Rollon"],correct:0,feedback:"Son père était Robert Ier de Normandie, dit 'le Magnifique'."},
{text:"Quel document Guillaume fait-il rédiger pour recenser terres et richesses d'Angleterre ?",answers:["Le Domesday Book","La Magna Carta","Le Livre de Westminster","Le Registre de Londres"],correct:0,feedback:"Le Domesday Book (1086) est un recensement exhaustif."},
{text:"Quelle est la longueur approximative de la tapisserie de Bayeux ?",answers:["70 mètres","20 mètres","120 mètres","50 mètres"],correct:0,feedback:"La tapisserie mesure environ 70 m de long pour 50 cm de haut."},
{text:"Contre qui Guillaume doit-il d'abord s'imposer avant de conquérir l'Angleterre ?",answers:["Les barons normands rebelles","Le roi de France","L'Empire germanique","Les Vikings scandinaves"],correct:0,feedback:"Guillaume dut affirmer son autorité face aux barons normands rebelles."},
]},
{id:"a2",code:"MOD-A2-GC25",name:"Guillaume et Harold — frères ennemis",format:"quiz",status:"published",createdAt:"5 oct. 2025",assignedClasses:["c1"],
questions:[
{text:"Quelle promesse Harold aurait-il faite à Guillaume avant de s'emparer du trône ?",answers:["Un serment d'allégeance à Bayeux","Un traité de paix à Rouen","Une promesse orale à Paris","Un accord écrit à Canterbury"],correct:0,feedback:"Harold aurait prêté serment sur des reliques à Bayeux."},
{text:"Comment Harold meurt-il selon la tradition lors de la bataille de Hastings ?",answers:["Frappé par une flèche à l'œil","Tué en combat singulier","Noyé en fuyant","Mort de ses blessures après"],correct:0,feedback:"La tradition veut qu'Harold ait été tué par une flèche dans l'œil."},
{text:"Par quel organe Harold est-il élu roi d'Angleterre ?",answers:["Le Witan (conseil des grands)","Le Parlement de Londres","Le Conseil du pape","L'assemblée des barons normands"],correct:0,feedback:"Harold est élu par le Witan, conseil des grands du royaume."},
{text:"D'où vient étymologiquement le mot 'Normand' ?",answers:["Northmen (hommes du Nord)","Normannus (homme de la norme)","Northmannia (territoire du nord)","Normas (chef de guerre)"],correct:0,feedback:"Les Normands descendent des Vikings ('Northmen')."},
{text:"Quelle bataille Harold remporte-t-il juste avant d'affronter Guillaume ?",answers:["Stamford Bridge (contre les Vikings)","Canterbury (rebelles)","York (Écossais)","Oxford (barons)"],correct:0,feedback:"Harold bat Harald Hardrada à Stamford Bridge le 25 sept. 1066."},
{text:"Quelle stratégie décisive les Normands emploient-ils à Hastings ?",answers:["Une feinte de retraite","Une charge frontale","Un encerclement nocturne","L'incendie du camp"],correct:0,feedback:"Les Normands simulent une retraite, rompant la ligne anglaise."},
{text:"À quelle formation les soldats d'Harold ont-ils recours ?",answers:["Le mur de boucliers (shieldwall)","La tortue romaine","Le carré défensif","La phalange grecque"],correct:0,feedback:"Les housecarls forment un mur de boucliers sur la colline de Senlac."},
{text:"Quel pape soutient officiellement la conquête de Guillaume ?",answers:["Alexandre II","Grégoire VII","Urbain II","Léon IX"],correct:0,feedback:"Le pape Alexandre II accorde sa bénédiction à Guillaume."},
{text:"Que désigne le 'Danelaw' ?",answers:["Zone d'influence viking","Loi des Danois en France","Code pénal normand","Région du Danemark"],correct:0,feedback:"Le Danelaw désigne les régions anglaises sous influence viking."},
{text:"Combien de temps s'écoule entre le débarquement et la bataille de Hastings ?",answers:["3 semaines","3 mois","1 an","1 semaine"],correct:0,feedback:"Guillaume débarque le 28 sept. et la bataille a lieu le 14 oct. : 3 semaines."},
]},
{id:"a3",name:"La Normandie avant 1066",format:"quiz",status:"draft",createdAt:"12 nov. 2025",assignedClasses:[],questions:[]},
],
results:{
s01:{a1:{status:'done',date:'5 oct.',time:11,ans:[1,1,1,1,1,1,1,1,0,1]},a2:{status:'done',date:'22 oct.',time:14,ans:[1,1,0,1,1,1,0,1,1,1]}},
s02:{a1:{status:'done',date:'6 oct.',time:13,ans:[1,1,1,0,1,0,0,1,1,0]},a2:{status:'done',date:'23 oct.',time:17,ans:[1,0,1,0,1,0,0,1,1,0]}},
s03:{a1:{status:'done',date:'5 oct.',time:9, ans:[1,1,1,1,1,1,1,1,1,1]},a2:{status:'done',date:'21 oct.',time:12,ans:[1,1,1,1,1,0,1,1,0,1]}},
s04:{a1:{status:'done',date:'7 oct.',time:18,ans:[1,0,0,1,0,0,1,0,0,1]},a2:{status:'done',date:'24 oct.',time:20,ans:[1,0,0,1,0,0,0,1,0,0]}},
s05:{a1:{status:'done',date:'5 oct.',time:12,ans:[1,1,1,1,0,1,1,1,0,1]},a2:null},
s06:{a1:null,a2:null},
s07:{a1:{status:'done',date:'6 oct.',time:15,ans:[1,1,0,1,1,1,0,1,1,0]},a2:null},
s08:{a1:null,a2:null},
s09:{a1:{status:'done',date:'5 oct.',time:10,ans:[1,1,1,1,1,1,1,1,0,1]},a2:{status:'done',date:'21 oct.',time:9, ans:[1,1,1,1,1,1,1,1,1,1]}},
s10:{a1:null,a2:null},
s11:{a1:{status:'done',date:'6 oct.',time:12,ans:[1,1,1,0,1,1,1,0,1,1]},a2:null},
s12:{a1:{status:'done',date:'7 oct.',time:14,ans:[1,1,0,1,0,1,0,1,1,0]},a2:{status:'done',date:'23 oct.',time:15,ans:[1,1,0,1,0,1,0,1,0,1]}},
s13:{a1:{status:'done',date:'8 oct.',time:12,ans:[1,1,1,0,1,1,1,0,1,1]}},
s14:{a1:null},
s15:{a1:{status:'done',date:'8 oct.',time:11,ans:[1,1,1,1,1,1,0,1,1,1]}},
s16:{a1:null},
s17:{a1:{status:'done',date:'9 oct.',time:15,ans:[1,1,0,1,0,1,0,0,1,1]}},
s18:{a1:null},
s19:{a1:{status:'done',date:'8 oct.',time:9, ans:[1,1,1,1,1,1,1,1,1,1]}},
s20:{a1:null},
s21:{a1:{status:'done',date:'9 oct.',time:13,ans:[1,1,0,1,1,1,0,1,0,1]}},
s22:{a1:null},
s23:{},s24:{},s25:{},s26:{},s27:{},s28:{},s29:{},s30:{},
},
progression:{
s01:{c1:4,c2:4,c3:2,c4:0},s02:{c1:4,c2:3,c3:0,c4:0},s03:{c1:4,c2:4,c3:4,c4:1},
s04:{c1:3,c2:0,c3:0,c4:0},s05:{c1:4,c2:4,c3:3,c4:0},s06:{c1:0,c2:0,c3:0,c4:0},
s07:{c1:4,c2:2,c3:0,c4:0},s08:{c1:4,c2:1,c3:0,c4:0},s09:{c1:4,c2:4,c3:4,c4:2},
s10:{c1:2,c2:0,c3:0,c4:0},s11:{c1:4,c2:3,c3:1,c4:0},s12:{c1:4,c2:2,c3:0,c4:0},
s13:{c1:4,c2:3,c3:0,c4:0},s14:{c1:0,c2:0,c3:0,c4:0},s15:{c1:4,c2:4,c3:2,c4:0},
s16:{c1:0,c2:0,c3:0,c4:0},s17:{c1:4,c2:1,c3:0,c4:0},s18:{c1:0,c2:0,c3:0,c4:0},
s19:{c1:4,c2:4,c3:4,c4:0},s20:{c1:0,c2:0,c3:0,c4:0},s21:{c1:4,c2:2,c3:0,c4:0},
s22:{c1:4,c2:3,c3:1,c4:0},
s23:{c1:0,c2:0,c3:0,c4:0},s24:{c1:0,c2:0,c3:0,c4:0},s25:{c1:0,c2:0,c3:0,c4:0},
s26:{c1:0,c2:0,c3:0,c4:0},s27:{c1:0,c2:0,c3:0,c4:0},s28:{c1:0,c2:0,c3:0,c4:0},
s29:{c1:0,c2:0,c3:0,c4:0},s30:{c1:0,c2:0,c3:0,c4:0},
},
// freeAccess[classId] = Set of strings : 'cXsY' (aventure) ou 'rlN' (activités réelles)
freeAccess:{'c1':new Set(['c2s1']),'c2':new Set(),'c3':new Set()},
libraryActivities:[
{id:'lib1',name:'Les grandes découvertes (XVeXVIe s.)',format:'quiz',status:'published',
questions:[
{text:'Qui a financé le voyage de Christophe Colomb ?',answers:['Le roi du Portugal','La reine Isabelle de Castille','Le pape'],correct:1,feedback:"Isabelle Iᵉʳᵉ de Castille finança l'expédition en 1492."},
{text:'En quelle année Vasco de Gama atteint-il l\'Inde ?',answers:['1492','1498','1510'],correct:1,feedback:'Vasco de Gama arrive à Calicut en 1498.'},
{text:'Quel détroit Magellan franchit-il en 1520 ?',answers:['Le détroit de Gibraltar','Le détroit de Magellan','Le détroit de Drake'],correct:1,feedback:'Le détroit qui porte son nom, au sud de l\'Amérique.'},
]},
{id:'lib2',name:'La Révolution française (17891799)',format:'quiz',status:'published',
questions:[
{text:'Quelle date marque la prise de la Bastille ?',answers:['14 juillet 1789','4 août 1789','26 août 1789'],correct:0,feedback:"Le 14 juillet 1789 est aujourd'hui fête nationale."},
{text:"Quel document proclame l'égalité des droits en 1789 ?",answers:['La Constitution','La Déclaration des droits de l\'homme et du citoyen','Le Code civil'],correct:1,feedback:'La DDHC est adoptée le 26 août 1789.'},
]},
{id:'lib3',name:'La Rome antique',format:'quiz',status:'published',
questions:[
{text:'En quelle année César est-il assassiné ?',answers:['-100 av. J.-C.','-44 av. J.-C.','-27 av. J.-C.'],correct:1,feedback:'Jules César est assassiné aux Ides de Mars, le 15 mars 44 av. J.-C.'},
{text:'Quel peuple Rome affronte-t-elle lors des guerres puniques ?',answers:['Les Gaulois','Les Carthaginois','Les Grecs'],correct:1,feedback:"Carthage, dirigée par Hannibal, s'affronte à Rome en trois guerres puniques."},
]},
{id:'lib4',name:'Le Moyen Âge — La féodalité',format:'quiz',status:'published',
questions:[
{text:'Qui est au sommet de la pyramide féodale ?',answers:['Le seigneur','Le roi','Le vassal'],correct:1,feedback:'Le roi est au sommet, même si son pouvoir est limité par la féodalité.'},
{text:'Comment appelle-t-on l\'acte par lequel un vassal jure fidélité ?',answers:['L\'investiture','L\'hommage','La commende'],correct:1,feedback:"L'hommage est la cérémonie au cours de laquelle le vassal reconnaît son seigneur."},
]},
{id:'lib5',name:'La Première Guerre mondiale',format:'quiz',status:'published',
questions:[
{text:'En quelle année débute la Première Guerre mondiale ?',answers:['1912','1914','1916'],correct:1,feedback:"La guerre commence en août 1914, après l'assassinat de François-Ferdinand."},
{text:'Quel traité met fin à la guerre en 1918 ?',answers:['Traité de Versailles','Traité de Paris','Traité de Lausanne'],correct:0,feedback:'Le traité de Versailles est signé le 28 juin 1919.'},
]},
],
};
// Activités dans la vie réelle
const REAL_LIFE_ACTS=[
{id:'rl1',label:'Visite de musée', icon:'🏛️'},
{id:'rl2',label:'Sortie archéologique', icon:'⛏️'},
{id:'rl3',label:'Rencontre avec un historien',icon:'🎓'},
{id:'rl4',label:'Atelier calligraphie', icon:'✍️'},
{id:'rl5',label:'Escape game historique', icon:'🗝️'},
{id:'rl6',label:'Visite de monument', icon:'🏰'},
{id:'rl7',label:'Projection documentaire', icon:'🎬'},
{id:'rl8',label:'Jeu de rôle historique', icon:'⚔️'},
];
// Chapitre structure pour Accès libre
const CHAP_CONTENT=[
{chap:1,steps:[{s:1,type:'Aventure',label:'Chap. 1 — Aventure 1'},{s:2,type:'Quiz',label:'Chap. 1 — Quiz 1'},{s:3,type:'Aventure',label:'Chap. 1 — Aventure 2'},{s:4,type:'Quiz',label:'Chap. 1 — Quiz 2'}]},
{chap:2,steps:[{s:1,type:'Aventure',label:'Chap. 2 — Aventure 1'},{s:2,type:'Quiz',label:'Chap. 2 — Quiz 1'},{s:3,type:'Aventure',label:'Chap. 2 — Aventure 2'},{s:4,type:'Quiz',label:'Chap. 2 — Quiz 2'}]},
{chap:3,steps:[{s:1,type:'Aventure',label:'Chap. 3 — Aventure 1'},{s:2,type:'Quiz',label:'Chap. 3 — Quiz 1'},{s:3,type:'Aventure',label:'Chap. 3 — Aventure 2'},{s:4,type:'Quiz',label:'Chap. 3 — Quiz 2'}]},
{chap:4,steps:[{s:1,type:'Aventure',label:'Chap. 4 — Aventure 1'},{s:2,type:'Quiz',label:'Chap. 4 — Quiz 1'},{s:3,type:'Aventure',label:'Chap. 4 — Aventure 2'},{s:4,type:'Quiz',label:'Chap. 4 — Quiz 2'}]},
];
(function seedExtra(){
const POOL=['EE2R','FF7S','GG3T','HH8U','II4V','JJ9W','KK5X','LL1Y','MM6Z','NN2A',
'OO7B','PP3C','QQ8D','RR4E','SS9F','TT5G','UU1H','VV6I','WW2J','XX7K',
'YY3L','ZZ8M','AB4N','BC9O','CD5P','DE1Q','EF6R','FG2S','GH7T','HI3U',
'IJ8V','JK4W','KL9X','LM5Y','MN1Z','NO6A','OP2B','PQ7C','QR3D','RS8E',
'ST4F','TU9G','UV5H','VW1I','WX6J','XY2K','YZ7L','AZ3M','BZ8N','CZ4O',
'DZ9P','EZ5Q','FZ1R','GZ6S','HZ2T','IZ7U','JZ3V','KZ8W','LZ4X','MZ9Y'];
let ci=0;
function addTo(classIdx,target,actIds){
const c=DB.classes[classIdx];
while(c.students.length<target){
const i=c.students.length;const id='sx'+classIdx+'_'+i;
c.students.push({id,code:POOL[ci++]||('Z'+i+'_'+classIdx)});
DB.results[id]={};
actIds.forEach((aid,aidx)=>{
const roll=(i*7+aidx*13)%10;
if(roll<6){
const seed=i*31+aidx*17;
const ans=Array.from({length:10},(_,j)=>((seed*7+j*11)%10>2?1:0));
const dates=['3 oct.','4 oct.','5 oct.','6 oct.','7 oct.','8 oct.','9 oct.','10 oct.'];
DB.results[id][aid]={status:'done',date:dates[(i+aidx)%dates.length],time:7+(i+aidx)%14,ans};
}
// roll>=6 → null (pas commencé) — no more pending
});
const c1v=Math.min(4,(i*3)%6);const c2v=c1v>=4?Math.min(4,(i*2+1)%5):0;
const c3v=c2v>=4?Math.min(4,i%3):0;
DB.progression[id]={c1:c1v,c2:c2v,c3:c3v,c4:0};
}
}
addTo(0,26,['a1','a2']);addTo(1,24,['a1']);addTo(2,22,[]);
})();

View file

@ -0,0 +1,34 @@
// ===== GUIDE — MES CLASSES =====
// Modifier ce fichier pour mettre à jour le contenu du guide affiché sur les vues Mes Classes / Une Classe.
// Chaque <section class="gms-guide-section"> est un bloc indépendant.
// Pour ajouter une image : <img class="gms-guide-img" src="assets/images/mon-image.png" alt="Description">
function guideClasses() {
return `
<div class="gms-guide-card">
<div class="gms-guide-layout">
<img class="gms-guide-mood" src="assets/images/CTV-mood.png" alt="Mes classes">
<div class="gms-guide-content">
<section class="gms-guide-section">
<h2 class="gms-guide-section-title">Gérer vos classes</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
</section>
<section class="gms-guide-section">
<h2 class="gms-guide-section-title">Rattacher des élèves</h2>
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.</p>
<p>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.</p>
</section>
<section class="gms-guide-section">
<h2 class="gms-guide-section-title">Assigner des modules</h2>
<p>At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.</p>
<p>Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.</p>
</section>
</div>
</div>
</div>`;
}

View file

@ -0,0 +1,34 @@
// ===== GUIDE — MES MODULES =====
// Modifier ce fichier pour mettre à jour le contenu du guide affiché sur les vues Mes Modules / Une Activité / Créer un module.
// Chaque <section class="gms-guide-section"> est un bloc indépendant.
// Pour ajouter une image : <img class="gms-guide-img" src="assets/images/mon-image.png" alt="Description">
function guideModules() {
return `
<div class="gms-guide-card">
<div class="gms-guide-layout">
<img class="gms-guide-mood" src="assets/images/CTV-mood.png" alt="Mes modules">
<div class="gms-guide-content">
<section class="gms-guide-section">
<h2 class="gms-guide-section-title">Les modules d'activités</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
</section>
<section class="gms-guide-section">
<h2 class="gms-guide-section-title">Créer un quiz</h2>
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.</p>
<p>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.</p>
</section>
<section class="gms-guide-section">
<h2 class="gms-guide-section-title">Suivre les résultats</h2>
<p>At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.</p>
<p>Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.</p>
</section>
</div>
</div>
</div>`;
}

34
js/guides/guide-suivi.js Normal file
View file

@ -0,0 +1,34 @@
// ===== GUIDE — SUIVI DES ÉLÈVES =====
// Modifier ce fichier pour mettre à jour le contenu du guide affiché sur la vue Suivi des élèves.
// Chaque <section class="gms-guide-section"> est un bloc indépendant.
// Pour ajouter une image : <img class="gms-guide-img" src="assets/images/mon-image.png" alt="Description">
function guideSuivi() {
return `
<div class="gms-guide-card">
<div class="gms-guide-layout">
<img class="gms-guide-mood" src="assets/images/CTV-mood.png" alt="Suivi des élèves">
<div class="gms-guide-content">
<section class="gms-guide-section">
<h2 class="gms-guide-section-title">Vue d'ensemble</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
</section>
<section class="gms-guide-section">
<h2 class="gms-guide-section-title">Filtrer par classe</h2>
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.</p>
<p>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.</p>
</section>
<section class="gms-guide-section">
<h2 class="gms-guide-section-title">Exporter les données</h2>
<p>At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.</p>
<p>Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.</p>
</section>
</div>
</div>
</div>`;
}

113
js/helpers.js Normal file
View file

@ -0,0 +1,113 @@
// ===================== HELPERS =====================
function cls(id){return DB.classes.find(c=>c.id===id);}
function act(id){return DB.activities.find(a=>a.id===id);}
function filteredClasses(){const y=S.params.year||'2025-2026';return DB.classes.filter(c=>c.year===y);}
function renderYearSelector(){
const years=['2024-2025','2025-2026','2026-2027'];const cur=S.params.year||'2025-2026';
const wrap=document.getElementById('sbYearWrap');if(!wrap)return;
wrap.innerHTML=`<div style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.08);">
<div style="font-size:10px;font-weight:600;color:rgba(255,255,255,.38);letter-spacing:.06em;text-transform:uppercase;margin-bottom:4px;">Année scolaire</div>
<select style="width:100%;background:rgba(255,255,255,.07);color:#fff;border:1px solid rgba(255,255,255,.12);border-radius:7px;padding:6px 10px;font-size:13px;font-weight:500;cursor:pointer;outline:none;" onchange="S.setYear(this.value)">
${years.map(y=>`<option value="${y}" ${y===cur?'selected':''} style="background:#2a2318;color:#fff;">${y.replace('-',' ')}</option>`).join('')}
</select>
</div>`;
}
function ansScore(ans){return(ans||[]).filter(Boolean).length;}
function totalSteps(prog){return(prog.c1||0)+(prog.c2||0)+(prog.c3||0)+(prog.c4||0);}
function escHtml(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function jsSQ(v){return JSON.stringify(v).replace(/"/g,"'");}
function copyText(txt){navigator.clipboard.writeText(txt).then(()=>showToast('Copié !'));}
function showToast(msg,type=''){
const t=document.createElement('div');
t.style.cssText='position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#1A1510;color:#fff;padding:10px 20px;border-radius:8px;font-size:13px;z-index:9999;font-family:-apple-system,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.25);display:flex;align-items:center;gap:8px;';
t.innerHTML=(type==='ok'?'<span style="color:#4ade80;">✓</span>':'')+msg;
document.body.appendChild(t);setTimeout(()=>t.remove(),2400);
}
function openSb(){document.getElementById('sidebar').classList.add('open');document.getElementById('sbOverlay').classList.add('open');}
function closeSb(){document.getElementById('sidebar').classList.remove('open');document.getElementById('sbOverlay').classList.remove('open');}
function markDirty(){S.quizDirty=true;const b=document.getElementById('quizSaveBtn');if(b)b.disabled=false;}
function updateCharCount(el,max){
const n=el.value.length;const ccId=el.dataset.ccid;
const cc=document.getElementById(ccId);if(!cc)return;
cc.textContent=n+'/'+max;cc.className='char-count'+(n>=max?' over':n>=max*0.85?' near':'');
}
function progStatusBadge(p){
const tot=totalSteps(p);
if(tot>=16)return'<span class="badge b-ok">✓ Terminé</span>';
if(tot===0)return'<span class="badge b-gray">Pas commencé·e</span>';
for(let i=1;i<=4;i++){if((p['c'+i]||0)<4)return`<span class="badge b-info">Chap. ${i} — Ét. ${p['c'+i]||0}</span>`;}
return'<span class="badge b-ok">✓ Terminé</span>';
}
function qPct(students,actId,nQ){
const done=students.map(s=>(DB.results[s.id]||{})[actId]).filter(r=>r&&r.status==='done');
return Array.from({length:nQ},(_,i)=>{
const ansd=done.filter(r=>r.ans.length>i);
if(!ansd.length)return null;
return Math.round(ansd.filter(r=>r.ans[i]).length/ansd.length*100);
});
}
function pctBadge(pct){
if(pct===null)return'<span class="muted xs">—</span>';
const cls2=pct>=75?'pb-hi':pct>=40?'pb-mid':'pb-lo';
return`<div class="pct-badge ${cls2}">${pct}%</div>`;
}
function generateUnlockCode(classCode,key){
// Déterministe — l'app élève utilise le même algo pour vérifier offline
const s=classCode+'-'+key;let h=0;
for(let i=0;i<s.length;i++)h=Math.imul(31,h)+s.charCodeAt(i)|0;
return Math.abs(h).toString(36).toUpperCase().slice(0,6).padStart(6,'0');
}
// ===================== CSV EXPORT =====================
function downloadCSV(filename,rows){
const csv=rows.map(r=>r.map(c=>`"${String(c==null?'':c).replace(/"/g,'""')}"`).join(',')).join('\r\n');
const blob=new Blob([''+csv],{type:'text/csv;charset=utf-8;'});
const url=URL.createObjectURL(blob);const a=document.createElement('a');
a.href=url;a.download=filename+'.csv';a.click();URL.revokeObjectURL(url);
}
function exportResultsCSV(actId,classId){
const a=act(actId);if(!a)return;
const targetCls=classId?[cls(classId)]:DB.classes.filter(c=>a.assignedClasses.includes(c.id));
const header=['Classe','Code élève','Statut',...a.questions.map((_,i)=>'Q'+(i+1)),'Score','Date','Durée (min)'];
const rows=[header];
targetCls.forEach(c=>{
c.students.forEach(s=>{
const r=(DB.results[s.id]||{})[actId];
const done=r&&r.status==='done';
rows.push([c.name,s.code,done?'Terminé·e':'Pas commencé·e',...(done?r.ans.map(v=>v?'1':'0'):a.questions.map(()=>'')),done?ansScore(r.ans)+'/'+a.questions.length:'',done?r.date:'',done?r.time:'']);
});
});
downloadCSV((a.name+(classId?'_'+cls(classId).name:''))+'_résultats',rows);
showToast('CSV exporté ✓','ok');
}
function exportProgressionCSV(classId){
const c=cls(classId);if(!c)return;
const rows=[['Code élève','Chap. 1','Chap. 2','Chap. 3','Chap. 4','Total /16','Statut']];
c.students.forEach(s=>{
const p=DB.progression[s.id]||{c1:0,c2:0,c3:0,c4:0};
const tot=totalSteps(p);
rows.push([s.code,p.c1,p.c2,p.c3,p.c4,tot,tot>=16?'Terminé':tot===0?'Pas commencé·e':'En cours']);
});
downloadCSV(c.name+'_progression',rows);
showToast('CSV exporté ✓','ok');
}
// ===================== CHIP FILTER =====================
function setSuiviCls(newSel){
const open=newSel.length===1?newSel[0]:(S.params.openSuiviCls||null);
S.navigate('suivi-eleves',{selectedClasses:newSel,fromClassId:S.params.fromClassId||'',openSuiviCls:open},false);
}
function setActCls(newSel){
const open=newSel.length===1?newSel[0]:(S.params.openResultsCls||null);
S.params.selectedActCls=newSel;S.params.openResultsCls=open;render();
}
function toggleAccordion(key,classId){S.params[key]=S.params[key]===classId?null:classId;render();}
function chipFilter(items,getId,getLabel,selectedIds,onClickFn,clearFn){
const sel=selectedIds||[];
return`<div class="chips mb16">${items.map(item=>{
const id=getId(item);const on=sel.includes(id);
const newSel=on?sel.filter(x=>x!==id):[...sel,id];
return`<span class="chip${on?' on':''}" onclick="${onClickFn}(${jsSQ(newSel)})">${getLabel(item)}${on?' ✕':''}</span>`;
}).join('')}${sel.length>0?`<button class="btn btn-g btn-sm" onclick="${clearFn}">✕ Tout afficher</button>`:''}</div>`;
}

228
js/modals.js Normal file
View file

@ -0,0 +1,228 @@
// ===================== MODALS =====================
function showSupportModal(){
const classOptions=DB.classes.map(c=>`<option value="${c.id}">${c.name}</option>`).join('');
showModal(`<div class="modal"><div class="mhd"><h2>⚙️ Support</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody">
<p class="sm muted mb16">Réinitialisez l'association d'un élève à sa classe. L'élève devra resaisir son code dans l'application pour se rattacher à nouveau. Sa progression n'est pas effacée.</p>
<div class="fg"><label>Classe</label><select id="supportClassId">${classOptions}</select></div>
<div class="fg"><label>Code élève</label><input type="text" id="supportCodeModal" placeholder="Ex. : A3K7" style="font-family:monospace;font-weight:700;letter-spacing:.1em;text-transform:uppercase;"></div>
<div id="supportModalFeedback" class="xs muted mt8"></div>
</div>
<div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Fermer</button><button class="btn btn-p" onclick="resetStudentModal()">Réinitialiser</button></div></div>`);
}
function resetStudentModal(){
const classId=document.getElementById('supportClassId').value;
const code=document.getElementById('supportCodeModal').value.trim().toUpperCase();
const fb=document.getElementById('supportModalFeedback');
const c=cls(classId);
if(!c||!code){showToast('Renseignez la classe et le code.');return;}
const stu=c.students.find(s=>s.code===code);
if(!stu){if(fb)fb.textContent=`Aucun élève avec le code "${code}" dans ${c.name}.`;return;}
if(fb)fb.textContent='';
showToast(`Élève ${code} réinitialisé ✓`,'ok');
document.getElementById('supportCodeModal').value='';
}
function confirmDeleteActivity(actId){
const a=act(actId);if(!a)return;
showModal(`<div class="modal"><div class="mhd"><h2>Supprimer le module</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody">
<div class="alert alert-warn"> Supprimer <strong>${a.name}</strong> retirera ce module de toutes les classes auxquelles il est assigné. Cette action est irréversible.</div>
</div>
<div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Annuler</button><button class="btn btn-d" onclick="deleteActivity('${actId}')">Supprimer</button></div></div>`);
}
function deleteActivity(actId){
const a=act(actId);if(!a)return;
DB.classes.forEach(c=>{c.activities=c.activities.filter(id=>id!==actId);});
DB.activities=DB.activities.filter(a=>a.id!==actId);
closeModal();showToast('Module supprimé','ok');
S.navigate('mes-activites',{year:S.params.year},false);
}
function toggleClassMenu(classId){
const m=document.getElementById('cmenu_'+classId);
if(!m)return;
const isOpen=m.style.display!=='none';
document.querySelectorAll('[id^="cmenu_"]').forEach(el=>el.style.display='none');
m.style.display=isOpen?'none':'block';
if(!isOpen)setTimeout(()=>document.addEventListener('click',()=>m.style.display='none',{once:true}),0);
}
function closeClassMenu(classId){
const m=document.getElementById('cmenu_'+classId);if(m)m.style.display='none';
}
function showModal(html){document.getElementById('modalContainer').innerHTML=`<div class="ov" onclick="if(event.target===this)closeModal()">${html}</div>`;}
function closeModal(){document.getElementById('modalContainer').innerHTML='';}
function showTipsModal(){showModal(`<div class="modal modal-lg"><div class="mhd"><h2>💡 Conseils de rédaction</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody">
<div class="tip"><div class="tip-title">🎯 Discriminer, pas piéger</div><div class="tip-body">Chaque question doit différencier ce qui est maîtrisé ou non. Évitez les pièges artificiels sans valeur pédagogique.</div></div>
<div class="tip"><div class="tip-title">🪤 Faux-vrai</div><div class="tip-body">Incluez des affirmations qui semblent vraies mais sont fausses. Elles révèlent les fausses certitudes.</div></div>
<div class="tip"><div class="tip-title">🧩 Réponses plausibles</div><div class="tip-body">Les mauvaises réponses doivent être suffisamment plausibles pour tester la connaissance.</div></div>
<div class="tip"><div class="tip-title">💬 Feedback guidant</div><div class="tip-body">Expliquez <strong>pourquoi</strong> c'est faux. « Pas tout à fait ! Voici ce qui s'est vraiment passé »</div></div>
<div class="tip tip-blue"><div class="tip-title"> Durée cible</div><div class="tip-body">10 questions 1015 minutes. Au-delà, l'attention chute.</div></div>
</div>
<div class="mfoot"><button class="btn btn-p" onclick="closeModal()">Fermer</button></div></div>`);}
function showRenameClassModal(classId){
const c=cls(classId);if(!c)return;
showModal(`<div class="modal"><div class="mhd"><h2>Renommer la classe</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody">
<div class="fg"><label>Nom de la classe</label><input type="text" id="renameClassInput" value="${c.name}"></div>
<div class="alert alert-warn" style="margin-top:12px;"> Ce changement sera visible par tous les enseignants qui ont accès à cette classe.</div>
</div>
<div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Annuler</button><button class="btn btn-p" onclick="renameClass('${classId}')">Renommer</button></div></div>`);
setTimeout(()=>document.getElementById('renameClassInput')?.focus(),50);
}
function renameClass(classId){
const c=cls(classId);const input=document.getElementById('renameClassInput');
if(!c||!input)return;
const name=input.value.trim();if(!name){showToast('Saisissez un nom.');return;}
c.name=name;closeModal();showToast('"'+name+'" ✓','ok');
S.navigate('une-classe',{classId,tab:S.params.tab||'eleves'},false);
}
function showNewClassModal(){showModal(`<div class="modal"><div class="mhd"><h2>Nouvelle classe</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody">
<div class="fg"><label>Nom de la classe</label><input type="text" id="newClassName" placeholder="Ex. : 5ème A"></div>
<div class="fg"><label>Année scolaire</label><select id="newClassYear"><option value="2024-2025">2024 2025</option><option value="2025-2026" selected>2025 2026</option><option value="2026-2027">2026 2027</option></select></div>
<div class="alert alert-info"> Un code de partage unique sera généré automatiquement.</div>
</div>
<div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Annuler</button><button class="btn btn-p" onclick="createClass()">Créer</button></div></div>`);}
function createClass(){
const name=document.getElementById('newClassName').value.trim();const year=document.getElementById('newClassYear').value.trim();
if(!name){showToast('Donnez un nom.');return;}
const id='c'+Date.now();const code='GC-'+name.replace(/\s/g,'').toUpperCase().slice(0,4)+'-'+Math.random().toString(36).slice(2,6).toUpperCase();
DB.classes.push({id,name,code,year,students:[],activities:[]});DB.freeAccess[id]=new Set();
closeModal();showToast('"'+name+'" créé·e !','ok');S.navigate('une-classe',{classId:id,tab:'eleves'});
}
function showImportClassModal(){showModal(`<div class="modal"><div class="mhd"><h2>Importer une classe</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody">
<div class="alert alert-info"> Saisissez le code partagé par un autre enseignant.</div>
<div class="fg"><label>Code de classe</label><input type="text" placeholder="Ex. : GC-5A-2025"></div>
</div>
<div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Annuler</button><button class="btn btn-p" onclick="showToast('Import simulé','ok');closeModal()">Importer</button></div></div>`);}
function showAddStudentModal(classId){showModal(`<div class="modal"><div class="mhd"><h2>Ajouter un élève</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody">
<div class="alert alert-info"> L'élève trouve son code dans les paramètres de l'app.</div>
<div class="fg"><label>Code élève</label><input type="text" id="newStuCode" placeholder="Ex. : X4K2" style="font-size:16px;font-family:monospace;font-weight:700;letter-spacing:.1em;"></div>
</div>
<div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Annuler</button><button class="btn btn-p" onclick="addStudent('${classId}')">Ajouter</button></div></div>`);}
function addStudent(classId){
const code=document.getElementById('newStuCode').value.trim().toUpperCase();
if(!code){showToast('Saisissez un code élève.');return;}
const c=cls(classId);if(!c)return;
const id='s'+Date.now();c.students.push({id,code});DB.results[id]={};DB.progression[id]={c1:0,c2:0,c3:0,c4:0};
closeModal();showToast('Élève '+code+' ajouté !','ok');S.navigate('une-classe',{classId,tab:'eleves'},false);
}
function showAssignActivityModal(classId){
const c=cls(classId);const ua=DB.activities.filter(a=>a.status==='published'&&!c.activities.includes(a.id));
showModal(`<div class="modal"><div class="mhd"><h2>Assigner une activité</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody">${ua.length===0?'<p class="muted sm">Toutes les modules publiés sont déjà assignés.</p>':ua.map(a=>`
<div style="border:1px solid var(--border);border-radius:8px;padding:13px;margin-bottom:10px;">
<div class="fbet"><div><div class="semi">${a.name}</div><div class="xs muted">${a.questions.length} questions</div></div>
<button class="btn btn-p btn-sm" onclick="assignActivity('${classId}','${a.id}')">Assigner</button></div>
</div>`).join('')}
</div><div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Fermer</button></div></div>`);
}
function assignActivity(classId,actId){
const c=cls(classId);const a=act(actId);if(!c||!a)return;
if(!c.activities.includes(actId))c.activities.push(actId);
if(!a.assignedClasses.includes(classId))a.assignedClasses.push(classId);
closeModal();showToast('"'+a.name+'" assigné à '+c.name+' !','ok');
S.navigate('une-classe',{classId,tab:'activite'},false);
}
function assignFromAct(actId,classId){
const c=cls(classId);const a=act(actId);if(!c||!a)return;
if(!c.activities.includes(actId))c.activities.push(actId);
if(!a.assignedClasses.includes(classId))a.assignedClasses.push(classId);
showToast(c.name+' assignée !','ok');S.navigate('une-activite',{activityId:actId,tab:'classes'},false);
}
function showImportActivityModal(){
showModal(`<div class="modal">
<div class="mhd"><h2>Importer un module par ID</h2><button class="btn-ico" onclick="closeModal()"></button></div>
<div class="mbody">
<div class="alert alert-info"> L'import crée une copie indépendante et modifiable dans vos modules.</div>
<div class="fg"><label>ID du module</label><input type="text" id="importActId" placeholder="Ex. : lib2" autofocus></div>
<div id="importActFeedback" class="xs muted mt8"></div>
</div>
<div class="mfoot">
<button class="btn btn-s" onclick="closeModal()">Annuler</button>
<button class="btn btn-p" onclick="doImportActivityById()">Importer</button>
</div>
</div>`);
}
function doImportActivityById(){
const id=(document.getElementById('importActId').value||'').trim();
const fb=document.getElementById('importActFeedback');
if(!id){if(fb)fb.textContent='Saisissez un ID.';return;}
const lib=DB.libraryActivities.find(a=>a.id===id);
if(!lib){if(fb)fb.textContent='Aucun module trouvé avec cet ID.';return;}
closeModal();
importLibraryActivity(id);
}
function confirmDesassign(classId,actId){
const c=cls(classId);const a=act(actId);if(!c||!a)return;
showModal(`<div class="modal"><div class="mhd"><h2>Désassigner le module ?</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody"><p style="font-size:13px;line-height:1.6;">Désassigner <strong>${a.name}</strong> de <strong>${c.name}</strong> ?<br>Les résultats déjà enregistrés sont conservés.</p></div>
<div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Annuler</button><button class="btn btn-d" onclick="doDesassign('${classId}','${actId}')">Désassigner</button></div></div>`);
}
function doDesassign(classId,actId){
const c=cls(classId);const a=act(actId);if(!c||!a)return;
c.activities=c.activities.filter(id=>id!==actId);a.assignedClasses=a.assignedClasses.filter(id=>id!==classId);
closeModal();showToast('Désassignée.');S.navigate('une-classe',{classId,tab:'activite'},false);
}
function confirmDesassignFromAct(actId,classId){
const c=cls(classId);const a=act(actId);if(!c||!a)return;
showModal(`<div class="modal"><div class="mhd"><h2>Désassigner la classe ?</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody"><p style="font-size:13px;line-height:1.6;">Désassigner <strong>${c.name}</strong> de <strong>${a.name}</strong> ?<br>Les résultats sont conservés.</p></div>
<div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Annuler</button><button class="btn btn-d" onclick="doDesassignFromAct('${actId}','${classId}')">Désassigner</button></div></div>`);
}
function doDesassignFromAct(actId,classId){
const c=cls(classId);const a=act(actId);if(!c||!a)return;
c.activities=c.activities.filter(id=>id!==actId);a.assignedClasses=a.assignedClasses.filter(id=>id!==classId);
closeModal();showToast(c.name+' désassigné.');S.navigate('une-activite',{activityId:actId,tab:'classes'},false);
}
function confirmDeleteClass(classId){
const c=cls(classId);if(!c)return;
showModal(`<div class="modal"><div class="mhd"><h2>Supprimer la classe ?</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody"><p style="font-size:13px;line-height:1.6;">Supprimer <strong>${c.name}</strong> et ses <strong>${c.students.length} élèves</strong> ?<br>Action irréversible.</p></div>
<div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Annuler</button><button class="btn btn-d" onclick="doDeleteClass('${classId}')">Supprimer</button></div></div>`);
}
function doDeleteClass(classId){
DB.classes=DB.classes.filter(c=>c.id!==classId);DB.activities.forEach(a=>{a.assignedClasses=a.assignedClasses.filter(id=>id!==classId);});
closeModal();showToast('Classe supprimée.');S.navigate('mes-classes');
}
function confirmDeleteStudent(classId,stuId){
const c=cls(classId);const s=c?.students.find(s=>s.id===stuId);if(!c||!s)return;
showModal(`<div class="modal"><div class="mhd"><h2>Supprimer l'élève ?</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody"><p style="font-size:13px;line-height:1.6;">Supprimer le code <strong>${s.code}</strong> de <strong>${c.name}</strong> ?</p></div>
<div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Annuler</button><button class="btn btn-d" onclick="doDeleteStudent('${classId}','${stuId}')">Supprimer</button></div></div>`);
}
function deleteStudent(classId,stuId){confirmDeleteStudent(classId,stuId);}
function doDeleteStudent(classId,stuId){
const c=cls(classId);if(!c)return;
c.students=c.students.filter(s=>s.id!==stuId);closeModal();showToast('Élève retiré.');S.navigate('une-classe',{classId,tab:'eleves'},false);
}
function confirmResetResults(classId,actId){
const c=cls(classId);const a=act(actId);if(!c||!a)return;
showModal(`<div class="modal"><div class="mhd"><h2>Réinitialiser les résultats ?</h2><button class="btn-ico" onclick="closeModal()">✕</button></div>
<div class="mbody"><p style="font-size:13px;line-height:1.6;">Réinitialiser les résultats de <strong>${c.name}</strong> pour <strong>${a.name}</strong> ?<br>Les élèves pourront recommencer. Action irréversible.</p></div>
<div class="mfoot"><button class="btn btn-s" onclick="closeModal()">Annuler</button><button class="btn btn-d" onclick="doResetResults('${classId}','${actId}')">Réinitialiser</button></div></div>`);
}
function doResetResults(classId,actId){
const c=cls(classId);if(!c)return;
c.students.forEach(s=>{if(DB.results[s.id])delete DB.results[s.id][actId];});
closeModal();showToast('Résultats réinitialisés pour '+c.name+'.');render();
}

50
js/render.js Normal file
View file

@ -0,0 +1,50 @@
// ===================== SIDEBAR & ROUTER =====================
function renderSidebar(){
const items=[
{id:'mes-classes',icon:'🏫',label:'Mes classes'},
{id:'mes-activites',icon:'📋',label:'Mes modules'},
{id:'suivi-eleves',icon:'📊',label:'Suivi des élèves'},
];
const r=S.route;
document.getElementById('sbNav').innerHTML=items.map(i=>{
const active=(i.id==='mes-classes'&&(r==='mes-classes'||r==='une-classe'))
||(i.id==='mes-activites'&&(r==='mes-activites'||r==='une-activite'||r==='creer-activite'))
||(i.id===r);
return`<div class="sb-item${active?' active':''}" onclick="S.navigate('${i.id}',{year:S.params.year});closeSb()"><span class="ico">${i.icon}</span>${i.label}</div>`;
}).join('');
renderYearSelector();
}
function renderBackButton(){
const prev=S.history[S.history.length-1];
const bc=document.getElementById('breadcrumb');
if(!prev){bc.innerHTML='';return;}
const routeLabels={'accueil':'Accueil','mes-classes':'Mes classes','mes-activites':'Mes modules',
'suivi-eleves':'Suivi des élèves','creer-activite':'Modifier le module'};
let label=routeLabels[prev.route]||prev.route;
if(prev.route==='une-classe'&&prev.params?.classId){const c=cls(prev.params.classId);if(c)label=c.name;}
if(prev.route==='une-activite'&&prev.params?.activityId){const a=act(prev.params.activityId);if(a)label=a.name;}
bc.innerHTML=`<button class="back-btn" onclick="S.back()">← ${label}</button>`;
}
function render(){
const {route,params}=S;
document.body.className=document.body.className
.replace(/\broute-\S+/g,'').trim();
document.body.classList.add('route-'+route);
renderSidebar();renderBackButton();
const page=document.getElementById('pageContent');
if(route==='accueil')page.innerHTML=viewAccueil();
else if(route==='mes-classes')page.innerHTML=viewMesClasses();
else if(route==='une-classe')page.innerHTML=viewUneClasse(params);
else if(route==='mes-activites')page.innerHTML=viewMesActivites();
else if(route==='creer-activite')page.innerHTML=viewCreerActivite(params);
else if(route==='une-activite')page.innerHTML=viewUneActivite(params);
else if(route==='suivi-eleves')page.innerHTML=viewSuiviEleves(params);
else if(route==='acces-libre')page.innerHTML=viewAccesLibre(params);
else if(route==='tableau-de-bord')page.innerHTML=viewTableauDeBord(params);
window.scrollTo(0,0);
if(route==='creer-activite')initDragDrop();
if(route==='une-classe'||route==='suivi-eleves'||route==='une-activite')setTimeout(initStudentNames,0);
}
render();

125
js/state.js Normal file
View file

@ -0,0 +1,125 @@
// ===================== URL ROUTING =====================
const BASE = (() => {
const p = location.pathname;
return p.replace(/\/index\.html$/, '').replace(/\/$/, '');
})();
function routeToPath(route, params) {
if (route === 'accueil') return '/guide';
if (route === 'mes-classes') return '/mes-classes';
if (route === 'mes-activites') return '/mes-modules';
if (route === 'suivi-eleves') return '/suivi-eleves';
if (route === 'acces-libre') return '/acces-libre';
if (route === 'tableau-de-bord') {
if (params.dashV === 2) return '/tableau-de-bord/activites';
if (params.dashV === 3) return '/tableau-de-bord/heatmap';
return '/tableau-de-bord';
}
if (route === 'une-classe') {
const seg = { eleves: 'eleves', activite: 'activites', progression: 'progression' };
return `/mes-classes/${params.classId}/${seg[params.tab] || 'eleves'}`;
}
if (route === 'creer-activite') {
return params.activityId ? `/mes-modules/${params.activityId}/modifier` : '/mes-modules/creer';
}
if (route === 'une-activite') {
return `/mes-modules/${params.activityId}/${params.tab || 'questions'}`;
}
return '/' + route;
}
function pathToRoute(path) {
const p = path || '/';
if (p === '/' || p === '' || p === '/guide') return { route: 'accueil', params: {} };
const parts = p.split('/').filter(Boolean);
if (parts[0] === 'mes-classes') {
if (parts.length === 1) return { route: 'mes-classes', params: {} };
if (parts.length >= 3) {
const tabMap = { eleves: 'eleves', activites: 'activite', progression: 'progression' };
return { route: 'une-classe', params: { classId: parts[1], tab: tabMap[parts[2]] || 'eleves' } };
}
return { route: 'mes-classes', params: {} };
}
if (parts[0] === 'mes-modules') {
if (parts.length === 1) return { route: 'mes-activites', params: {} };
if (parts[1] === 'creer') return { route: 'creer-activite', params: {} };
if (parts.length >= 3) {
if (parts[2] === 'modifier') return { route: 'creer-activite', params: { activityId: parts[1] } };
return { route: 'une-activite', params: { activityId: parts[1], tab: parts[2] } };
}
return { route: 'mes-activites', params: {} };
}
if (parts[0] === 'suivi-eleves') return { route: 'suivi-eleves', params: {} };
if (parts[0] === 'acces-libre') return { route: 'acces-libre', params: {} };
if (parts[0] === 'tableau-de-bord') {
const dashMap = { activites: 2, heatmap: 3 };
return { route: 'tableau-de-bord', params: { dashV: dashMap[parts[1]] || 1 } };
}
return { route: 'accueil', params: {} };
}
// ===================== STATE =====================
const S = {
route: 'accueil',
params: { year: '2025-2026' },
history: [],
quizEditor: null,
quizDirty: false,
navigate(route, params = {}, pushHist = true) {
const year = this.params.year || '2025-2026';
const newParams = { year, ...params };
if (pushHist && this.route) this.history.push({ route: this.route, params: { ...this.params } });
this.route = route;
this.params = newParams;
const url = BASE + routeToPath(route, newParams);
const state = { route, params: newParams, hist: [...this.history] };
if (pushHist) history.pushState(state, '', url);
else history.replaceState(state, '', url);
render();
},
back() {
if (this.history.length) {
history.back(); // popstate handler restores state and calls render()
} else {
this.navigate('mes-classes', {}, false);
}
},
setYear(y) {
this.params = { ...this.params, year: y };
history.replaceState({ route: this.route, params: this.params, hist: [...this.history] }, '', location.href);
render();
}
};
// Browser back / forward
window.addEventListener('popstate', e => {
if (e.state) {
S.route = e.state.route;
S.params = e.state.params;
S.history = e.state.hist || [];
} else {
const path = location.pathname.slice(BASE.length) || '/';
const { route, params } = pathToRoute(path);
S.route = route;
S.params = { year: '2025-2026', ...params };
S.history = [];
}
render();
});
// Init from URL on first load
(function initRoute() {
const path = location.pathname.slice(BASE.length) || '/';
const { route, params } = pathToRoute(path);
S.route = route;
S.params = { year: '2025-2026', ...params };
history.replaceState({ route, params: S.params, hist: [] }, '', BASE + routeToPath(route, S.params));
})();

57
js/views/acces-libre.js Normal file
View file

@ -0,0 +1,57 @@
// ===================== (ANCIEN) ACCÈS LIBRE — conservé pour toggleFreeAccess =====================
function viewAccesLibre({selectedClassId}){
const selId=selectedClassId||DB.classes[0].id;
const c=cls(selId);
const fa=DB.freeAccess[selId]||(DB.freeAccess[selId]=new Set());
const totalUnlocked=fa.size;
return`<div class="ph">
<div><div class="pt">🔓 Accès libre</div>
<div class="ps">Rendre un contenu accessible indépendamment de la progression de l'élève</div></div>
</div>
<div class="alert alert-info mb20"> Par défaut, les élèves débloquent le contenu en progressant dans l'ordre. Ici vous pouvez forcer l'accès à n'importe quel chapitre ou étape pour une classe donnée, quelle que soit leur avancée.</div>
<div class="flex g12 mb24" style="align-items:center;flex-wrap:wrap;">
<label style="text-transform:none;font-size:13px;font-weight:600;margin-bottom:0;">Classe :</label>
${DB.classes.map(c2=>`<button class="btn ${selId===c2.id?'btn-p':'btn-s'} btn-sm" onclick="S.navigate('acces-libre',{selectedClassId:'${c2.id}'},false)">${c2.name}</button>`).join('')}
<span class="badge b-amber ms">${totalUnlocked} déverrouillé${totalUnlocked!==1?'s':''}</span>
</div>
<div class="al-grid">
${CHAP_CONTENT.map(ch=>`<div class="al-chap">
<div class="al-chap-hd"> Chapitre ${ch.chap}</div>
${ch.steps.map(step=>{
const key=`c${ch.chap}s${step.s}`;const isOn=fa.has(key);
const icon=step.type==='Aventure'?'🗺️':'📝';
return`<div class="al-item">
<div class="al-item-label">
<span style="font-size:14px;">${icon}</span>
<div>
<div style="font-size:12px;font-weight:600;">${step.type}</div>
<div style="font-size:10px;color:var(--muted);">${step.type==='Aventure'?'Aventure '+(step.s<=2?1:2):('Quiz '+(step.s<=2?1:2))}</div>
</div>
</div>
<label class="toggle-sw">
<input type="checkbox" ${isOn?'checked':''} onchange="toggleFreeAccess('${selId}','${key}',this.checked)">
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</label>
</div>`;}).join('')}
</div>`).join('')}
</div>
<div class="card" style="padding:18px;">
<div class="card-title mb12">Résumé ${c.name}</div>
${totalUnlocked===0?`<p class="sm muted">Aucun contenu déverrouillé. Tous les élèves progressent selon leur niveau.</p>`:`
<p class="sm muted mb12">Contenu déverrouillé pour tous les élèves de ${c.name} :</p>
<div class="flex g8" style="flex-wrap:wrap;">
${[...fa].map(key=>{
const m=key.match(/c(\d)s(\d)/);if(!m)return'';
const step=CHAP_CONTENT[m[1]-1]?.steps[m[2]-1];if(!step)return'';
return`<span class="badge b-amber">⚔️ Chap. ${m[1]}${step.type} ${m[2]<=2?1:2}</span>`;
}).join('')}
</div>`}
</div>`;
}
function toggleFreeAccess(classId,key,checked){
if(!DB.freeAccess[classId])DB.freeAccess[classId]=new Set();
if(checked){DB.freeAccess[classId].add(key);showToast('Accès activé ✓','ok');}
else{DB.freeAccess[classId].delete(key);showToast('Accès retiré');}
S.navigate('acces-libre',{selectedClassId:classId},false);
}

235
js/views/accueil.js Normal file
View file

@ -0,0 +1,235 @@
// ===================== ACCUEIL =====================
function copyCode(code, el) {
navigator.clipboard.writeText(code).then(() => {
const icon = el.querySelector('.gms-code-copy-icon');
if (!icon) return;
icon.src = 'assets/images/Check.svg';
setTimeout(() => { icon.src = 'assets/images/file-copy-line.svg'; }, 1500);
});
}
const CLASS_COLORS = ['#E8922A','#534bac','#2884aa','#1a7c50','#9200a6','#d55600'];
function classInitials(name) {
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0,2);
}
function classColor(id) {
const idx = parseInt(id.replace(/\D/g,''), 10) || 0;
return CLASS_COLORS[idx % CLASS_COLORS.length];
}
function classProgressSummary(c) {
const started = c.students.filter(s => {
const p = DB.progression[s.id]; return p && totalSteps(p) > 0;
}).length;
const finished = c.students.filter(s => {
const p = DB.progression[s.id]; return p && totalSteps(p) >= 16;
}).length;
const n = c.students.length;
if (!n) return '<span class="gms-muted">Aucun élève</span>';
if (finished === n) return '<span class="status-badge success">✓ Tous terminés</span>';
if (started === 0) return '<span class="status-badge draft">Pas commencé</span>';
return `<span class="status-badge ongoing">${started}/${n} en cours</span>`;
}
function goToInterface() {
const card = document.getElementById('gmFlipCard');
const acceder = document.getElementById('gmAccederTab');
const tab = document.getElementById('gmGuideTab');
const nav = document.getElementById('gmNavTabs');
if (!card) return;
// Guide overlay ouvert sur une page interface (ex: une-classe) → juste re-flipper
if (S.route !== 'accueil') {
const clsTabs = document.getElementById('gmClassTabs');
if (acceder) acceder.classList.add('tab-down');
setTimeout(() => {
if (acceder) acceder.classList.add('tab-gone');
card.classList.add('is-flipped');
setTimeout(() => {
if (tab) { tab.classList.remove('tab-gone'); tab.classList.remove('tab-down'); }
if (nav) { nav.classList.remove('tab-gone'); nav.classList.remove('tab-down'); }
if (clsTabs) { clsTabs.classList.remove('tab-gone'); clsTabs.classList.remove('tab-down'); }
}, 500);
}, 400);
return;
}
// Page /guide avec historique → retour à la page précédente
if (S.history.length) {
S.back();
return;
}
// Page /guide, première visite → flip + mise à jour URL vers /mes-classes
S.history.push({ route: S.route, params: { ...S.params } });
S.params = { ...S.params, _flipped: true };
history.pushState(
{ route: S.route, params: S.params, hist: [...S.history] },
'',
BASE + '/mes-classes'
);
if (acceder) acceder.classList.add('tab-down');
setTimeout(() => {
if (acceder) acceder.classList.add('tab-gone');
card.classList.add('is-flipped');
setTimeout(() => {
if (tab) { tab.classList.remove('tab-gone'); tab.classList.remove('tab-down'); }
if (nav) { nav.classList.remove('tab-gone'); nav.classList.remove('tab-down'); }
}, 500);
}, 400);
}
function toggleFlip() {
const card = document.getElementById('gmFlipCard');
const tab = document.getElementById('gmGuideTab');
const acceder = document.getElementById('gmAccederTab');
const nav = document.getElementById('gmNavTabs');
const clsTabs = document.getElementById('gmClassTabs');
if (!card) return;
const wasFlipped = !!S.params._flipped;
if (tab) tab.classList.add('tab-down');
if (nav) nav.classList.add('tab-down');
if (clsTabs) clsTabs.classList.add('tab-down');
setTimeout(() => {
if (tab) tab.classList.add('tab-gone');
if (nav) nav.classList.add('tab-gone');
if (clsTabs) clsTabs.classList.add('tab-gone');
card.classList.remove('is-flipped');
setTimeout(() => {
if (acceder) { acceder.classList.remove('tab-gone'); acceder.classList.remove('tab-down'); }
// Si on avait pushé un état pour le flip, revenir en arrière (restaure /guide)
if (wasFlipped) history.back();
}, 500);
}, 400);
}
// Shell GMS partagé — utilisé par viewAccueil et viewUneClasse
// classTabs : { classes: [...], activeClassId: '...', tab: '...' } ou null
function renderGmsShell(frontContent, { startOnInterface = false, activeNavTab = 'classes', guideKey = 'classes', classTabs = null, wrapContent = true } = {}) {
const guideFns = { classes: guideClasses, modules: guideModules, suivi: guideSuivi };
const guideHtml = (guideFns[guideKey] || guideClasses)();
const navItems = [
{ id: 'classes', label: 'Mes classes', onclick: `S.navigate('accueil',{year:S.params.year,_flipped:true})` },
{ id: 'modules', label: 'Mes modules', onclick: `S.navigate('mes-activites',{year:S.params.year})` },
{ id: 'suivi', label: 'Suivi des élèves', onclick: `S.navigate('suivi-eleves',{year:S.params.year})` },
];
const tabHide = startOnInterface ? '' : ' tab-down tab-gone';
const accederHide = startOnInterface ? ' tab-down tab-gone' : '';
const cardClass = startOnInterface ? ' is-flipped' : '';
const classTabsHtml = classTabs ? `
<div class="gms-class-tabs" id="gmClassTabs">
${classTabs.classes.map(c => {
const isActive = c.id === classTabs.activeClassId;
return `<div class="gms-class-tab${isActive ? ' is-active' : ''}"
onclick="S.navigate('une-classe',{classId:'${c.id}',tab:'${classTabs.tab}'})">
<div class="gms-class-tab-name">${escHtml(c.name)}</div>
<div class="gms-class-tab-code">
<span class="gms-tab-code-wrap" onclick="copyCode(${jsSQ(c.code)},this);event.stopPropagation()">
<code>${escHtml(c.code || '—')}</code>
${c.code ? `<img class="gms-code-copy-icon" src="assets/images/file-copy-line.svg" alt="">` : ''}
</span>
</div>
</div>`;
}).join('')}
</div>` : '';
return `
<header class="gms-header">
<div class="gms-header-left">
<img src="assets/images/logo.png" onclick="S.navigate('accueil',{year:S.params.year})" style="cursor:pointer;display:block;height:auto;max-height:90px;object-fit:contain;" alt="Conquiers Ta Vie">
</div>
<div class="gms-header-right">
<button class="gms-user-btn" style="background:transparent;color:var(--gms-grey);border-color:var(--gms-grey-light);">
Alex Lefebvre
</button>
</div>
</header>
<div class="gms-page">
<div class="gms-guide-tab${tabHide}" id="gmGuideTab" onclick="toggleFlip()">
<img src="assets/images/question-line.svg" alt="Guide">
</div>
<div class="gms-guide-tab gms-acceder-tab${accederHide}" id="gmAccederTab" onclick="goToInterface()">
Accéder à l'interface
<img src="assets/images/play-circle-fill.svg" alt="">
</div>
${classTabsHtml}
<div class="gms-content-area">
<div class="gms-nav-tabs${tabHide}" id="gmNavTabs">
${navItems.map(n => `<div class="gms-nav-tab${n.id === activeNavTab ? ' is-active' : ''}" onclick="${n.onclick}">${n.label}</div>`).join('')}
</div>
<div class="gms-flip-card${cardClass}" id="gmFlipCard">
<div class="gms-flip-front">
${wrapContent ? `<div class="gms-page-content">${frontContent}</div>` : frontContent}
</div>
<div class="gms-flip-back">
${guideHtml}
</div>
</div>
</div>
</div>`;
}
function viewAccueil() {
const fc = filteredClasses();
const frontContent = `
<div class="data-table">
<table class="data-table__table">
<thead>
<tr>
<th>Classe</th>
<th>Code</th>
<th>Élèves</th>
<th>Modules assignés</th>
<th>Progression</th>
<th class="actions-column">Actions</th>
</tr>
</thead>
<tbody>
${fc.length === 0 ? `
<tr><td colspan="6" style="text-align:center;padding:3rem;color:var(--color-grey);font-family:'Danzza',sans-serif;">
Aucune classe pour l'année ${S.params.year || '2025-2026'}.
<br><br>
<button class="btn btn--s btn--filled color-palette-ctv" onclick="showNewClassModal()">+ Nouvelle classe</button>
</td></tr>
` : fc.map(c => {
const assignedActs = DB.activities.filter(a => c.activities.includes(a.id) && a.status === 'published');
const color = classColor(c.id);
return `<tr onclick="S.navigate('une-classe',{classId:'${c.id}',tab:'activite'})" style="cursor:pointer;">
<td>
<div class="gms-class-link">
<div class="gms-class-thumbnail" style="background-color:${color};">${classInitials(c.name)}</div>
<span class="gms-class-name">${c.name}</span>
</div>
</td>
<td>
<span class="gms-class-code-wrap" onclick="copyCode(${jsSQ(c.code)}, this); event.stopPropagation()">
<span class="gms-class-code">${c.code || '—'}</span>
<img class="gms-code-copy-icon" src="assets/images/file-copy-line.svg" alt="">
</span>
</td>
<td class="gms-muted">${c.students.length} élève${c.students.length !== 1 ? 's' : ''}</td>
<td class="gms-muted">
${assignedActs.length === 0
? '<span style="opacity:.5;">Aucun</span>'
: assignedActs.map(a => `<span style="margin-right:6px;">${a.name}</span>`).join('<span class="gms-dot">•</span>')}
</td>
<td>${classProgressSummary(c)}</td>
<td class="actions-cell">
<button class="btn btn--square btn--s btn--bordered color-palette-ctv" title="Renommer" onclick="showRenameClassModal('${c.id}');event.stopPropagation()"><i class="ri-pencil-line"></i></button>
<button class="btn btn--square btn--s btn--in-between color-palette-warning" title="Supprimer" onclick="confirmDeleteClass('${c.id}');event.stopPropagation()"><i class="ri-delete-bin-line"></i></button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
return renderGmsShell(frontContent, { startOnInterface: !!S.params._flipped, activeNavTab: 'classes' });
}

304
js/views/activites.js Normal file
View file

@ -0,0 +1,304 @@
// ===================== MES ACTIVITÉS =====================
function toggleStatus(actId,e){
e.stopPropagation();const a=act(actId);if(!a)return;
a.status=a.status==='published'?'draft':'published';
showToast(a.status==='published'?'✓ Module publié':'Module mis en brouillon','ok');render();
}
function viewMesActivites(){
const allActs=DB.activities;
const pub=allActs.filter(a=>a.status==='published').length;
const draft=allActs.filter(a=>a.status==='draft').length;
const frontContent=`
<div class="fbet mb16" style="align-items:flex-start;">
<div>
<div style="font-size:var(--size-text-xl);font-weight:var(--weight-text-l);margin-bottom:4px;">Mes modules</div>
<div class="xs muted">${pub} publié${pub!==1?'s':''} · ${draft} brouillon${draft!==1?'s':''}</div>
</div>
<div class="flex g10">
<button class="btn btn-s btn-sm" onclick="showImportActivityModal()">Importer</button>
<button class="btn btn-p" onclick="initNewQuiz()">+ Nouveau module</button>
</div>
</div>
${allActs.length===0
?`<div class="empty"><div class="empty-ico">📋</div><p>Aucun module. Créez-en un !</p></div>`
:`<div class="data-table">
<table class="data-table__table">
<thead>
<tr>
<th>Module</th>
<th>Type</th>
<th>Statut</th>
<th class="actions-column">Actions</th>
</tr>
</thead>
<tbody>
${allActs.map(a=>{
const color=classColor(a.id);
const initial=a.name.trim()[0].toUpperCase();
const statusCls=a.status==='published'?'b-ok':'b-draft';
const statusLabel=a.status==='published'?'✓ Publié':'Brouillon';
return`<tr style="cursor:pointer;" onclick="S.navigate('une-activite',{activityId:'${a.id}',tab:'questions'})">
<td>
<div class="gms-class-link">
<div class="gms-class-thumbnail" style="background-color:${color};">${initial}</div>
<span class="gms-class-name">${escHtml(a.name)}</span>
</div>
</td>
<td><span class="badge b-info">Quiz · ${a.questions.length} q.</span></td>
<td>
<span class="badge ${statusCls} badge-toggle" onclick="toggleStatus('${a.id}',event);event.stopPropagation()" title="Cliquer pour changer">${statusLabel} </span>
</td>
<td class="actions-cell">
<button class="btn btn--square btn--s btn--bordered color-palette-ctv" title="Modifier" onclick="S.navigate('creer-activite',{activityId:'${a.id}'});event.stopPropagation()"><i class="ri-edit-line"></i></button>
<button class="btn btn--square btn--s btn--in-between color-palette-warning" title="Supprimer" onclick="confirmDeleteActivity('${a.id}');event.stopPropagation()"><i class="ri-delete-bin-line"></i></button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`}
<div class="gms-section" style="margin-top:var(--gap-xl,32px);">
<div style="margin-bottom:12px;">
<div style="font-size:var(--size-text-xl);font-weight:var(--weight-text-l);margin-bottom:4px;">Bibliothèque du département</div>
<div class="xs muted">Modules prêts à l'emploi fournis par le département. L'import crée une copie indépendante dans vos modules.</div>
</div>
<div class="data-table">
<table class="data-table__table">
<thead><tr><th>Module</th><th>Type</th><th class="actions-column">Action</th></tr></thead>
<tbody>
${DB.libraryActivities.map(lib=>{
const color=classColor(lib.id);
return`<tr>
<td>
<div class="gms-class-link">
<div class="gms-class-thumbnail" style="background-color:${color};">${lib.name.trim()[0].toUpperCase()}</div>
<span class="gms-class-name">${escHtml(lib.name)}</span>
</div>
</td>
<td><span class="badge b-info">Quiz · ${lib.questions.length} q.</span></td>
<td class="actions-cell">
<button class="btn btn--s btn--bordered color-palette-ctv"
onclick="importLibraryActivity('${lib.id}');event.stopPropagation()">
<i class="ri-download-line"></i> Importer
</button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
</div>`;
return renderGmsShell(frontContent,{startOnInterface:true,activeNavTab:'modules',guideKey:'modules'});
}
// ===================== UNE ACTIVITÉ =====================
function viewUneActivite({activityId,tab='questions',fromClassId}){
const a=act(activityId);if(!a)return'<div class="empty"><h3>Activité introuvable</h3></div>';
const tabs=[{id:'questions',label:'Questions'},{id:'classes',label:'Classes'},{id:'resultats',label:'Résultats'}];
const np=fromClassId?`,fromClassId:'${fromClassId}'`:'';
const content=tab==='questions'?tabQuestions(a,activityId):tab==='classes'?tabClassesActivite(a):tabResultatsActivite(a,fromClassId);
const frontContent=`
<div class="gms-une-classe-wrap">
<div class="container__tabs container__tabs--l container__tabs--connected container__tabs--full-width">
${tabs.map(t=>`
<button class="tab tab--m${tab===t.id?' active':''}"
onclick="S.navigate('une-activite',{activityId:'${activityId}',tab:'${t.id}'${np}})">
${t.label}
</button>`).join('')}
</div>
<div class="gms-une-classe-content">
${content}
</div>
</div>`;
return renderGmsShell(frontContent,{startOnInterface:true,activeNavTab:'modules',guideKey:'modules',wrapContent:false});
}
function tabQuestions(a,activityId){
const statusCls=a.status==='published'?'b-ok':'b-draft';
const statusLabel=a.status==='published'?'✓ Publié':'Brouillon';
const actionBar=`
<div class="fbet mb16" style="align-items:center;flex-wrap:wrap;gap:10px;">
<div class="flex g10" style="align-items:center;flex-wrap:wrap;">
<span style="font-size:var(--size-text-xl);font-weight:var(--weight-text-l);">${escHtml(a.name)}</span>
<span class="badge ${statusCls} badge-toggle" onclick="toggleStatus('${a.id}',event)" title="Cliquer pour changer">${statusLabel} </span>
${a.code?`<span class="badge b-gray">Code : <strong>${a.code}</strong></span>`:''}
</div>
<div class="flex g10">
<button class="btn btn--s btn--bordered color-palette-ctv" onclick="S.navigate('creer-activite',{activityId:'${activityId}'})"> Modifier</button>
<button class="btn btn--s btn--bordered" style="border-color:var(--color-red);color:var(--color-red);" onclick="confirmDeleteActivity('${activityId}')">🗑 Supprimer</button>
</div>
</div>`;
if(!a.questions.length)return actionBar+`<div class="empty"><div class="empty-ico">📝</div><h3>Aucune question</h3><button class="btn btn-p" onclick="S.navigate('creer-activite',{activityId:'${activityId}'})">Modifier</button></div>`;
return actionBar+a.questions.map((q,i)=>`<div class="q-card mb12">
<div class="q-hd"><span class="q-num">Question ${i+1}</span></div>
<p class="semi mb12">${escHtml(q.text)}</p>
${q.answers.map((ans,j)=>`<div class="ans-row${j===q.correct?' correct':''}">
<div class="ans-letter">${'ABCD'[j]}</div><span style="flex:1;font-size:13px;">${escHtml(ans)}</span>
${j===q.correct?'<span class="badge b-ok">✓ Correcte</span>':''}
</div>`).join('')}
<div class="tip mt12" style="margin-bottom:0;"><div class="tip-title">💬 Feedback</div><div class="tip-body">${escHtml(q.feedback)}</div></div>
</div>`).join('');}
function tabClassesActivite(a){
const allCls=filteredClasses();
return`
<p class="xs muted mb14">Ce module sera visible dans l'<strong>espace collège</strong> des élèves des classes activées.</p>
${allCls.length===0
?`<div class="empty"><p>Aucune classe pour cette année scolaire.</p></div>`
:`<div class="data-table">
<table class="data-table__table">
<thead>
<tr>
<th>Classe</th>
<th>Élèves</th>
<th style="text-align:center;width:80px;">Activer</th>
</tr>
</thead>
<tbody>
${allCls.map(c=>{
const isOn=a.assignedClasses.includes(c.id);
const color=classColor(c.id);
return`<tr>
<td>
<div class="gms-class-link">
<div class="gms-class-thumbnail" style="background-color:${color};">${classInitials(c.name)}</div>
<span class="gms-class-name">${escHtml(c.name)}</span>
</div>
</td>
<td><span class="badge b-gray">${c.students.length} élève${c.students.length!==1?'s':''}</span></td>
<td style="text-align:center;">
<label class="toggle-sw">
<input type="checkbox" ${isOn?'checked':''} onchange="toggleActClass('${a.id}','${c.id}',this.checked)">
<div class="toggle-track"></div><div class="toggle-thumb"></div>
</label>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`}`;
}
function toggleActClass(actId,classId,checked){
const a=act(actId);const c=cls(classId);if(!a||!c)return;
if(checked){if(!a.assignedClasses.includes(classId))a.assignedClasses.push(classId);if(!c.activities.includes(actId))c.activities.push(actId);}
else{a.assignedClasses=a.assignedClasses.filter(id=>id!==classId);c.activities=c.activities.filter(id=>id!==actId);}
showToast(checked?`Module assigné à ${c.name}`:`Retiré de ${c.name}`,checked?'ok':'');
S.navigate('une-activite',{activityId:actId,tab:'classes'},false);
}
function tabResultatsActivite(a,fromClassId){
const assignedCls=DB.classes.filter(c=>a.assignedClasses.includes(c.id));
const allStu=assignedCls.flatMap(c=>c.students);
let doneCount=0;allStu.forEach(s=>{const r=(DB.results[s.id]||{})[a.id];if(r&&r.status==='done')doneCount++;});
const totalStu=allStu.length;
const sel=fromClassId?[fromClassId]:(S.params.selectedActCls||[]);
const displayCls=sel.length>0?assignedCls.filter(c=>sel.includes(c.id)):assignedCls;
const openId=S.params.openResultsCls||(displayCls.length===1?displayCls[0].id:null);
const nQ=a.questions.length;
const globalPcts=qPct(allStu,a.id,nQ);
const nonNull=globalPcts.filter(v=>v!==null);
const globalAvg=nonNull.length?Math.round(nonNull.reduce((s,v)=>s+v,0)/nonNull.length):null;
const chips=assignedCls.length>1?chipFilter(assignedCls,c=>c.id,c=>c.name,sel,'setActCls',`setActCls([])`):'';
const globalHeat=`<div class="card mb20">
<div class="card-hd fbet">
<span class="card-title">Réussite globale par question</span>
<button class="btn btn-s btn-sm" onclick="exportResultsCSV('${a.id}')"> CSV global</button>
</div>
<div class="heat-wrap" style="padding:16px;">
<table class="heat-table"><thead><tr>
<th class="ht-code">Scope</th>${a.questions.map((_,i)=>`<th>Q${i+1}</th>`).join('')}<th style="text-align:right;padding-left:10px;">Moy.</th>
</tr></thead><tbody><tr>
<td class="ht-code" style="font-size:12px;color:var(--muted);font-weight:600;">${doneCount}/${totalStu} réponses</td>
${globalPcts.map(p=>`<td>${pctBadge(p)}</td>`).join('')}
<td class="ht-score">${globalAvg!==null?'<span class="badge b-info">'+globalAvg+'%</span>':'—'}</td>
</tr></tbody></table>
</div></div>`;
return`${fromClassId?`<div class="alert alert-info mb16"> Résultats filtrés sur <strong>${cls(fromClassId)?.name}</strong>.</div>`:''}
<div class="overview-grid" style="grid-template-columns:repeat(2,1fr);margin-bottom:var(--gap-l);max-width:360px;">
<div class="metric-card">
<div class="metric-icon"><i class="ri-user-line"></i></div>
<div class="metric-content">
<div class="metric-value">${doneCount}/${totalStu}</div>
<div class="metric-label">Ont répondu</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon"><i class="ri-bar-chart-line"></i></div>
<div class="metric-content">
<div class="metric-value">${globalAvg!==null?globalAvg+'%':'—'}</div>
<div class="metric-label">Score moyen</div>
</div>
</div>
</div>
${globalHeat}${chips}
${displayCls.map(c=>{
const isOpen=openId===c.id;
const cDone=c.students.filter(s=>{const r=(DB.results[s.id]||{})[a.id];return r&&r.status==='done';}).length;
const cPcts=qPct(c.students,a.id,nQ);
const cNonNull=cPcts.filter(v=>v!==null);
const cAvg=cNonNull.length?Math.round(cNonNull.reduce((s,v)=>s+v,0)/cNonNull.length):null;
return`<div class="acc-cls">
<div class="acc-hd${isOpen?' open':''}" onclick="toggleAccordion('openResultsCls','${c.id}')">
<span class="card-title">${c.name} <span class="badge b-gray ms">${c.students.length} élèves</span></span>
<div class="flex g10" style="align-items:center;">
<span class="xs muted">${cDone}/${c.students.length} réponses${cAvg!==null?' · moy. '+cAvg+'%':''}</span>
<button class="btn btn-s btn-sm" onclick="event.stopPropagation();exportResultsCSV('${a.id}','${c.id}')"> CSV</button>
<button class="btn btn-d btn-sm" onclick="event.stopPropagation();confirmResetResults('${c.id}','${a.id}')">🗑 Reset</button>
<span style="color:var(--muted);font-size:11px;">${isOpen?'▲':'▼'}</span>
</div>
</div>
<div style="padding:10px 16px;background:#F8F5EF;border-bottom:1px solid var(--border);">
<table class="heat-table"><tbody><tr>
<td class="ht-code" style="font-size:11px;color:var(--muted);font-weight:600;">% par question</td>
${cPcts.map(p=>`<td>${pctBadge(p)}</td>`).join('')}
<td class="ht-score">${cAvg!==null?cAvg+'%':'—'}</td>
</tr></tbody></table>
</div>
${isOpen?`<div>${renderHeatGrid(c,a)}</div>`:''}
</div>`;
}).join('')}`;
}
function renderHeatGrid(c,a){
const qs=a.questions;const nQ=qs.length;
return`<div class="heat-wrap" style="padding:14px 16px;">
<table class="heat-table">
<thead><tr>
<th class="ht-code">Code élève</th>${qs.map((_,i)=>`<th>Q${i+1}</th>`).join('')}<th style="text-align:right;padding-left:10px;">Score</th>
</tr></thead>
<tbody>${c.students.map(s=>{
const r=(DB.results[s.id]||{})[a.id];
const done=r&&r.status==='done';
const score=done?ansScore(r.ans):null;
return`<tr>
<td class="ht-code">${s.code}</td>
${qs.map((_,i)=>{
if(!done)return`<td><div class="hcell hc-none" title="Pas répondu">·</div></td>`;
return`<td><div class="hcell ${r.ans[i]?'hc-ok':'hc-no'}" title="Q${i+1}: ${r.ans[i]?'✓ Correct':'✗ Faux'}">${r.ans[i]?'✓':'✗'}</div></td>`;
}).join('')}
<td class="ht-score">${done?`<span class="badge ${score/nQ>=0.75?'b-ok':score/nQ>=0.4?'b-info':'b-warn'}">${score}/${nQ}</span>`:'<span class="muted xs">—</span>'}</td>
</tr>`;}).join('')}
</tbody>
</table>
</div>`;
}
function importLibraryActivity(libId){
const lib=DB.libraryActivities.find(a=>a.id===libId);
if(!lib)return;
const copy={
...lib,
id:'la'+Date.now(),
status:'draft',
createdAt:new Date().toLocaleDateString('fr-FR',{day:'numeric',month:'short',year:'numeric'}),
assignedClasses:[],
sourceLibId:lib.id,
};
DB.activities.push(copy);
showToast('Module importé dans vos modules ✓','ok');
S.navigate('mes-activites',{year:S.params.year},false);
}

297
js/views/classes.js Normal file
View file

@ -0,0 +1,297 @@
// ===================== MES CLASSES =====================
function viewMesClasses(){
const fc=filteredClasses();
const total=fc.reduce((s,c)=>s+c.students.length,0);
return`<div class="ph"><div><div class="pt">Mes classes</div><div class="ps">${fc.length} classe${fc.length!==1?'s':''} · ${total} élèves</div></div>
<div class="flex g10"><button class="btn btn-s btn-sm" onclick="showImportClassModal()">Importer</button><button class="btn btn-p" onclick="showNewClassModal()">+ Nouvelle classe</button></div></div>
<div class="g3">${fc.map(c=>`<div class="cls-card" onclick="S.navigate('une-classe',{classId:'${c.id}',tab:'eleves'})">
<div class="fbet"><div class="cls-icon">🏫</div></div>
<div class="cls-name">${c.name}</div>
<div class="cls-meta">Code : <strong>${c.code}</strong></div>
<div class="cls-stats">
<div class="cls-stat"><div class="cls-stat-v">${c.students.length}</div><div class="cls-stat-l">élèves</div></div>
<div class="cls-stat"><div class="cls-stat-v">${c.activities.length}</div><div class="cls-stat-l">modules</div></div>
</div></div>`).join('')}</div>`;
}
// ===================== UNE CLASSE =====================
function viewUneClasse({classId, tab='activite'}){
const c = cls(classId);
if (!c) return '<div class="empty"><h3>Classe introuvable</h3></div>';
const allClasses = filteredClasses();
const tabs = [
{id:'activite', label:'Activités'},
{id:'progression',label:'Progression'},
{id:'eleves', label:'Élèves'},
];
const content = tab==='activite' ? tabActiviteClasse(c)
: tab==='progression' ? tabProgressionClasse(c)
: tabEleves(c);
const frontContent = `
<div class="gms-une-classe-wrap">
<div class="container__tabs container__tabs--l container__tabs--connected container__tabs--full-width">
${tabs.map(t => `
<button class="tab tab--m${tab===t.id?' active':''}"
onclick="S.navigate('une-classe',{classId:'${classId}',tab:'${t.id}'})">
${t.label}
</button>`).join('')}
</div>
<div class="gms-une-classe-content">
${content}
</div>
</div>`;
return renderGmsShell(frontContent, {
startOnInterface: true,
activeNavTab: 'classes',
guideKey: 'classes',
classTabs: { classes: allClasses, activeClassId: classId, tab },
wrapContent: false,
});
}
function tabEleves(c){
return`<div class="mb16"><div class="alert alert-info"> Les élèves trouvent leur code dans les paramètres de l'app. Ajoutez leur code ici pour les rattacher à cette classe.</div></div>
<div class="card"><div class="card-hd"><span class="card-title">Élèves <span class="badge b-gray ms">${c.students.length}</span></span>
<button class="btn btn-p btn-sm" onclick="showAddStudentModal('${c.id}')">+ Ajouter</button></div>
<div style="padding:16px;">
<div class="stu-tags">
${c.students.map(s=>`<div class="stu-tag" id="stag_${s.id}">
<code class="stu-code">${s.code}</code>
<span class="stu-name" data-code="${s.code}" onclick="editStuName(this,'${s.code}')" title="Cliquer pour ajouter un prénom (local)">+prénom</span>
<button class="stu-del" onclick="if(confirm('Retirer ${s.code} de la classe ?'))deleteStudent('${c.id}','${s.id}')"></button>
</div>`).join('')}
</div>
<p class="xs muted mt12" style="font-style:italic;">💾 Les prénoms saisis sont stockés localement sur cet appareil uniquement ils ne sont jamais transmis au serveur.</p>
</div></div>`;
}
function tabActiviteClasse(c){
const allActs=DB.activities.filter(a=>a.status==='published');
const fa=DB.freeAccess[c.id]||(DB.freeAccess[c.id]=new Set());
return`
<div class="gms-sections">
<div>
<h2 style="font-size:var(--size-text-xl);font-weight:var(--weight-text-l);margin:0 0 8px;">Assigner mes modules à la classe</h2>
<p class="xs muted" style="margin-bottom:14px;">Les modules activés apparaissent dans l'<strong>espace collège</strong> des élèves de cette classe.</p>
${allActs.length===0
?`<div class="empty"><div class="empty-ico">📋</div><p>Aucun module publié. Créez-en un dans <em>Mes modules</em>.</p></div>`
:`<div class="data-table">
<table class="data-table__table">
<thead>
<tr>
<th>Module</th>
<th>Type</th>
<th style="text-align:center;width:80px;">Activer</th>
<th class="actions-column">Code / Stats</th>
</tr>
</thead>
<tbody>
${allActs.map(a=>{
const isOn=c.activities.includes(a.id);
const color=classColor(a.id);
const initial=a.name.trim()[0].toUpperCase();
return`<tr>
<td>
<div class="gms-class-link">
<div class="gms-class-thumbnail" style="background-color:${color};">${initial}</div>
<span class="gms-class-name">${escHtml(a.name)}</span>
</div>
</td>
<td><span class="badge b-info">Quiz · ${a.questions.length} q.</span></td>
<td style="text-align:center;">
<label class="toggle-sw">
<input type="checkbox" ${isOn?'checked':''} onchange="toggleModuleAssign('${c.id}','${a.id}',this.checked)">
<div class="toggle-track"></div><div class="toggle-thumb"></div>
</label>
</td>
<td style="text-align:center;">
${isOn?`<div style="display:flex;flex-direction:column;align-items:center;gap:5px;">
<span class="gms-class-code-wrap" title="Code de déverrouillage offline"
onclick="copyCode(${jsSQ(generateUnlockCode(c.code,a.id))},this);event.stopPropagation()">
<span class="gms-class-code" style="font-size:10px;">${generateUnlockCode(c.code,a.id)}</span>
<img class="gms-code-copy-icon" src="assets/images/file-copy-line.svg" alt="">
</span>
<button class="btn btn--s btn--bordered color-palette-ctv" style="font-size:11px;padding:3px 8px;" onclick="S.navigate('une-activite',{activityId:'${a.id}',tab:'resultats',fromClassId:'${c.id}'});event.stopPropagation()">Voir</button>
</div>`:''}
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`}
</div>
<div class="gms-section">
<h2>L'aventure principale</h2>
<p class="xs muted" style="margin-bottom:14px;">Par défaut, les chapitres se débloquent progressivement. Activez un accès libre pour des besoins pédagogiques spécifiques (rattrapage, différenciation). Ces contenus apparaissent dans l'<strong>espace collège</strong>.</p>
<div class="al-grid">
${CHAP_CONTENT.map(ch=>`<div class="al-chap">
<div class="al-chap-hd"> Chapitre ${ch.chap}</div>
${ch.steps.map(step=>{
const key=`c${ch.chap}s${step.s}`;const isOn=fa.has(key);
const icon=step.type==='Aventure'?'🗺️':'📝';const stepNum=step.s<=2?1:2;
return`<div class="al-item">
<div class="al-item-label">
<span style="font-size:14px;">${icon}</span>
<div>
<div style="font-size:12px;font-weight:600;">${step.type} ${stepNum}</div>
<div style="font-size:10px;color:var(--muted);">${isOn?'<span style="color:var(--ok);">✓ Déverrouillé</span>':'Verrouillé par défaut'}</div>
</div>
</div>
<label class="toggle-sw">
<input type="checkbox" ${isOn?'checked':''} onchange="toggleFreeAccessCls('${c.id}','${key}',this.checked)">
<div class="toggle-track"></div><div class="toggle-thumb"></div>
</label>
</div>`;}).join('')}
</div>`).join('')}
</div>
</div>
<div class="gms-section">
<h2>Activités dans la vie réelle</h2>
<p class="xs muted" style="margin-bottom:14px;">Activez une activité pour générer un <strong>code de déverrouillage</strong> à communiquer à la classe. L'élève le saisit dans l'app pour débloquer l'activité, même sans connexion.</p>
<div class="al-grid">
${REAL_LIFE_ACTS.map(rlact=>{
const isOn=fa.has(rlact.id);
const code=generateUnlockCode(c.code,rlact.id);
return`<div class="al-item">
<div class="al-item-label">
<span style="font-size:16px;">${rlact.icon}</span>
<div>
<div style="font-size:12px;font-weight:600;">${rlact.label}</div>
${isOn
?`<span class="gms-class-code-wrap" style="margin-top:4px;"
onclick="copyCode(${jsSQ(code)},this)">
<span class="gms-class-code" style="font-size:10px;">${code}</span>
<img class="gms-code-copy-icon" src="assets/images/file-copy-line.svg" alt="">
</span>`
:`<div style="font-size:10px;color:var(--muted);">Désactivé</div>`}
</div>
</div>
<label class="toggle-sw">
<input type="checkbox" ${isOn?'checked':''} onchange="toggleFreeAccessCls('${c.id}','${rlact.id}',this.checked)">
<div class="toggle-track"></div><div class="toggle-thumb"></div>
</label>
</div>`;
}).join('')}
</div>
</div>
</div>`;
}
function toggleModuleAssign(classId,actId,checked){
const c=cls(classId);if(!c)return;
if(checked&&!c.activities.includes(actId))c.activities.push(actId);
if(!checked)c.activities=c.activities.filter(id=>id!==actId);
showToast(checked?'Module activé ✓':'Module désactivé',checked?'ok':'');
S.navigate('une-classe',{classId,tab:'activite'},false);
}
function tabProgressionClasse(c){
const studs=c.students;
const started=studs.filter(s=>{const p=DB.progression[s.id];return p&&totalSteps(p)>0;}).length;
const finished=studs.filter(s=>{const p=DB.progression[s.id];return p&&totalSteps(p)>=16;}).length;
return`
<div class="overview-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:var(--gap-l);">
<div class="metric-card">
<div class="metric-icon"><i class="ri-group-line"></i></div>
<div class="metric-content">
<div class="metric-value">${studs.length}</div>
<div class="metric-label">Élèves</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon"><i class="ri-play-circle-line"></i></div>
<div class="metric-content">
<div class="metric-value">${started}</div>
<div class="metric-label">Ont commencé</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon"><i class="ri-checkbox-circle-line"></i></div>
<div class="metric-content">
<div class="metric-value">${finished}</div>
<div class="metric-label">Ont terminé</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;margin-bottom:12px;">
<button class="btn btn--s btn--bordered color-palette-ctv" onclick="exportProgressionCSV('${c.id}')">
<i class="ri-download-2-line"></i> CSV
</button>
</div>
<div class="data-table">
<table class="data-table__table">
<thead><tr><th>Code élève</th><th>Progression</th><th>Statut</th></tr></thead>
<tbody>${studs.map(s=>{
const p=DB.progression[s.id]||{c1:0,c2:0,c3:0,c4:0};
const tot=totalSteps(p);const pct=Math.round(tot/16*100);
return`<tr>
<td>
<code style="font-size:12px;font-weight:700;">${s.code}</code>
<span class="stu-name-inline" data-code="${s.code}"></span>
</td>
<td><div style="display:flex;align-items:center;gap:8px;">
<div class="prog-bar" style="width:90px;"><div class="prog-fill" style="width:${pct}%;background:${pct>=100?'var(--color-green)':'var(--gms-primary)'};"></div></div>
<span style="font-size:var(--size-text-xs);font-weight:600;">${tot}/16</span>
</div></td>
<td>${progStatusBadge(p)}</td>
</tr>`;}).join('')}
</tbody>
</table>
</div>`;
}
function tabSupport(c){
return`<div class="card mb16">
<div class="card-hd"><span class="card-title"> Réinitialiser un élève</span></div>
<div style="padding:16px;">
<p class="sm muted mb16">Saisissez le code d'un élève pour dissocier son compte de cette classe. L'élève devra resaisir son code dans l'application pour se rattacher à nouveau. Cette action ne supprime pas la progression de l'élève.</p>
<div class="flex g10 mb10">
<input type="text" id="supportStuCode" placeholder="Code élève (ex : A3K7)" style="font-family:monospace;font-weight:700;letter-spacing:.1em;text-transform:uppercase;flex:1;">
<button class="btn btn-s" onclick="resetStudentByCode('${c.id}')">Réinitialiser</button>
</div>
<div id="supportFeedback" class="xs muted"></div>
</div>
</div>`;
}
function resetStudentByCode(classId){
const code=document.getElementById('supportStuCode').value.trim().toUpperCase();
const c=cls(classId);const fb=document.getElementById('supportFeedback');
if(!c||!code){showToast('Saisissez un code élève.');return;}
const stu=c.students.find(s=>s.code===code);
if(!stu){if(fb)fb.textContent=`Aucun élève avec le code "${code}" dans cette classe.`;return;}
if(fb)fb.textContent='';
showToast(`Élève ${code} réinitialisé ✓`,'ok');
document.getElementById('supportStuCode').value='';
}
function editStuName(el,code){
const current=localStorage.getItem('sname_'+code)||'';
const name=prompt('Prénom local pour '+code+' (visible seulement sur cet appareil) :',current);
if(name===null)return;
if(name.trim()){localStorage.setItem('sname_'+code,name.trim());el.textContent=name.trim();el.classList.add('stu-name-set');}
else{localStorage.removeItem('sname_'+code);el.textContent='+prénom';el.classList.remove('stu-name-set');}
}
function initStudentNames(){
document.querySelectorAll('[data-code]').forEach(el=>{
const name=localStorage.getItem('sname_'+el.dataset.code);
if(el.classList.contains('stu-name')){
if(name){el.textContent=name;el.classList.add('stu-name-set');}
else{el.textContent='+prénom';}
} else if(el.classList.contains('stu-name-inline')){
if(name) el.innerHTML=` <span style="font-size:11px;color:var(--muted);font-style:italic;">${name}</span>`;
}
});
}
function toggleFreeAccessCls(classId,key,checked){
if(!DB.freeAccess[classId])DB.freeAccess[classId]=new Set();
if(checked){DB.freeAccess[classId].add(key);showToast('Accès activé ✓','ok');}
else{DB.freeAccess[classId].delete(key);showToast('Accès retiré');}
S.navigate('une-classe',{classId,tab:'activite'},false);
}

91
js/views/quiz-builder.js Normal file
View file

@ -0,0 +1,91 @@
// ===================== QUIZ BUILDER =====================
function initNewQuiz(){S.quizEditor={activityId:null,name:'',questions:[newQ()]};S.quizDirty=false;S.navigate('creer-activite',{});}
function newQ(){return{text:'',answers:['','','',''],correct:0,feedback:''};}
function viewCreerActivite({activityId}){
if(activityId&&!S.quizEditor){const a=act(activityId);if(a)S.quizEditor={activityId,name:a.name,questions:a.questions.map(q=>({...q,answers:[...q.answers]}))};S.quizDirty=false;}
if(!S.quizEditor){S.quizEditor={activityId:null,name:'',questions:[newQ()]};S.quizDirty=false;}
const qe=S.quizEditor;
return`<div class="ph"><div><div class="pt">${activityId?'Modifier le quiz':'Nouveau quiz'}</div>
<div class="ps">${qe.questions.length}/10 questions</div></div>
<div class="flex g10">
<button class="btn btn-s btn-sm" onclick="showTipsModal()">💡 Conseils</button>
<button class="btn btn-s btn-sm" onclick="S.quizEditor=null;S.quizDirty=false;S.back()">Annuler</button>
<button id="quizSaveBtn" class="btn btn-p" ${S.quizDirty?'':'disabled'} onclick="saveQuiz()"> Enregistrer</button>
</div></div>
<div class="fg mb20"><label>Nom du quiz</label><input type="text" id="quizName" value="${escHtml(qe.name)}" placeholder="Ex. : La conquête de l'Angleterre" oninput="S.quizEditor.name=this.value;markDirty()"></div>
<div class="card mb20" style="padding:14px;">
<p class="xs muted mb8">Ordre glissez-déposez pour réorganiser</p>
<div id="thumbsRow" style="display:flex;gap:8px;flex-wrap:wrap;">
${qe.questions.map((q,i)=>`<div data-idx="${i}" draggable="true"
style="border:2px solid var(--border);border-radius:8px;padding:7px 11px;cursor:grab;background:#fff;min-width:75px;max-width:130px;transition:all .15s;"
ondragstart="thumbDragStart(event,${i})" ondragover="thumbDragOver(event)" ondrop="thumbDrop(event,${i})" ondragleave="thumbDragLeave(event)">
<div style="font-size:10px;font-weight:700;color:var(--accent);margin-bottom:2px;">Q${i+1}</div>
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${q.text?q.text.slice(0,30)+'…':'Vide'}</div>
</div>`).join('')}
</div>
</div>
<div id="questionsArea">
${qe.questions.map((q,i)=>renderQCard(q,i)).join('')}
${qe.questions.length<10?`<button class="btn btn-s w100" style="border-style:dashed;" onclick="addQ()">+ Ajouter une question (${qe.questions.length}/10)</button>`:`<p class="sm muted center">Maximum 10 questions atteint.</p>`}
</div>`;
}
function renderQCard(q,i){
return`<div class="q-card" id="qcard-${i}" data-idx="${i}" draggable="true"
ondragstart="cardDragStart(event,${i})" ondragover="cardDragOver(event)" ondrop="cardDrop(event,${i})" ondragleave="cardDragLeave(event)">
<div class="q-hd">
<div class="flex g8" style="align-items:center;"><span class="q-drag" title="Glisser"></span><span class="q-num">Question ${i+1}</span></div>
${S.quizEditor.questions.length>1?`<button class="btn-ico" onclick="removeQ(${i})">🗑️</button>`:''}
</div>
<div class="fg"><label>Intitulé <span class="label-hint">200 car. max</span></label>
<textarea maxlength="200" data-ccid="cc-txt-${i}" placeholder="En quelle année…"
oninput="S.quizEditor.questions[${i}].text=this.value;markDirty();updateCharCount(this,200)">${escHtml(q.text)}</textarea>
<div id="cc-txt-${i}" class="char-count${q.text.length>=170?' near':''}">${q.text.length}/200</div></div>
<p class="sm semi mb8" style="font-size:12px;">Réponses <span class="label-hint">(sélectionnez la correcte · 80 car. max)</span></p>
${['A','B','C','D'].map((L,j)=>`<div class="ans-row${q.correct===j?' correct':''}" id="ansrow-${i}-${j}">
<input type="radio" name="cor-${i}" ${q.correct===j?'checked':''} onchange="setCorrect(${i},${j})" style="width:auto;accent-color:var(--ok);">
<div class="ans-letter">${L}</div>
<div style="flex:1;">
<input type="text" maxlength="80" data-ccid="cc-ans-${i}-${j}" placeholder="Réponse ${L}"
value="${escHtml(q.answers[j])}" oninput="S.quizEditor.questions[${i}].answers[${j}]=this.value;markDirty();updateCharCount(this,80)">
<div id="cc-ans-${i}-${j}" class="char-count${q.answers[j].length>=68?' near':''}">${q.answers[j].length}/80</div>
</div></div>`).join('')}
<div class="fg mt12" style="margin-bottom:0;"><label>Feedback <span class="label-hint">300 car. max</span></label>
<textarea maxlength="300" data-ccid="cc-fb-${i}" placeholder="Pas tout à fait !…"
oninput="S.quizEditor.questions[${i}].feedback=this.value;markDirty();updateCharCount(this,300)">${escHtml(q.feedback)}</textarea>
<div id="cc-fb-${i}" class="char-count${q.feedback.length>=255?' near':''}">${q.feedback.length}/300</div></div>
</div>`;
}
let _dragSrc=null;
function thumbDragStart(e,i){_dragSrc=i;e.currentTarget.style.opacity='.4';}
function thumbDragOver(e){e.preventDefault();e.currentTarget.style.borderColor='var(--accent)';e.currentTarget.style.background='#FFF8EC';}
function thumbDragLeave(e){e.currentTarget.style.borderColor='var(--border)';e.currentTarget.style.background='#fff';}
function thumbDrop(e,i){
e.preventDefault();e.currentTarget.style.borderColor='var(--border)';e.currentTarget.style.background='#fff';
if(_dragSrc===null||_dragSrc===i)return;
const qs=S.quizEditor.questions;const m=qs.splice(_dragSrc,1)[0];qs.splice(i,0,m);_dragSrc=null;S.quizDirty=true;render();
}
let _cardSrc=null;
function cardDragStart(e,i){_cardSrc=i;e.currentTarget.style.opacity='.5';}
function cardDragOver(e){e.preventDefault();e.currentTarget.classList.add('drag-over');}
function cardDragLeave(e){e.currentTarget.classList.remove('drag-over');}
function cardDrop(e,i){
e.preventDefault();e.currentTarget.classList.remove('drag-over');e.currentTarget.style.opacity='1';
if(_cardSrc===null||_cardSrc===i)return;
const qs=S.quizEditor.questions;const m=qs.splice(_cardSrc,1)[0];qs.splice(i,0,m);_cardSrc=null;S.quizDirty=true;render();
}
function initDragDrop(){document.querySelectorAll('.q-card').forEach(c=>c.addEventListener('dragend',()=>{c.style.opacity='1';c.classList.remove('drag-over');}));}
function setCorrect(qIdx,ansIdx){
if(!S.quizEditor)return;
S.quizEditor.questions[qIdx].correct=ansIdx;
document.querySelectorAll(`[id^="ansrow-${qIdx}-"]`).forEach((r,j)=>r.classList.toggle('correct',j===ansIdx));
markDirty();
}
function addQ(){if(!S.quizEditor)return;if(S.quizEditor.questions.length>=10){showToast('Maximum 10 questions.');return;}S.quizEditor.questions.push(newQ());S.quizDirty=true;render();}
function removeQ(i){if(!S.quizEditor||S.quizEditor.questions.length<=1)return;S.quizEditor.questions.splice(i,1);S.quizDirty=true;render();}
function saveQuiz(){
const qe=S.quizEditor;if(!qe)return;
if(!qe.name.trim()){showToast('Donnez un nom au quiz.');return;}
if(qe.questions.some(q=>!q.text.trim())){showToast('Renseignez toutes les questions.');return;}
if(qe.activityId){const a=act(qe.activityId);if(a){a.name=qe.name;a.questions=qe.questions;}showToast('Quiz mis à jour ✓','ok');S.quizEditor=null;S.quizDirty=false;S.navigate('une-activite',{activityId:qe.activityId,tab:'questions'});}
else{const id='a'+Date.now();const code='MOD-'+Math.random().toString(36).slice(2,4).toUpperCase()+Math.random().toString(36).slice(2,4).toUpperCase()+'-GC'+new Date().getFullYear().toString().slice(2);DB.activities.unshift({id,name:qe.name,code,format:'quiz',status:'draft',createdAt:'Maintenant',assignedClasses:[],questions:qe.questions});showToast('Quiz créé ✓','ok');S.quizEditor=null;S.quizDirty=false;S.navigate('une-activite',{activityId:id,tab:'questions'});}
}

79
js/views/suivi.js Normal file
View file

@ -0,0 +1,79 @@
// ===================== SUIVI DES ÉLÈVES =====================
function viewSuiviEleves({selectedClasses,fromClassId,openSuiviCls}){
const sel=selectedClasses||[];
const fc=filteredClasses();const displayCls=sel.length>0?fc.filter(c=>sel.includes(c.id)):fc;
const allStu=displayCls.flatMap(c=>c.students);
const started=allStu.filter(s=>{const p=DB.progression[s.id];return p&&totalSteps(p)>0;}).length;
const finished=allStu.filter(s=>{const p=DB.progression[s.id];return p&&totalSteps(p)>=16;}).length;
const openId=openSuiviCls||(displayCls.length===1?displayCls[0].id:null);
const chips=chipFilter(filteredClasses(),c=>c.id,c=>c.name,sel,'setSuiviCls',`setSuiviCls([])`);
const frontContent=`
<div class="overview-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:var(--gap-l);">
<div class="metric-card">
<div class="metric-icon"><i class="ri-group-line"></i></div>
<div class="metric-content">
<div class="metric-value">${allStu.length}</div>
<div class="metric-label">Élèves suivis</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon"><i class="ri-play-circle-line"></i></div>
<div class="metric-content">
<div class="metric-value">${started}</div>
<div class="metric-label">Ont commencé</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon"><i class="ri-checkbox-circle-line"></i></div>
<div class="metric-content">
<div class="metric-value">${finished}</div>
<div class="metric-label">Ont terminé</div>
</div>
</div>
</div>
${chips}
${displayCls.map(c=>{
const cStu=c.students;
const cStart=cStu.filter(s=>{const p=DB.progression[s.id];return p&&totalSteps(p)>0;}).length;
const cFin=cStu.filter(s=>{const p=DB.progression[s.id];return p&&totalSteps(p)>=16;}).length;
const isOpen=openId===c.id;
return`<div class="acc-cls">
<div class="acc-hd${isOpen?' open':''}" onclick="toggleSuiviCls('${c.id}','${fromClassId||''}',${jsSQ(sel)})">
<span class="card-title">${c.name} <span class="badge b-gray ms">${cStu.length} élèves</span></span>
<div class="flex g10" style="align-items:center;">
<span class="xs muted">${cStart} commencé${cStart!==1?'s':''} · ${cFin} terminé${cFin!==1?'s':''}</span>
<button class="btn btn-s btn-sm" onclick="event.stopPropagation();exportProgressionCSV('${c.id}')"> CSV</button>
<span style="color:var(--muted);font-size:11px;">${isOpen?'▲':'▼'}</span>
</div>
</div>
${isOpen?`<div class="data-table" style="border-top:none;border-radius:0 0 8px 8px;">
<table class="data-table__table">
<thead><tr><th>Code élève</th><th>Progression</th><th>Statut</th></tr></thead>
<tbody>${cStu.map(s=>{
const p=DB.progression[s.id]||{c1:0,c2:0,c3:0,c4:0};
const tot=totalSteps(p);const pct=Math.round(tot/16*100);
return`<tr>
<td>
<code style="font-size:12px;font-weight:700;">${s.code}</code>
<span class="stu-name-inline" data-code="${s.code}"></span>
</td>
<td><div style="display:flex;align-items:center;gap:8px;">
<div class="prog-bar" style="width:90px;"><div class="prog-fill" style="width:${pct}%;background:${pct>=100?'var(--color-green)':'var(--gms-primary)'};"></div></div>
<span style="font-size:var(--size-text-xs);font-weight:600;">${tot}/16</span>
</div></td>
<td>${progStatusBadge(p)}</td>
</tr>`;}).join('')}
</tbody>
</table>
</div>`:''}
</div>`;
}).join('')}`;
return renderGmsShell(frontContent,{startOnInterface:true,activeNavTab:'suivi',guideKey:'suivi'});
}
function toggleSuiviCls(classId,fromClassId,sel){
const cur=S.params.openSuiviCls;const newOpen=cur===classId?null:classId;
S.navigate('suivi-eleves',{selectedClasses:sel,fromClassId:fromClassId||'',openSuiviCls:newOpen},false);
}

178
js/views/tableau-de-bord.js Normal file
View file

@ -0,0 +1,178 @@
// ===================== TABLEAU DE BORD (3 versions) =====================
function viewTableauDeBord({dashV=1}){
const v=parseInt(dashV)||1;
const allStu=DB.classes.flatMap(c=>c.students);
const allStarted=allStu.filter(s=>{const p=DB.progression[s.id];return p&&totalSteps(p)>0;}).length;
const allFinished=allStu.filter(s=>{const p=DB.progression[s.id];return p&&totalSteps(p)>=16;}).length;
const pubActs=DB.activities.filter(a=>a.status==='published');
let totalDone=0,totalPossible=0;
pubActs.forEach(a=>{
const cls2=DB.classes.filter(c=>a.assignedClasses.includes(c.id));
cls2.flatMap(c=>c.students).forEach(s=>{
totalPossible++;const r=(DB.results[s.id]||{})[a.id];if(r&&r.status==='done')totalDone++;
});
});
const completionRate=totalPossible>0?Math.round(totalDone/totalPossible*100):0;
const kpis=`<div class="kpi-strip">
<div class="kpi"><div class="kpi-val">${DB.classes.length}</div><div class="kpi-label">Classes</div></div>
<div class="kpi"><div class="kpi-val">${allStu.length}</div><div class="kpi-label">Élèves</div></div>
<div class="kpi"><div class="kpi-val">${allStarted}</div><div class="kpi-label">Ont commencé·e·s</div></div>
<div class="kpi"><div class="kpi-val">${allFinished}</div><div class="kpi-label">Ont terminé·e·s</div></div>
<div class="kpi"><div class="kpi-val">${completionRate}%</div><div class="kpi-label">Taux complétion quiz</div></div>
</div>`;
let body='';
if(v===1) body=dashV1(allStu,pubActs,allStarted,allFinished);
else if(v===2) body=dashV2(pubActs);
else body=dashV3();
return`<div class="ph"><div><div class="pt">Tableau de bord</div><div class="ps">Vue synthétique · Année 20252026</div></div></div>
${kpis}
<div class="dash-tab-btns mb20">
<button class="dash-tab-btn${v===1?' active':''}" onclick="S.navigate('tableau-de-bord',{dashV:1},false)">Vue 1 Aperçu classes</button>
<button class="dash-tab-btn${v===2?' active':''}" onclick="S.navigate('tableau-de-bord',{dashV:2},false)">Vue 2 Activités</button>
<button class="dash-tab-btn${v===3?' active':''}" onclick="S.navigate('tableau-de-bord',{dashV:3},false)">Vue 3 Heatmap élèves</button>
</div>
${body}`;
}
function dashV1(allStu,pubActs,allStarted,allFinished){
return`<div class="g2 mb22">
${DB.classes.map(c=>{
const stu=c.students;
const started=stu.filter(s=>{const p=DB.progression[s.id];return p&&totalSteps(p)>0;}).length;
const finished=stu.filter(s=>{const p=DB.progression[s.id];return p&&totalSteps(p)>=16;}).length;
const avgProg=stu.length?Math.round(stu.reduce((sum,s)=>{const p=DB.progression[s.id];return sum+(p?totalSteps(p):0);},0)/(stu.length*16)*100):0;
const cActs=DB.activities.filter(a=>a.assignedClasses.includes(c.id)&&a.status==='published');
let done2=0,total2=0;
cActs.forEach(a=>{stu.forEach(s=>{total2++;const r=(DB.results[s.id]||{})[a.id];if(r&&r.status==='done')done2++;});});
return`<div class="card">
<div class="card-hd fbet">
<span class="card-title">${c.name}</span>
<span class="badge b-gray">${c.students.length} élèves</span>
</div>
<div style="padding:18px;">
<div class="g2 mb16">
<div><div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;">Progression app</div>
<div style="font-size:22px;font-weight:800;">${avgProg}%</div>
<div class="prog-bar mt8"><div class="prog-fill" style="width:${avgProg}%;"></div></div>
<div class="xs muted mt8">${started}/${stu.length} commencé · ${finished} terminé</div>
</div>
<div><div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;">Quiz complétés</div>
<div style="font-size:22px;font-weight:800;">${total2>0?Math.round(done2/total2*100)+'%':'—'}</div>
<div class="xs muted mt8">${done2}/${total2} réponses</div>
</div>
</div>
${cActs.length>0?`<div style="border-top:1px solid var(--border);padding-top:12px;">
${cActs.map(a=>{
const aDone=stu.filter(s=>{const r=(DB.results[s.id]||{})[a.id];return r&&r.status==='done';}).length;
const pct=Math.round(aDone/stu.length*100);
return`<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span class="xs" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${a.name}</span>
<div class="prog-bar" style="width:60px;flex-shrink:0;"><div class="prog-fill" style="width:${pct}%;background:var(--ok);"></div></div>
<span class="xs semi" style="min-width:30px;text-align:right;">${pct}%</span>
</div>`;}).join('')}
</div>`:'<p class="xs muted">Aucun module assigné.</p>'}
<div class="flex g8 mt12">
<button class="btn btn-s btn-sm" onclick="S.navigate('une-classe',{classId:'${c.id}',tab:'progression'})">Voir </button>
</div>
</div>
</div>`;}).join('')}
</div>`;
}
function dashV2(pubActs){
if(!pubActs.length)return`<div class="empty"><div class="empty-ico">📋</div><h3>Aucun module publié</h3></div>`;
return pubActs.map(a=>{
const assignedCls=DB.classes.filter(c=>a.assignedClasses.includes(c.id));
const nQ=a.questions.length;
return`<div class="card mb16">
<div class="card-hd fbet">
<div><span class="card-title">${a.name}</span><span class="badge b-info ms">${a.assignedClasses.length} classes</span></div>
<button class="btn btn-s btn-sm" onclick="exportResultsCSV('${a.id}')"> CSV</button>
</div>
<div style="padding:16px;">
${nQ>0?`<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:14px;align-items:flex-end;">
${Array.from({length:nQ},(_,i)=>{
const allStu2=assignedCls.flatMap(c=>c.students);
const done2=allStu2.map(s=>(DB.results[s.id]||{})[a.id]).filter(r=>r&&r.status==='done');
const pct=done2.length?Math.round(done2.filter(r=>r.ans[i]).length/done2.length*100):null;
const h=pct!==null?Math.max(20,Math.round(pct*0.6)):20;
const col=pct===null?'#EDE8E0':pct>=75?'#22A05E':pct>=40?'#3B82F6':'#E05050';
return`<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
<div style="font-size:9px;font-weight:700;color:var(--muted);">${pct!==null?pct+'%':''}</div>
<div style="width:28px;height:${h}px;background:${col};border-radius:4px 4px 0 0;" title="Q${i+1}: ${pct!==null?pct+'%':'—'}"></div>
<div style="font-size:9px;color:var(--muted);">Q${i+1}</div>
</div>`;}).join('')}
</div>`:'<p class="xs muted mb12">Aucune question.</p>'}
<table style="width:100%;border-collapse:collapse;font-size:12px;">
<thead><tr style="border-bottom:1px solid var(--border);">
<th style="padding:6px 10px;text-align:left;font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;">Classe</th>
<th style="padding:6px 10px;text-align:center;font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;">Répondus</th>
<th style="padding:6px 10px;text-align:center;font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;">Score moy.</th>
<th style="padding:6px 10px;text-align:center;font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;">Complétion</th>
</tr></thead>
<tbody>${assignedCls.map(c=>{
const stu=c.students;
const done2=stu.map(s=>(DB.results[s.id]||{})[a.id]).filter(r=>r&&r.status==='done');
const pct=done2.length&&nQ?Math.round(done2.reduce((s,r)=>s+ansScore(r.ans),0)/(done2.length*nQ)*100):null;
const comp=Math.round(done2.length/stu.length*100);
return`<tr style="border-bottom:1px solid var(--border);">
<td style="padding:8px 10px;font-weight:600;">${c.name}</td>
<td style="padding:8px 10px;text-align:center;">${done2.length}/${stu.length}</td>
<td style="padding:8px 10px;text-align:center;">${pct!==null?pct+'%':'—'}</td>
<td style="padding:8px 10px;text-align:center;">
<div style="display:flex;align-items:center;gap:6px;justify-content:center;">
<div class="prog-bar" style="width:50px;"><div class="prog-fill" style="width:${comp}%;background:var(--ok);"></div></div>
<span class="xs semi">${comp}%</span>
</div>
</td>
</tr>`;}).join('')}
</tbody>
</table>
</div>
</div>`;}).join('');
}
function dashV3(){
// Heatmap: all students × 4 chapters, color = steps completed
const chapColors=['#FFF8EC','#FDE9C8','#F5C97A','#E8922A','#C47820'];
return`<div class="card">
<div class="card-hd fbet">
<span class="card-title">Heatmap progression tous les élèves</span>
<div class="flex g8">
<div class="flex g6" style="align-items:center;font-size:11px;color:var(--muted);">
${chapColors.map((col,i)=>`<span style="display:inline-flex;align-items:center;gap:4px;"><span style="width:12px;height:12px;background:${col};border-radius:2px;border:1px solid #E3DDD5;display:inline-block;"></span>${i===0?'0 ét.':i===4?'✓ 4 ét.':''}</span>`).filter((_,i)=>i===0||i===4).join('')}
</div>
</div>
</div>
<div class="heat-wrap" style="padding:16px;">
<table class="heat-table">
<thead><tr>
<th class="ht-code">Code</th><th class="ht-code" style="min-width:60px;">Classe</th>
<th>Chap. 1</th><th>Chap. 2</th><th>Chap. 3</th><th>Chap. 4</th>
<th style="text-align:right;padding-left:10px;">Total</th>
</tr></thead>
<tbody>
${DB.classes.flatMap(c=>c.students.map(s=>{
const p=DB.progression[s.id]||{c1:0,c2:0,c3:0,c4:0};
const tot=totalSteps(p);
return`<tr>
<td class="ht-code">${s.code}</td>
<td class="ht-code" style="font-size:11px;color:var(--muted);font-family:inherit;">${c.name}</td>
${[1,2,3,4].map(i=>{
const v=p['c'+i]||0;
const col=chapColors[v];
const label=v===0?'—':v>=4?'✓':'Ét. '+v;
return`<td><div class="hcell" style="background:${col};color:${v>=4?'#92400E':v>0?'#78350F':'#9A8A78'};border:1px solid #E3DDD5;width:36px;" title="Chap. ${i}: ${label}">${v>=4?'✓':v>0?v:'·'}</div></td>`;}).join('')}
<td class="ht-score">
<span style="font-size:12px;font-weight:700;color:${tot>=16?'var(--ok)':tot>0?'var(--accent)':'var(--muted)'};">${tot}/16</span>
</td>
</tr>`;
})).join('')}
</tbody>
</table>
</div>
</div>`;
}