Compare commits

...

20 Commits

Author SHA1 Message Date
0b818414ed Restaure index.html supprime par erreur, avec les masquages
Le commit precedent avait supprime index.html au lieu de le modifier.
Ce commit le retablit dans son integralite (429 lignes) avec le
display:none sur la documentation et les rapports de veille.
2026-07-21 08:31:08 +02:00
f027dfcf4c Masque provisoirement la documentation et les rapports de veille
Ajoute display:none aux liens de navigation, aux boutons du hero et aux
deux sections #documentation et #rapports. Le contenu reste dans le code
pour un reaffichage ulterieur.
2026-07-21 08:29:55 +02:00
34216a52f9 Fusion de exos-rework dans main
Retravaille le TP Nuisibles : inversion accueil/identification, style
BioCID, navigation regroupee dans la bottom bar, boutons d'action
simplifies et encadres finaux recentres.

# Conflicts:
#	.gitignore
2026-07-21 08:24:27 +02:00
f611bdcd7e Recentre les encadres TP1/TP2/TP3 de la slide finale
Passe la marge de .final-grid de 1.5rem 0 a 1.5rem auto pour centrer la
grille des trois travaux pratiques.
2026-07-21 08:22:29 +02:00
f7c846c85c Simplifie les boutons d'action du TP Nuisibles
Slide 1 : retire le bouton "Corriger un rendu", l'ecran de contexte ne
garde que l'identification. Slide 13 : ne conserve que Telecharger le PDF,
Corriger un rendu et Recommencer, aligne sur le style BioCID (PDF plein
ambre, deux boutons contour) et retire le lien biocid-anses.fr.
2026-07-21 08:20:13 +02:00
a74df51201 Migre la navigation du TP Nuisibles dans la bottom bar
Reprend le modele de footer d'Exo_BioCID : tous les boutons d'avancement
et d'aide passent dans la barre du bas, pilotes par un slideConfig
(bouton suivant, libelle, indice par slide). Supprime les boutons
in-slide correspondants, gere la validation du nom de groupe et le
deverrouillage du bouton apres choix du cas, et applique le style
footer-btn (compteur a gauche, actions centrees).
2026-07-21 08:16:05 +02:00
42650ca351 Aligne le TP Nuisibles sur le style des autres exercices
Inverse l'accueil et l'identification (héro en slide 0, contexte et
identification du groupe en slide 1), applique le style BioCID aux deux
premieres slides et supprime le badge d'avancement en haut a droite,
redondant avec la barre de progression.
2026-07-21 08:10:09 +02:00
21a7359057 Retravaille l'exercice FDS : étiquette libre et corrigé déplacé
- Slide 5 (FT) : reformule la question sur le type de produit en « Où se
  trouve la liste des TP (type de produit) ? »
- Slide 7 : l'utilisateur peut désormais cliquer n'importe quelle zone,
  même incorrecte ; le clic est enregistré et l'exercice avance.
- Slide 7 : « Terminer l'exercice » mène directement à la synthèse ; le
  récapitulatif « l'étiquette annotée » quitte le déroulé des slides.
- Déplace ce récapitulatif (étiquette annotée + check-list réglementaire)
  vers corriger-exercice.html, en corrigé de référence du rendu FDS.
2026-07-21 08:01:12 +02:00
6bb7985512 Uniformise l'identification du groupe sur BioCID et TP Nuisibles
Applique le style de carte de référence (Exo_FDS) à ExoBIOCID et au TP
Nuisibles : titre dans la carte, champs nom/date, et sélecteur de
formateur Nathan/Gauthier en boutons remplis à l'état actif, à la place
du champ texte libre (BioCID) et des boutons pilule (TP).
2026-07-21 07:46:15 +02:00
090cbc19a2 Aligne les deux premières slides du TP Nuisibles sur BioCID
Les slides 1 et 2 reprennent la présentation des slides BioCID :
étiquette de section, titre, introduction et encadré d'information.
La slide 1 abandonne le bloc héro au profit de ce gabarit, la slide 2
gagne un encadré rappelant que le cas choisi vaut pour tout le TP.

La règle .info-card, absente de ce fichier, est reprise de BioCID.
Aucun champ ni contenu déplacé, numérotation des slides inchangée.
2026-07-20 21:12:56 +02:00
794ae69fa0 Ajoute une slide d'introduction à l'exercice 1 de BioCID
L'exercice 1 s'ouvre désormais sur une slide de présentation calquée
sur celles de l'exercice FDS : cercle décoratif, étiquette de section,
titre avec emoji, texte d'introduction et encadré d'information.

Les slides suivantes sont décalées d'un rang (2 à 6 deviennent 3 à 7),
le compteur passe à 7 étapes et les confettis se déclenchent sur la
nouvelle slide finale. Les questions et leurs champs sont inchangés.
2026-07-20 21:12:56 +02:00
88b075fb88 Bloque les fichiers ne correspondant pas à l'exercice choisi
Chaque exercice dispose désormais d'une signature propre, basée sur
l'en-tête du PDF qu'il exporte. La fonction detectExercise identifie
l'exercice réellement contenu dans le fichier déposé (signature d'abord,
sentinelle en secours).

Si le PDF correspond à un autre exercice que celui sélectionné, un
message d'incompatibilité nommant les deux exercices s'affiche et la
correction n'est pas générée.
2026-07-20 20:51:23 +02:00
b5f2eac842 Sauvegarde les réponses de BioCID et du TP Nuisibles
Les réponses saisies sont désormais enregistrées dans le navigateur au fil
de la frappe, sous une clé propre à chaque exercice. Un rechargement ne les
perd plus.

Au chargement, rien n'est restauré d'office : un bandeau signale les
réponses retrouvées avec leur date et propose « Restaurer » ou « Repartir
de zéro ». Sur un poste de formation partagé, un nouveau groupe ne démarre
donc jamais sur le travail du précédent.

Sont restaurés les champs d'identification, les réponses, la position dans
le diaporama et, pour le TP, le tableau de risques (lignes ajoutées
recréées avant remplissage), le cas pratique choisi et le formateur.

L'avertissement avant rechargement est conservé comme second filet. Les
accès à localStorage sont protégés : en navigation privée ou en file://
sous Safari, la page fonctionne normalement, sans sauvegarde.

FDS reste à traiter : ses réponses ne vivent pas dans des champs de
formulaire mais dans l'état des zones de dépôt, des liaisons et de
l'étiquette, ce qui demande une logique de réaffichage dédiée.
2026-07-20 20:44:47 +02:00
a42957ad41 Avertit avant la perte des réponses sur les trois TD
Les réponses saisies dans les exercices ne sont pas enregistrées : un
rechargement ou une navigation les efface sans prévenir.

Ajoute sur BioCID, FDS et TP Nuisibles un indicateur de saisie et un
gestionnaire beforeunload : dès qu'une réponse a été renseignée, le
navigateur demande confirmation avant de recharger ou de quitter la page.
Aucune alerte tant que rien n'a été saisi.

Sur FDS, les parties glisser-déposer, liaisons FT et zones cliquables de
l'étiquette ne produisent pas d'événement input : elles signalent la
saisie explicitement. Même chose pour le choix du cas pratique du TP.

Note : le texte affiché dans la boîte de dialogue est imposé par le
navigateur et n'est pas personnalisable.
2026-07-20 20:36:19 +02:00
421ae4c2bf Unifie la correction des exercices en une page unique
La page de correction ne réagissait pas au dépôt d'un PDF : elle encodait
le fichier en base64 dans localStorage puis redirigeait vers une page
dédiée censée le relire. Ce relais échoue en file://, qui est le mode
d'ouverture habituel des pages.

corriger-exercice.html devient autonome : sélection de l'exercice, dépôt
du PDF et affichage des réponses se font sur place, sans localStorage ni
redirection. Les trois parseurs (BioCID, FDS, TP Nuisibles) sont regroupés
derrière un format commun avec carrousel exercice par exercice, bandeau
participants, correction chiffrée pour la partie FDS et annexe TP3.

Ajoute un garde-fou signalant un PDF ne correspondant pas à l'exercice
sélectionné, repointe les boutons « Corriger » de Exo_FDS et TP_Nuisibles
vers la page unifiée, et supprime les trois pages de correction dédiées
devenues sans référence.
2026-07-20 20:30:16 +02:00
affa042c05 Aligne les slides d'intro et la bottom bar sur le modèle BioCID
- FDS : bottom bar refaite façon BioCID (84 px, compteur à gauche, boutons pilule centrés en style footer-btn, bouton retour intégré, masquée sur l'accueil)
- FDS : bouton "Démarrer le TD" remis dans la slide d'accueil et 2e cercle déco passé en ambre
- TP_Nuisibles : formulaire d'identification encadré dans une carte blanche comme la carte identité BioCID
2026-07-19 18:17:56 +02:00
30817c829b Retravaille l'exercice FDS : boutons, étiquette et récapitulatif
- index : emoji de la carte "Corriger un exercice" ✔️
- Boutons de toutes les slides centralisés et uniformisés dans la bottom bar
- Slide 1 : encadré "Durée 30 min" remonté au-dessus des trois parties
- Slides 3 et 5 : suppression du bouton "Vérifier mes réponses" et blocage du passage à la partie suivante tant que tout n'est pas rempli/relié
- Slide 7 : bouton "Terminer l'exercice" (ex "Voir le récapitulatif") réparé, faux pictogrammes remplacés par les vraies images SGH, volet de correction de droite et encadré de résultats supprimés
- Slide 8 : encadrés 💡 retirés, boutons déplacés sous le tableau comparatif, bouton "Retour aux exercices" supprimé
- Confettis déplacés de la slide 6 vers la slide 8
2026-07-19 17:59:02 +02:00
8164b03cc4 Refonte du rapport de veille Certibiocide semaine 28
Enrichit et restructure le rapport HTML de la semaine 28 (06-12 juillet 2026).
2026-07-17 17:03:57 +02:00
317f98b711 Merge branch 'memory-results' : classement Memory en ligne 2026-07-15 22:35:33 +02:00
2a85ced1e6 Passe le classement Memory en ligne (ASP.NET + SQL Server)
Le classement du jeu Memory était stocké en localStorage (visible d'un seul
joueur, sur un seul poste). Il est désormais partagé en ligne via une API
ASP.NET/SQL Server adaptée à l'hébergement bsite.net.

- API scores.ashx (C#, compilée au vol par IIS) : lecture GET et
  enregistrement POST, création automatique de la table, ne conserve que le
  top 10 de chaque mode (facile / normal / difficile).
- leaderboard.js : le classement passe par fetch vers l'API (états
  chargement/erreur/vide, surbrillance de la ligne du joueur). Les
  statistiques personnelles restent stockées localement.
- memory-game.js : rafraîchit le classement après l'enregistrement (async).
- Style .is-me pour la ligne du joueur courant.
- web.config.example fourni ; le web.config réel (mot de passe) est ignoré.
2026-07-15 22:35:24 +02:00
20 changed files with 3050 additions and 2965 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.DS_Store .DS_Store
# Contient le mot de passe SQL Server — à éditer uniquement sur bsite.net # Contient le mot de passe de la base SQL Server — à créer/éditer uniquement sur
# le serveur bsite.net à partir de web.config.example (ne jamais versionner)
exercices/memory-pictogrammes/api/web.config exercices/memory-pictogrammes/api/web.config

BIN
data/ghs05-corrosif.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
data/ghs07-nocif.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -406,6 +406,15 @@
box-sizing: border-box; box-sizing: border-box;
} }
.identity-fields input:focus { outline: none; border-color: var(--amber); background: white; } .identity-fields input:focus { outline: none; border-color: var(--amber); background: white; }
.formateur-label { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted); margin: 4px 0 2px; }
.formateur-row { display: flex; gap: 8px; }
.formateur-btn {
flex: 1; font-family: 'DM Sans', sans-serif; font-size: 13.5px;
padding: 9px 13px; border: 1px solid var(--border); border-radius: 10px;
background: var(--bg); color: var(--text); cursor: pointer; transition: all 0.2s ease;
}
.formateur-btn:hover { border-color: var(--amber); }
.formateur-btn.active { background: var(--amber); border-color: var(--amber); color: #fff; }
/* DECO */ /* DECO */
.deco-circle { position: fixed; border-radius: 50%; pointer-events: none; z-index: 0; } .deco-circle { position: fixed; border-radius: 50%; pointer-events: none; z-index: 0; }
@@ -492,18 +501,33 @@
<div class="identity-fields"> <div class="identity-fields">
<input type="text" id="idGroupe" placeholder="Nom du groupe / stagiaires (ex : Groupe 3 — Dupont / Martin)" autocomplete="off" oninput="clearGroupeWarning()"> <input type="text" id="idGroupe" placeholder="Nom du groupe / stagiaires (ex : Groupe 3 — Dupont / Martin)" autocomplete="off" oninput="clearGroupeWarning()">
<input type="date" id="idDate"> <input type="date" id="idDate">
<input type="text" id="idFormateur" placeholder="Formateur (facultatif)" autocomplete="off"> <input type="hidden" id="idFormateur">
<div class="formateur-label">Formateur de la session</div>
<div class="formateur-row">
<button type="button" class="formateur-btn" data-formateur="Nathan" onclick="selectFormateur(this)">Nathan</button>
<button type="button" class="formateur-btn" data-formateur="Gauthier" onclick="selectFormateur(this)">Gauthier</button>
</div>
</div> </div>
<div id="idWarning" style="display:none;margin-top:8px;font-size:12.5px;color:var(--coral);">⚠ Renseignez le nom du groupe pour continuer.</div> <div id="idWarning" style="display:none;margin-top:8px;font-size:12.5px;color:var(--coral);">⚠ Renseignez le nom du groupe pour continuer.</div>
</div> </div>
</div> </div>
<!-- ============ SLIDE 2 — EXERCICE 1 ============ --> <!-- ============ SLIDE 2 — EXERCICE 1 : INTRO ============ -->
<div class="slide" id="slide-2"> <div class="slide" id="slide-2">
<div class="deco-circle" style="width:280px;height:280px;background:var(--amber-light);top:-40px;right:-40px;opacity:0.5;"></div>
<div class="section-tag">Exercice 1 / 4 — 5 min</div>
<div class="slide-title">🔎 Recherche par nom de produit</div>
<p class="slide-intro">La recherche par nom est le chemin le plus direct dans BioCID : elle donne accès à la <strong>fiche complète</strong> d'un produit déjà identifié — son <strong>type</strong>, sa <strong>substance active</strong>, son <strong>fabricant</strong> et ses <strong>conditions d'utilisation</strong>.</p>
<div class="info-card">
<span>🎯</span>
<div>Pour chaque question, saisissez votre réponse dans le champ prévu. Vos réponses sont <strong>enregistrées automatiquement</strong> et reprises dans le PDF final.</div>
</div>
</div>
<!-- ============ SLIDE 3 — EXERCICE 1 : QUESTIONS ============ -->
<div class="slide" id="slide-3">
<div class="scroll-area"> <div class="scroll-area">
<div class="section-tag">Exercice 1 / 4 — 5 min</div>
<div class="slide-title">Recherche par nom de produit</div> <div class="slide-title">Recherche par nom de produit</div>
<p class="slide-intro">Trouvez un produit spécifique et relevez ses caractéristiques principales.</p>
<ul class="questions-list"> <ul class="questions-list">
<li class="q-item"> <li class="q-item">
<div class="q-head"><span class="q-num">1</span><span>Recherchez un produit connu (ex. « Goliath », « Phobi », « Soricide »). Notez son <strong>type de produit</strong>, sa <strong>substance active</strong> et son <strong>fabricant</strong>.</span></div> <div class="q-head"><span class="q-num">1</span><span>Recherchez un produit connu (ex. « Goliath », « Phobi », « Soricide »). Notez son <strong>type de produit</strong>, sa <strong>substance active</strong> et son <strong>fabricant</strong>.</span></div>
@@ -521,8 +545,8 @@
</div> </div>
</div> </div>
<!-- ============ SLIDE 3 — EXERCICE 2 ============ --> <!-- ============ SLIDE 4 — EXERCICE 2 ============ -->
<div class="slide" id="slide-3"> <div class="slide" id="slide-4">
<div class="scroll-area"> <div class="scroll-area">
<div class="section-tag">Exercice 2 / 4 — 5 min</div> <div class="section-tag">Exercice 2 / 4 — 5 min</div>
<div class="slide-title">Recherche par type de produit</div> <div class="slide-title">Recherche par type de produit</div>
@@ -540,8 +564,8 @@
</div> </div>
</div> </div>
<!-- ============ SLIDE 4 — EXERCICE 3 ============ --> <!-- ============ SLIDE 5 — EXERCICE 3 ============ -->
<div class="slide" id="slide-4"> <div class="slide" id="slide-5">
<div class="scroll-area"> <div class="scroll-area">
<div class="section-tag">Exercice 3 / 4 — 5 min</div> <div class="section-tag">Exercice 3 / 4 — 5 min</div>
<div class="slide-title">Recherche par substance active</div> <div class="slide-title">Recherche par substance active</div>
@@ -559,8 +583,8 @@
</div> </div>
</div> </div>
<!-- ============ SLIDE 5 — EXERCICE 4 (CAS DE SYNTHÈSE) ============ --> <!-- ============ SLIDE 6 — EXERCICE 4 (CAS DE SYNTHÈSE) ============ -->
<div class="slide" id="slide-5"> <div class="slide" id="slide-6">
<div class="scroll-area"> <div class="scroll-area">
<div class="section-tag" style="background:var(--coral-light);color:var(--coral);">Exercice 4 / 4 — Cas de synthèse</div> <div class="section-tag" style="background:var(--coral-light);color:var(--coral);">Exercice 4 / 4 — Cas de synthèse</div>
<div class="slide-title">Mise en situation</div> <div class="slide-title">Mise en situation</div>
@@ -585,8 +609,8 @@
</div> </div>
</div> </div>
<!-- ============ SLIDE 6 — ASTUCES / FIN ============ --> <!-- ============ SLIDE 7 — ASTUCES / FIN ============ -->
<div class="slide" id="slide-6"> <div class="slide" id="slide-7">
<div class="scroll-area"> <div class="scroll-area">
<div class="deco-circle" style="width:320px;height:320px;background:var(--amber-light);bottom:-80px;right:-60px;opacity:0.5;"></div> <div class="deco-circle" style="width:320px;height:320px;background:var(--amber-light);bottom:-80px;right:-60px;opacity:0.5;"></div>
<div style="text-align:center;"> <div style="text-align:center;">
@@ -613,15 +637,16 @@
<script> <script>
let currentSlide = 0; let currentSlide = 0;
let history = []; let history = [];
const totalSlides = 6; const totalSlides = 7;
// Configuration des boutons du footer par slide // Configuration des boutons du footer par slide
const slideConfig = { const slideConfig = {
1: { next: 2, nextLabel: "Commencer l'exercice 1 →" }, 1: { next: 2, nextLabel: "Commencer l'exercice 1 →" },
2: { hint: 'ex1', next: 3, nextLabel: 'Exercice suivant →' }, 2: { next: 3, nextLabel: 'Aux questions →' },
3: { hint: 'ex2', next: 4, nextLabel: 'Exercice suivant →' }, 3: { hint: 'ex1', next: 4, nextLabel: 'Exercice suivant →' },
4: { hint: 'ex3', next: 5, nextLabel: 'Cas de synthèse →' }, 4: { hint: 'ex2', next: 5, nextLabel: 'Exercice suivant →' },
5: { next: 6, nextLabel: "Terminer l'exercice →" } 5: { hint: 'ex3', next: 6, nextLabel: 'Cas de synthèse →' },
6: { next: 7, nextLabel: "Terminer l'exercice →" }
}; };
const hints = { const hints = {
@@ -655,7 +680,7 @@ function goTo(n) {
const sa = next.querySelector('.scroll-area'); const sa = next.querySelector('.scroll-area');
if (sa) sa.scrollTop = 0; if (sa) sa.scrollTop = 0;
updateUI(); updateUI();
if (n === 6) setTimeout(launchConfetti, 200); if (n === 7) setTimeout(launchConfetti, 200);
} }
function goBack() { function goBack() {
@@ -678,6 +703,12 @@ function clearGroupeWarning() {
if (w) w.style.display = 'none'; if (w) w.style.display = 'none';
} }
function selectFormateur(btn) {
document.querySelectorAll('.formateur-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('idFormateur').value = btn.dataset.formateur;
}
function goNext() { function goNext() {
const cfg = slideConfig[currentSlide]; const cfg = slideConfig[currentSlide];
if (!cfg || cfg.next == null) return; if (!cfg || cfg.next == null) return;
@@ -859,6 +890,116 @@ function exporterPDF() {
setTimeout(() => w.print(), 300); setTimeout(() => w.print(), 300);
} }
/* ======================== SAUVEGARDE AUTOMATIQUE ======================== */
/* Les réponses sont enregistrées dans le navigateur au fil de la saisie, puis
proposées à la restauration au rechargement. En file://, localStorage peut être
indisponible (Safari) : dans ce cas la page fonctionne normalement, sans filet. */
const CLE_SAUVEGARDE = 'certibiocide:biocid:v1';
let reponsesModifiees = false;
function marquerModifie() { reponsesModifiees = true; sauvegarder(); }
document.addEventListener('input', marquerModifie, true);
document.addEventListener('change', marquerModifie, true);
// Avertit avant tout rechargement ou départ. Le texte de la boîte est imposé
// par le navigateur, il n'est pas personnalisable.
window.addEventListener('beforeunload', e => {
if (!reponsesModifiees) return;
e.preventDefault();
e.returnValue = '';
});
function etatCourant() {
const champs = {};
document.querySelectorAll('input[id], textarea[id]').forEach(el => {
if (el.value) champs[el.id] = el.value;
});
return { savedAt: new Date().toISOString(), slide: currentSlide, champs };
}
let minuteurSauvegarde = null;
function sauvegarder() {
clearTimeout(minuteurSauvegarde);
minuteurSauvegarde = setTimeout(() => {
try {
localStorage.setItem(CLE_SAUVEGARDE, JSON.stringify(etatCourant()));
} catch (e) { /* navigation privée ou quota : on continue sans sauvegarde */ }
}, 400);
}
function lireSauvegarde() {
try { return JSON.parse(localStorage.getItem(CLE_SAUVEGARDE)); }
catch (e) { return null; }
}
function effacerSauvegarde() {
try { localStorage.removeItem(CLE_SAUVEGARDE); } catch (e) {}
}
function appliquerSauvegarde(data) {
Object.entries(data.champs || {}).forEach(([id, v]) => {
const el = document.getElementById(id);
if (el) el.value = v;
});
reponsesModifiees = true;
if (typeof data.slide === 'number' && data.slide > 0) goTo(data.slide);
updateUI();
}
/* Bandeau de restauration : on ne restaure jamais sans demander, pour qu'un
nouveau groupe n'hérite pas des réponses du précédent sur un poste partagé. */
function injecterStylesBandeau() {
const style = document.createElement('style');
style.textContent = `
.restore-bar {
position: fixed; top: 84px; left: 50%; transform: translateX(-50%);
z-index: 900; max-width: min(680px, calc(100vw - 32px));
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
background: var(--white); border: 1.5px solid var(--amber);
border-radius: 14px; padding: 14px 18px;
box-shadow: 0 8px 28px rgba(0,0,0,0.12); font-size: 13.5px;
}
.restore-bar .restore-text { flex: 1; min-width: 220px; line-height: 1.5; }
.restore-bar .restore-actions { display: flex; gap: 8px; flex-shrink: 0; }
.restore-bar button {
font-family: 'Syne', sans-serif; font-weight: 700; font-size: 12.5px;
padding: 8px 16px; border-radius: 20px; cursor: pointer;
border: 1px solid var(--border); background: transparent; color: var(--text-muted);
}
.restore-bar button:hover { border-color: var(--amber); color: var(--amber); }
.restore-bar button.primary {
background: var(--amber); border-color: var(--amber); color: #fff;
}
.restore-bar button.primary:hover { background: var(--amber-dark); border-color: var(--amber-dark); color: #fff; }
`;
document.head.appendChild(style);
}
function proposerRestauration() {
const data = lireSauvegarde();
if (!data || !data.champs || Object.keys(data.champs).length === 0) return;
const quand = new Date(data.savedAt);
const libelle = isNaN(quand.getTime()) ? '' :
` du ${quand.toLocaleDateString('fr-FR')} à ${quand.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
injecterStylesBandeau();
const bar = document.createElement('div');
bar.className = 'restore-bar';
bar.innerHTML = `
<span class="restore-text">💾 Des réponses enregistrées${libelle} ont été retrouvées sur ce poste.</span>
<span class="restore-actions">
<button type="button" class="primary" id="btnRestaurer">Restaurer</button>
<button type="button" id="btnRepartir">Repartir de zéro</button>
</span>`;
document.body.appendChild(bar);
document.getElementById('btnRestaurer').onclick = () => { appliquerSauvegarde(data); bar.remove(); };
document.getElementById('btnRepartir').onclick = () => { effacerSauvegarde(); bar.remove(); };
}
proposerRestauration();
updateUI(); updateUI();
</script> </script>
</body> </body>

View File

@@ -17,7 +17,7 @@
justify-content: center; justify-content: center;
padding: 2rem; padding: 2rem;
padding-top: calc(70px + 2rem); padding-top: calc(70px + 2rem);
padding-bottom: calc(52px + 2rem); padding-bottom: calc(84px + 2rem);
opacity: 0; opacity: 0;
transition: opacity 0.4s ease; transition: opacity 0.4s ease;
overflow-y: auto; overflow-y: auto;
@@ -46,33 +46,63 @@
z-index: 100; z-index: 100;
} }
/* BOTTOM BAR */ /* BOTTOM BAR (modèle BioCID) */
.bottom-bar { .bottom-bar {
position: fixed; position: fixed;
bottom: 0; left: 0; right: 0; bottom: 0; left: 0; right: 0;
height: 52px; height: 84px;
background: rgba(250, 238, 218, 0.98); background: rgba(250, 238, 218, 0.98);
border-top: 2px solid var(--amber); border-top: 2px solid var(--amber);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
padding: 0 20px; padding: 0 20px;
z-index: 100; z-index: 100;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
.slide-counter { font-size: 12px; font-family: 'Syne', sans-serif; font-weight: 600; color: var(--amber-dark); } .slide-counter {
.back-btn { position: absolute;
background: none; left: 20px;
border: 0.5px solid var(--amber);
border-radius: 20px;
padding: 6px 16px;
font-size: 12px; font-size: 12px;
font-family: 'Syne', sans-serif;
font-weight: 600;
color: var(--amber-dark); color: var(--amber-dark);
cursor: pointer;
transition: all 0.2s;
font-family: 'DM Sans', sans-serif;
} }
.back-btn:hover { background: rgba(186,117,23,0.08); } /* Boutons centralisés dans la bottom bar */
.bar-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.footer-btn {
font-family: 'Syne', sans-serif;
font-size: 13px;
font-weight: 600;
padding: 11px 24px;
border-radius: 40px;
border: 1.5px solid var(--amber);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1;
transition: all 0.2s;
text-decoration: none;
white-space: nowrap;
}
.footer-btn:hover { transform: translateY(-1px); }
.footer-btn:active { transform: scale(0.98); }
.footer-btn.is-primary { background: var(--amber); color: #fff; }
.footer-btn.is-primary:hover { background: var(--amber-dark); }
.footer-btn.is-secondary { background: transparent; color: var(--amber-dark); }
.footer-btn.is-secondary:hover { background: rgba(186,117,23,0.08); }
.footer-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; pointer-events: none; }
@media (max-width: 560px) {
.slide-counter { display: none; }
.footer-btn { padding: 9px 16px; font-size: 12px; }
}
/* BUTTONS */ /* BUTTONS */
.btn { .btn {
@@ -210,7 +240,7 @@
#slide-3 { #slide-3 {
justify-content: flex-start; justify-content: flex-start;
padding-top: calc(70px + 0.9rem); padding-top: calc(70px + 0.9rem);
padding-bottom: calc(52px + 0.6rem); padding-bottom: calc(84px + 0.6rem);
} }
#slide-3 .slide-title { margin: 0.1rem 0 0.15rem; font-size: clamp(19px, 2.4vw, 26px); } #slide-3 .slide-title { margin: 0.1rem 0 0.15rem; font-size: clamp(19px, 2.4vw, 26px); }
.dnd-hint { font-size: 12.5px; color: var(--text-muted); text-align: center; margin: 0 0 10px; } .dnd-hint { font-size: 12.5px; color: var(--text-muted); text-align: center; margin: 0 0 10px; }
@@ -568,7 +598,8 @@ select:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px
background: repeating-linear-gradient(90deg, #1a1a18 0 2px, #fff 2px 4px, #1a1a18 4px 5px, #fff 5px 9px); background: repeating-linear-gradient(90deg, #1a1a18 0 2px, #fff 2px 4px, #1a1a18 4px 5px, #fff 5px 9px);
} }
.lbl-picto-wrap { display: flex; align-items: center; gap: 8px; } .lbl-picto-wrap { display: flex; align-items: center; gap: 8px; }
.pictos { display: flex; gap: 6px; } .pictos { display: flex; gap: 6px; align-items: center; }
.ghs-img { width: 42px; height: 42px; object-fit: contain; display: block; }
.ghs { .ghs {
width: 38px; height: 38px; position: relative; width: 38px; height: 38px; position: relative;
border: 2.5px solid #c62828; background: #fff; border: 2.5px solid #c62828; background: #fff;
@@ -616,21 +647,6 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
.eti-pop .pop-reg { color: var(--text-muted); } .eti-pop .pop-reg { color: var(--text-muted); }
.eti-pop .pop-close { position: absolute; top: 5px; right: 8px; cursor: pointer; color: var(--text-muted); font-size: 16px; line-height: 1; border: none; background: none; padding: 2px; } .eti-pop .pop-close { position: absolute; top: 5px; right: 8px; cursor: pointer; color: var(--text-muted); font-size: 16px; line-height: 1; border: none; background: none; padding: 2px; }
/* Check-list de synthèse (modèle FHER p.15) */
.checklist-table {
width: 100%; max-width: 780px; border-collapse: collapse;
background: #fff; border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
font-size: 12.5px; margin: 0.25rem 0 1rem;
}
.checklist-table th { background: var(--teal-light); color: var(--teal-dark); font-family: 'Syne', sans-serif; font-weight: 700; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.03em; padding: 10px 12px; text-align: left; border-bottom: 2px solid var(--teal-mid); }
.checklist-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); color: var(--text-muted); vertical-align: top; }
.checklist-table tr:last-child td { border-bottom: none; }
.checklist-table td.cl-num { text-align: center; }
.cl-badge { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; background: #2e7d32; color: #fff; font-family: 'Syne', sans-serif; font-weight: 800; font-size: 10px; }
.checklist-table strong { color: var(--text); }
.cl-check { color: #2e7d32; font-weight: 700; }
.cl-cross { color: #c62828; font-weight: 700; }
@media (max-width: 700px) { @media (max-width: 700px) {
.lbl-cols { grid-template-columns: 1fr; } .lbl-cols { grid-template-columns: 1fr; }
.lbl-title { font-size: 26px; } .lbl-title { font-size: 26px; }
@@ -651,44 +667,11 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
/* ---- SLIDE 7 — encadré de réponse sur le côté de l'étiquette ---- */ /* ---- SLIDE 7 — encadré de réponse sur le côté de l'étiquette ---- */
#eti-stage { #eti-stage {
max-width: 980px; max-width: 680px;
display: grid; display: flex;
grid-template-columns: minmax(0, 1fr) 300px;
align-items: start;
gap: 16px;
justify-content: center; justify-content: center;
} }
#eti-stage .label-card { grid-column: 1; grid-row: 1; }
#eti-stage .eti-pop,
#eti-stage .eti-side-hint { grid-column: 2; grid-row: 1; align-self: start; }
#eti-stage .eti-pop { position: relative; inset: auto; left: auto; top: auto; max-width: none; width: 100%; }
.eti-side-hint {
font-size: 12.5px; color: var(--teal-dark); line-height: 1.5;
background: var(--teal-light); border: 1px dashed var(--teal-mid);
border-radius: 10px; padding: 12px 14px;
}
.eti-side-hint[hidden] { display: none; }
/* ---- SLIDE 7 — récapitulatif : étiquette à gauche, tableau à droite ---- */
.recap-layout { display: flex; gap: 20px; align-items: flex-start; justify-content: center; width: 100%; max-width: 1120px; }
.recap-layout #recap-stage { flex: 1 1 0; min-width: 0; max-width: 560px; }
.recap-layout .checklist-table { flex: 1 1 0; min-width: 0; margin: 0; }
.recap-layout.animate #recap-stage { animation: recapSlideLeft 0.5s ease both; }
.recap-layout.animate .checklist-table { animation: recapSlideRight 0.5s ease both; }
@keyframes recapSlideLeft { from { opacity: 0; transform: translateX(-40px); } to { opacity: 1; transform: none; } }
@keyframes recapSlideRight { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: none; } }
@media (max-width: 820px) {
#eti-stage { grid-template-columns: 1fr; max-width: 680px; }
#eti-stage .eti-pop,
#eti-stage .eti-side-hint { grid-column: 1; grid-row: auto; }
#eti-stage .label-card { grid-row: auto; }
}
@media (max-width: 1000px) {
.recap-layout { flex-direction: column; align-items: center; }
.recap-layout #recap-stage { flex: none; max-width: 560px; width: 100%; }
.recap-layout .checklist-table { flex: none; width: 100%; max-width: 640px; }
}
</style> </style>
</head> </head>
<body> <body>
@@ -709,19 +692,19 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
<!-- Progress bar --> <!-- Progress bar -->
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div> <div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
<!-- Phase badge -->
<div class="phase-badge" id="phaseBadge" style="display:none"></div>
<!-- Bottom bar --> <!-- Bottom bar -->
<div class="bottom-bar" id="bottomBar" style="display:none;"> <div class="bottom-bar" id="bottomBar" style="display:none;">
<button class="back-btn" id="backBtn" onclick="goBack()" style="display:none;">← Retour</button>
<div class="slide-counter" id="slideCounter"></div> <div class="slide-counter" id="slideCounter"></div>
<div class="bar-actions" id="barActions"></div>
</div> </div>
<!-- Confetti -->
<canvas id="confettiCanvas" style="position:fixed;inset:0;pointer-events:none;z-index:150;display:none;"></canvas>
<!-- ============ SLIDE 0 — ACCUEIL ============ --> <!-- ============ SLIDE 0 — ACCUEIL ============ -->
<div class="slide active fade-in" id="slide-0"> <div class="slide active fade-in" id="slide-0">
<div class="deco-circle" style="width:400px;height:400px;background:var(--amber-light);top:-100px;right:-80px;opacity:0.6;"></div> <div class="deco-circle" style="width:400px;height:400px;background:var(--amber-light);top:-100px;right:-80px;opacity:0.6;"></div>
<div class="deco-circle" style="width:200px;height:200px;background:var(--coral-light);bottom:60px;left:-60px;opacity:0.5;"></div> <div class="deco-circle" style="width:200px;height:200px;background:var(--amber-light);bottom:60px;left:-60px;opacity:0.5;"></div>
<div style="text-align:center;"> <div style="text-align:center;">
<div class="hero-label">Certibiocide — Travaux dirigés</div> <div class="hero-label">Certibiocide — Travaux dirigés</div>
<div class="hero-title">FDS, FT<br>& Étiquettes</div> <div class="hero-title">FDS, FT<br>& Étiquettes</div>
@@ -736,6 +719,10 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
<div class="section-tag">Déroulé du TD</div> <div class="section-tag">Déroulé du TD</div>
<div class="slide-title">Trois documents, trois usages</div> <div class="slide-title">Trois documents, trois usages</div>
<p class="slide-intro">Vous allez apprendre à localiser une information dans chacun des documents de référence d'un produit biocide.</p> <p class="slide-intro">Vous allez apprendre à localiser une information dans chacun des documents de référence d'un produit biocide.</p>
<div class="info-card">
<span>⏱️</span>
<div>Durée ≈ 30 min. Pour chaque question, sélectionnez la <strong>zone du document</strong> où trouver l'information, puis vérifiez votre score.</div>
</div>
<div class="overview-grid"> <div class="overview-grid">
<div class="overview-card"> <div class="overview-card">
<div class="overview-icon">📄</div> <div class="overview-icon">📄</div>
@@ -753,10 +740,6 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
<div class="overview-sub">Interface directe avec l'utilisateur</div> <div class="overview-sub">Interface directe avec l'utilisateur</div>
</div> </div>
</div> </div>
<div class="info-card">
<span>⏱️</span>
<div>Durée ≈ 30 min. Pour chaque question, sélectionnez la <strong>zone du document</strong> où trouver l'information, puis vérifiez votre score.</div>
</div>
<div class="identity-card"> <div class="identity-card">
<div class="identity-title">👥 Identification du groupe</div> <div class="identity-title">👥 Identification du groupe</div>
<div class="identity-fields"> <div class="identity-fields">
@@ -771,7 +754,6 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
</div> </div>
<div class="identity-error" id="idGroupe-error" hidden>⚠️ Renseignez le nom du stagiaire ou du groupe pour continuer.</div> <div class="identity-error" id="idGroupe-error" hidden>⚠️ Renseignez le nom du stagiaire ou du groupe pour continuer.</div>
</div> </div>
<button class="btn btn-amber" onclick="startPart1()" style="margin-top:1.5rem;">Commencer la partie 1 →</button>
</div> </div>
<!-- ============ SLIDE 2 — FDS INTRO ============ --> <!-- ============ SLIDE 2 — FDS INTRO ============ -->
@@ -784,7 +766,6 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
<span>🎯</span> <span>🎯</span>
<div>Pour chaque question, indiquez dans quelle <strong>rubrique (1 à 16)</strong> trouver la réponse — ou « Absent » si l'information n'y figure pas. Les questions sont mélangées à chaque chargement.</div> <div>Pour chaque question, indiquez dans quelle <strong>rubrique (1 à 16)</strong> trouver la réponse — ou « Absent » si l'information n'y figure pas. Les questions sont mélangées à chaque chargement.</div>
</div> </div>
<button class="btn btn-amber" onclick="goTo(3)" style="margin-top:1.5rem;">Répondre aux questions →</button>
</div> </div>
<!-- ============ SLIDE 3 — FDS QUIZ (DRAG & DROP) ============ --> <!-- ============ SLIDE 3 — FDS QUIZ (DRAG & DROP) ============ -->
@@ -796,12 +777,7 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
<div class="quiz-list quiz-grid" id="fds-questions"></div> <div class="quiz-list quiz-grid" id="fds-questions"></div>
<div class="dnd-bank" id="fds-bank-right"></div> <div class="dnd-bank" id="fds-bank-right"></div>
</div> </div>
<button class="verify-btn btn-amber" id="fds-verify" onclick="verifyFDS()" disabled>✓ Vérifier mes réponses</button>
<div id="fds-result" class="result-message"></div> <div id="fds-result" class="result-message"></div>
<div class="actions-row">
<button class="btn btn-outline" onclick="resetFDS()">↺ Recommencer</button>
<button class="btn btn-coral" onclick="goTo(4)">Passer à la partie 2 →</button>
</div>
</div> </div>
<!-- ============ SLIDE 4 — FT INTRO ============ --> <!-- ============ SLIDE 4 — FT INTRO ============ -->
@@ -814,7 +790,6 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
<span>🎯</span> <span>🎯</span>
<div>Indiquez la <strong>section</strong> de la FT où trouver chaque information : Description, Propriétés biocides, Conditionnements, Utilisations — ou « Absent ».</div> <div>Indiquez la <strong>section</strong> de la FT où trouver chaque information : Description, Propriétés biocides, Conditionnements, Utilisations — ou « Absent ».</div>
</div> </div>
<button class="btn btn-coral" onclick="goTo(5)" style="margin-top:1.5rem;">Répondre aux questions →</button>
</div> </div>
<!-- ============ SLIDE 5 — FT : RELIER ============ --> <!-- ============ SLIDE 5 — FT : RELIER ============ -->
@@ -827,12 +802,7 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
<div class="match-col match-left" id="ft-left"></div> <div class="match-col match-left" id="ft-left"></div>
<div class="match-col match-right" id="ft-right"></div> <div class="match-col match-right" id="ft-right"></div>
</div> </div>
<button class="verify-btn btn-coral" id="ft-verify" onclick="verifyFT()" disabled>✓ Vérifier mes réponses</button>
<div id="ft-result" class="result-message"></div> <div id="ft-result" class="result-message"></div>
<div class="actions-row">
<button class="btn btn-outline" onclick="resetFT()">↺ Recommencer</button>
<button class="btn btn-teal" onclick="goTo(6)">Passer à la partie 3 →</button>
</div>
</div> </div>
</div> </div>
@@ -844,9 +814,8 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
<p class="slide-intro">L'étiquette est l'interface directe entre le produit et l'utilisateur. Elle communique rapidement les <strong>dangers essentiels</strong>, les <strong>EPI</strong> et les <strong>consignes d'utilisation</strong>.</p> <p class="slide-intro">L'étiquette est l'interface directe entre le produit et l'utilisateur. Elle communique rapidement les <strong>dangers essentiels</strong>, les <strong>EPI</strong> et les <strong>consignes d'utilisation</strong>.</p>
<div class="info-card" style="background:var(--teal-light);color:var(--teal-dark);"> <div class="info-card" style="background:var(--teal-light);color:var(--teal-dark);">
<span>🎯</span> <span>🎯</span>
<div>Sur une <strong>étiquette réelle</strong> (produit professionnel DÉS'INFECT), <strong>cliquez la zone</strong> qui répond à chaque question : nom commercial, pictogrammes, mention d'avertissement, phrases H, phrases P (EPI, premiers secours, élimination), contenu net et centre antipoison. Chaque clic ouvre la <strong>justification réglementaire</strong> de la zone.</div> <div>Sur une <strong>étiquette réelle</strong> (produit professionnel DÉS'INFECT), <strong>cliquez la zone</strong> qui répond à chaque question : nom commercial, pictogrammes, mention d'avertissement, phrases H, phrases P (EPI, premiers secours, élimination), contenu net et centre antipoison. La <strong>justification réglementaire</strong> de chaque zone est fournie sur la page de correction de l'exercice.</div>
</div> </div>
<button class="btn btn-teal" onclick="goTo(7)" style="margin-top:1.5rem;">Répondre aux questions →</button>
</div> </div>
<!-- ============ SLIDE 7 — ÉTIQUETTE CLIQUABLE (HOTSPOTS) ============ --> <!-- ============ SLIDE 7 — ÉTIQUETTE CLIQUABLE (HOTSPOTS) ============ -->
@@ -911,9 +880,9 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
<div class="lbl-picto-wrap"> <div class="lbl-picto-wrap">
<span class="hotspot" data-zone="z-picto"> <span class="hotspot" data-zone="z-picto">
<span class="pictos"> <span class="pictos">
<span class="ghs"><span>🐟</span></span> <img class="ghs-img" src="../data/ghs09-environnement.jpg" alt="Pictogramme SGH09 — danger pour le milieu aquatique">
<span class="ghs"><span>🧪</span></span> <img class="ghs-img" src="../data/ghs05-corrosif.jpg" alt="Pictogramme SGH05 — corrosif">
<span class="ghs excl"><span>!</span></span> <img class="ghs-img" src="../data/ghs07-nocif.jpg" alt="Pictogramme SGH07 — nocif / irritant">
</span> </span>
</span> </span>
<span class="hotspot" data-zone="z-mention"><span class="lbl-danger">DANGER</span></span> <span class="hotspot" data-zone="z-mention"><span class="lbl-danger">DANGER</span></span>
@@ -923,38 +892,9 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
<div class="lbl-footer">FHER — 2, rue de Sèze, 75009 Paris — Tél. 01 23 45 67 89</div> <div class="lbl-footer">FHER — 2, rue de Sèze, 75009 Paris — Tél. 01 23 45 67 89</div>
</div> </div>
<div class="eti-side-hint" id="eti-side-hint">👉 Cliquez une zone de l'étiquette : sa justification réglementaire s'affiche ici, à côté du bidon.</div>
<div class="eti-pop" id="eti-pop" hidden>
<button class="pop-close" onclick="closeEtiPop()" aria-label="Fermer">×</button>
<div class="pop-status" id="pop-status"></div>
<div class="pop-title" id="pop-title"></div>
<div class="pop-reg" id="pop-reg"></div>
</div>
</div> </div>
<div id="etiquette-result" class="result-message"></div> <div id="etiquette-result" class="result-message"></div>
<div class="actions-row">
<button class="btn btn-outline" onclick="resetEtiquette()">↺ Recommencer</button>
<button class="btn btn-teal" id="eti-recap-btn" onclick="showEtiRecap()" disabled>Voir le récapitulatif →</button>
</div>
</div>
<!-- ---- PANNEAU B : synthèse / check-list ---- -->
<div id="eti-recap" hidden style="width:100%;display:flex;flex-direction:column;align-items:center;gap:0.75rem;">
<div class="section-tag" style="background:var(--teal-light);color:var(--teal-dark);">Check-list d'étiquetage</div>
<div class="slide-title">Récapitulatif — l'étiquette annotée</div>
<p class="slide-intro">Les 9 zones réglementaires et leurs bonnes réponses, présentées comme la <strong>check-list officielle d'étiquetage des produits biocides</strong> (Guide FHER, p. 15) — un outil réutilisable en conditions réelles.</p>
<div class="recap-layout" id="recap-layout">
<div class="eti-stage" id="recap-stage"></div>
<table class="checklist-table" id="checklist-table"></table>
</div>
<div class="actions-row">
<button class="btn btn-outline" onclick="hideEtiRecap()">← Retour à l'exercice</button>
<button class="btn btn-amber" onclick="goTo(8)">Voir la synthèse générale →</button>
</div>
</div> </div>
</div> </div>
@@ -979,19 +919,10 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
<tr><td><strong>Étiquette</strong></td><td>Dangers essentiels, interface rapide</td><td>À chaque prise du produit</td><td>Tous les utilisateurs</td></tr> <tr><td><strong>Étiquette</strong></td><td>Dangers essentiels, interface rapide</td><td>À chaque prise du produit</td><td>Tous les utilisateurs</td></tr>
</tbody> </tbody>
</table> </table>
<ul class="tips-list">
<li><strong>FDS = Sécurité et dangers</strong> → à consulter AVANT manipulation.</li>
<li><strong>FT = Mode d'emploi et applications</strong> → à consulter pour l'utilisation.</li>
<li><strong>Étiquette = Interface rapide</strong> → lecture obligatoire avant ouverture du produit.</li>
<li>Les trois documents sont <strong>complémentaires</strong>, jamais redondants — chacun cible un besoin et un public différents.</li>
</ul>
<div class="actions-row"> <div class="actions-row">
<button class="btn btn-amber" onclick="exporterPDF()">⬇ Télécharger mes réponses (PDF)</button> <button class="btn btn-amber" onclick="exporterPDF()">⬇ Télécharger mes réponses (PDF)</button>
<a href="correction-fds.html" class="btn btn-outline">✔ Corriger un rendu</a> <a href="corriger-exercice.html?exo=fds" class="btn btn-outline">✔ Corriger un rendu</a>
</div>
<div class="actions-row" style="margin-top:0.5rem;">
<button class="btn btn-outline" onclick="restartAll()">↺ Recommencer</button> <button class="btn btn-outline" onclick="restartAll()">↺ Recommencer</button>
<a href="../index.html#formations" class="btn btn-outline">Retour aux exercices</a>
</div> </div>
</div> </div>
</div> </div>
@@ -1024,7 +955,7 @@ const fdsQuestions = [
const ftQuestions = [ const ftQuestions = [
{ id: 1, text: "Quel est le nom du produit et qui est le fabricant ?", answer: "Description" }, { id: 1, text: "Quel est le nom du produit et qui est le fabricant ?", answer: "Description" },
{ id: 2, text: "Sur quelles matières ce produit est-il compatible ?", answer: "Absent" }, { id: 2, text: "Sur quelles matières ce produit est-il compatible ?", answer: "Absent" },
{ id: 3, text: "Quel est (sont) le(s) type(s) de ce produit ?", answer: "Propriétés biocides" }, { id: 3, text: "Où se trouve la liste des TP (type de produit) ?", answer: "Propriétés biocides" },
{ id: 4, text: "Quels sont les types de conditionnements disponibles ?", answer: "Conditionnements" }, { id: 4, text: "Quels sont les types de conditionnements disponibles ?", answer: "Conditionnements" },
{ id: 5, text: "Où retrouver les différents modes d'utilisation de ce produit ?", answer: "Utilisations" } { id: 5, text: "Où retrouver les différents modes d'utilisation de ce produit ?", answer: "Utilisations" }
]; ];
@@ -1091,7 +1022,6 @@ let etiOrder = []; // ordre aléatoire des questions
let etiPos = 0; // question courante let etiPos = 0; // question courante
let etiAnswers = {}; // { zoneCible : zoneCliquée en 1re tentative } — pour le PDF let etiAnswers = {}; // { zoneCible : zoneCliquée en 1re tentative } — pour le PDF
let etiFirstTry = {}; // { zoneCible : true si trouvée sans erreur } let etiFirstTry = {}; // { zoneCible : true si trouvée sans erreur }
let etiCurError = false; // une erreur a-t-elle eu lieu sur la question courante
let etiInit = false; // écouteur de clic posé une seule fois let etiInit = false; // écouteur de clic posé une seule fois
function buildEtiquette() { function buildEtiquette() {
@@ -1099,7 +1029,6 @@ function buildEtiquette() {
etiPos = 0; etiPos = 0;
etiAnswers = {}; etiAnswers = {};
etiFirstTry = {}; etiFirstTry = {};
etiCurError = false;
const card = document.getElementById('labelCard'); const card = document.getElementById('labelCard');
card.querySelectorAll('.hotspot').forEach(h => h.classList.remove('hs-correct', 'hs-wrong')); card.querySelectorAll('.hotspot').forEach(h => h.classList.remove('hs-correct', 'hs-wrong'));
@@ -1107,9 +1036,7 @@ function buildEtiquette() {
closeEtiPop(); closeEtiPop();
const r = document.getElementById('etiquette-result'); const r = document.getElementById('etiquette-result');
r.className = 'result-message'; r.innerHTML = ''; r.className = 'result-message'; r.innerHTML = '';
document.getElementById('eti-recap-btn').disabled = true; if (currentSlide === 7) renderBarActions();
document.getElementById('eti-exercise').style.display = 'flex';
document.getElementById('eti-recap').style.display = 'none';
if (!etiInit) { if (!etiInit) {
card.addEventListener('click', onEtiClick); card.addEventListener('click', onEtiClick);
@@ -1136,38 +1063,25 @@ function onEtiClick(e) {
if (!hs) return; if (!hs) return;
const clicked = hs.dataset.zone; const clicked = hs.dataset.zone;
// Exploration libre une fois l'exercice terminé // Exercice terminé : plus d'interaction (la correction est sur la page de correction).
if (etiPos >= etiOrder.length) { if (etiPos >= etiOrder.length) return;
openEtiPop(hs, 'info', clicked);
return;
}
marquerModifie();
const target = etiZones[etiOrder[etiPos]]; const target = etiZones[etiOrder[etiPos]];
if (clicked === target.zone) {
hs.classList.remove('hs-wrong'); // La zone cliquée — bonne ou mauvaise — est enregistrée comme réponse, puis on passe
hs.classList.add('hs-correct'); // à la question suivante. Aucun retour de justesse n'est affiché ici : la correction
if (etiAnswers[target.zone] === undefined) etiAnswers[target.zone] = clicked; // (étiquette annotée + check-list) est présentée sur la page de correction de l'exercice.
etiFirstTry[target.zone] = !etiCurError; etiAnswers[target.zone] = clicked;
openEtiPop(hs, 'ok', clicked); etiFirstTry[target.zone] = (clicked === target.zone);
etiPos++; etiPos++;
etiCurError = false; renderEtiQuestion();
renderEtiQuestion(); if (etiPos >= etiOrder.length) finishEtiquette();
if (etiPos >= etiOrder.length) finishEtiquette();
} else {
if (etiAnswers[target.zone] === undefined) etiAnswers[target.zone] = clicked; // 1re réponse (erronée) mémorisée
etiCurError = true;
etiFirstTry[target.zone] = false;
hs.classList.remove('hs-wrong'); void hs.offsetWidth; // relance l'animation
hs.classList.add('hs-wrong');
setTimeout(() => hs.classList.remove('hs-wrong'), 700);
openEtiPop(hs, 'ko', clicked);
}
} }
function finishEtiquette() { function finishEtiquette() {
const correct = etiZones.filter(z => etiFirstTry[z.zone]).length; const b = document.getElementById('eti-recap-btn');
showScore(document.getElementById('etiquette-result'), correct, etiZones.length); if (b) b.disabled = false;
document.getElementById('eti-recap-btn').disabled = false;
} }
/* Info-bulle réglementaire positionnée près de la zone cliquée */ /* Info-bulle réglementaire positionnée près de la zone cliquée */
@@ -1199,66 +1113,6 @@ function closeEtiPop() {
function resetEtiquette() { buildEtiquette(); } function resetEtiquette() { buildEtiquette(); }
/* ---- Synthèse : étiquette annotée + check-list ---- */
function showEtiRecap() {
closeEtiPop();
const stage = document.getElementById('recap-stage');
stage.innerHTML = '';
// Clone de l'étiquette, toutes zones en « correct » + numéros
const clone = document.getElementById('labelCard').cloneNode(true);
clone.removeAttribute('id');
clone.classList.add('recap');
clone.querySelectorAll('.hotspot').forEach(h => {
h.classList.remove('hs-wrong');
h.classList.add('hs-correct');
const idx = etiZones.findIndex(z => z.zone === h.dataset.zone);
if (idx >= 0) {
const num = document.createElement('b');
num.className = 'hs-num';
num.textContent = idx + 1;
h.insertBefore(num, h.firstChild);
}
});
stage.appendChild(clone);
// Table check-list
const tbl = document.getElementById('checklist-table');
let rows = '<thead><tr><th style="width:34px;">N°</th><th>Élément d\'étiquetage</th><th>Base réglementaire</th><th style="width:70px;">Statut</th></tr></thead><tbody>';
etiZones.forEach((z, i) => {
const ok = etiFirstTry[z.zone];
const answered = etiAnswers[z.zone] !== undefined;
const statut = ok
? '<span class="cl-check">✓ trouvé</span>'
: (answered ? '<span class="cl-cross">✗ à revoir</span>' : '<span class="cl-cross">—</span>');
rows += `<tr>
<td class="cl-num"><span class="cl-badge">${i + 1}</span></td>
<td><strong>${z.label}</strong></td>
<td>${z.reg}</td>
<td>${statut}</td>
</tr>`;
});
rows += '</tbody>';
tbl.innerHTML = rows;
document.getElementById('eti-exercise').style.display = 'none';
document.getElementById('eti-recap').style.display = 'flex';
// Animation : l'étiquette glisse à gauche, le tableau apparaît à sa droite
const rl = document.getElementById('recap-layout');
if (rl) { rl.classList.remove('animate'); void rl.offsetWidth; rl.classList.add('animate'); }
const sa = document.querySelector('#slide-7 .scroll-area');
if (sa) sa.scrollTop = 0;
}
function hideEtiRecap() {
document.getElementById('eti-recap').style.display = 'none';
document.getElementById('eti-exercise').style.display = 'flex';
const sa = document.querySelector('#slide-7 .scroll-area');
if (sa) sa.scrollTop = 0;
}
/* ---- FT : exercice « relier » ---- */ /* ---- FT : exercice « relier » ---- */
const ftConnections = {}; // { qId: valeur de la section reliée } const ftConnections = {}; // { qId: valeur de la section reliée }
let ftDrag = null; // { qid } pendant un glissement let ftDrag = null; // { qid } pendant un glissement
@@ -1296,7 +1150,7 @@ function buildFtMatch() {
initFtMatch(); initFtMatch();
document.getElementById('ft-svg').innerHTML = ''; document.getElementById('ft-svg').innerHTML = '';
document.getElementById('ft-verify').disabled = true; refreshFtState();
} }
function resetFT() { function resetFT() {
@@ -1354,7 +1208,8 @@ function refreshFtState() {
dot.closest('.match-item').classList.toggle('connected', used); dot.closest('.match-item').classList.toggle('connected', used);
}); });
const all = ftQuestions.every(q => ftConnections[q.id] != null); const all = ftQuestions.every(q => ftConnections[q.id] != null);
document.getElementById('ft-verify').disabled = !all; const btn = document.getElementById('ft-next');
if (btn) btn.disabled = !all;
} }
function initFtMatch() { function initFtMatch() {
@@ -1383,7 +1238,7 @@ document.addEventListener('pointerup', e => {
if (!ftDrag) return; if (!ftDrag) return;
const target = document.elementFromPoint(e.clientX, e.clientY); const target = document.elementFromPoint(e.clientX, e.clientY);
const answerDot = target && target.closest('.match-dot[data-side="right"]'); const answerDot = target && target.closest('.match-dot[data-side="right"]');
if (answerDot) ftConnections[ftDrag.qid] = answerDot.dataset.answer; if (answerDot) { marquerModifie(); ftConnections[ftDrag.qid] = answerDot.dataset.answer; }
document.querySelectorAll('.match-dot.active').forEach(d => d.classList.remove('active')); document.querySelectorAll('.match-dot.active').forEach(d => d.classList.remove('active'));
ftDrag = null; ftDrag = null;
refreshFtState(); refreshFtState();
@@ -1434,6 +1289,7 @@ function initFdsDnd() {
r.className = 'result-message'; r.innerHTML = ''; r.className = 'result-message'; r.innerHTML = '';
}; };
const fill = (slot, v, l) => { const fill = (slot, v, l) => {
marquerModifie();
slot.dataset.value = v; slot.textContent = l; slot.classList.add('filled'); slot.dataset.value = v; slot.textContent = l; slot.classList.add('filled');
resetResults(); updateFdsGate(); resetResults(); updateFdsGate();
}; };
@@ -1474,7 +1330,8 @@ function initFdsDnd() {
function updateFdsGate() { function updateFdsGate() {
const slots = document.querySelectorAll('#slide-3 .drop-slot'); const slots = document.querySelectorAll('#slide-3 .drop-slot');
const all = Array.from(slots).every(s => (s.dataset.value || '') !== ''); const all = Array.from(slots).every(s => (s.dataset.value || '') !== '');
document.getElementById('fds-verify').disabled = !all; const btn = document.getElementById('fds-next');
if (btn) btn.disabled = !all;
} }
function wireVerifyGate(containerId, btnId) { function wireVerifyGate(containerId, btnId) {
@@ -1676,6 +1533,7 @@ function goTo(n) {
const sa = next.querySelector('.scroll-area'); const sa = next.querySelector('.scroll-area');
if (sa) sa.scrollTop = 0; if (sa) sa.scrollTop = 0;
updateUI(); updateUI();
if (n === 8) setTimeout(launchConfetti, 200);
} }
function goBack() { function goBack() {
@@ -1691,20 +1549,68 @@ function goBack() {
updateUI(); updateUI();
} }
/* Boutons centralisés dans la bottom bar, un jeu par slide */
function barBtn(bar, html, cls, fn, opts) {
opts = opts || {};
const b = document.createElement('button');
b.className = 'footer-btn ' + cls;
b.innerHTML = html;
if (opts.id) b.id = opts.id;
if (opts.disabled) b.disabled = true;
b.addEventListener('click', fn);
bar.appendChild(b);
return b;
}
function renderBarActions() {
const bar = document.getElementById('barActions');
if (!bar) return;
bar.innerHTML = '';
// Bouton retour intégré au groupe centré
if (history.length > 0) {
barBtn(bar, '← Retour', 'is-secondary', goBack);
}
switch (currentSlide) {
case 1:
barBtn(bar, 'Commencer la partie 1 →', 'is-primary', startPart1);
break;
case 2:
barBtn(bar, 'Répondre aux questions →', 'is-primary', () => goTo(3));
break;
case 3:
barBtn(bar, '↺ Recommencer', 'is-secondary', resetFDS);
barBtn(bar, 'Passer à la partie 2 →', 'is-primary', () => goTo(4), { id: 'fds-next' });
updateFdsGate();
break;
case 4:
barBtn(bar, 'Répondre aux questions →', 'is-primary', () => goTo(5));
break;
case 5:
barBtn(bar, '↺ Recommencer', 'is-secondary', resetFT);
barBtn(bar, 'Passer à la partie 3 →', 'is-primary', () => goTo(6), { id: 'ft-next' });
refreshFtState();
break;
case 6:
barBtn(bar, 'Répondre aux questions →', 'is-primary', () => goTo(7));
break;
case 7:
barBtn(bar, '↺ Recommencer', 'is-secondary', resetEtiquette);
barBtn(bar, 'Terminer l\'exercice →', 'is-primary', () => goTo(8),
{ id: 'eti-recap-btn', disabled: etiPos < etiOrder.length });
break;
case 8:
// Boutons placés sous le tableau comparatif, dans la slide (pas dans la bottom bar)
break;
}
}
function updateUI() { function updateUI() {
document.getElementById('progressFill').style.width = document.getElementById('progressFill').style.width =
currentSlide === 0 ? '0%' : ((currentSlide / totalSlides) * 100) + '%'; currentSlide === 0 ? '0%' : ((currentSlide / totalSlides) * 100) + '%';
document.getElementById('slideCounter').textContent = document.getElementById('slideCounter').textContent =
currentSlide === 0 ? '' : currentSlide + ' / ' + totalSlides; currentSlide === 0 ? '' : currentSlide + ' / ' + totalSlides;
document.getElementById('bottomBar').style.display = currentSlide === 0 ? 'none' : 'flex'; document.getElementById('bottomBar').style.display = currentSlide === 0 ? 'none' : 'flex';
document.getElementById('backBtn').style.display = history.length > 0 ? 'block' : 'none'; renderBarActions();
const badge = document.getElementById('phaseBadge');
const set = (bg, col, txt) => { badge.style.display = 'block'; badge.style.background = bg; badge.style.color = col; badge.textContent = txt; };
if (currentSlide === 2 || currentSlide === 3) set('var(--amber-light)', 'var(--amber-dark)', 'Partie 1 — FDS');
else if (currentSlide === 4 || currentSlide === 5) set('var(--coral-light)', 'var(--coral)', 'Partie 2 — FT');
else if (currentSlide === 6 || currentSlide === 7) set('var(--teal-light)', 'var(--teal-dark)', 'Partie 3 — Étiquette');
else badge.style.display = 'none';
} }
function restartAll() { function restartAll() {
@@ -1719,6 +1625,96 @@ function restartAll() {
updateUI(); updateUI();
} }
/* ======================== CONFETTIS ======================== */
function launchConfetti() {
const canvas = document.getElementById('confettiCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.display = 'block';
const colors = ['#BA7517', '#D85A30', '#F5DFB8', '#633806', '#F0A855', '#E8C068', '#FF8C5A'];
const particles = Array.from({ length: 180 }, () => ({
x: Math.random() * canvas.width,
y: -(Math.random() * canvas.height * 0.5),
w: Math.random() * 10 + 5,
h: Math.random() * 5 + 3,
color: colors[Math.floor(Math.random() * colors.length)],
rotation: Math.random() * Math.PI * 2,
rotSpeed: (Math.random() - 0.5) * 0.18,
vx: (Math.random() - 0.5) * 2.5,
vy: Math.random() * 3 + 2,
opacity: 1,
shape: Math.random() < 0.4 ? 'circle' : 'rect'
}));
let rafId;
let start = null;
const totalDuration = 3800;
function draw(ts) {
if (!start) start = ts;
const elapsed = ts - start;
ctx.clearRect(0, 0, canvas.width, canvas.height);
let active = false;
particles.forEach(p => {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.04;
p.rotation += p.rotSpeed;
p.opacity = elapsed < totalDuration * 0.55
? 1
: Math.max(0, 1 - (elapsed - totalDuration * 0.55) / (totalDuration * 0.45));
if (p.y < canvas.height + 20) active = true;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rotation);
ctx.globalAlpha = p.opacity;
ctx.fillStyle = p.color;
if (p.shape === 'circle') {
ctx.beginPath();
ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
ctx.restore();
});
if (active && elapsed < totalDuration * 1.3) {
rafId = requestAnimationFrame(draw);
} else {
canvas.style.display = 'none';
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(draw);
}
/* ======================== PROTECTION CONTRE LA PERTE DES RÉPONSES ======================== */
/* Les réponses ne sont pas enregistrées : on avertit avant tout rechargement ou départ.
Le texte de la boîte est imposé par le navigateur, il n'est pas personnalisable. */
let reponsesModifiees = false;
function marquerModifie() { reponsesModifiees = true; }
// Les champs texte déclenchent input/change ; les parties FDS (glisser-déposer),
// FT (liaisons) et Étiquette (zones cliquables) appellent marquerModifie() directement.
document.addEventListener('input', marquerModifie, true);
document.addEventListener('change', marquerModifie, true);
window.addEventListener('beforeunload', e => {
if (!reponsesModifiees) return;
e.preventDefault();
e.returnValue = '';
});
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
generateQuizzes(); generateQuizzes();
updateUI(); updateUI();

View File

@@ -17,7 +17,7 @@
justify-content: center; justify-content: center;
padding: 2rem; padding: 2rem;
padding-top: calc(70px + 2rem); padding-top: calc(70px + 2rem);
padding-bottom: calc(52px + 2rem); padding-bottom: calc(84px + 2rem);
opacity: 0; opacity: 0;
transition: opacity 0.4s ease; transition: opacity 0.4s ease;
overflow-y: auto; overflow-y: auto;
@@ -38,73 +38,67 @@
transition: width 0.4s ease; transition: width 0.4s ease;
} }
/* PHASE INDICATOR */
.phase-badge {
position: fixed;
top: 80px; right: 16px;
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 4px 12px;
border-radius: 20px;
z-index: 100;
}
/* BOTTOM BAR */ /* BOTTOM BAR */
.bottom-bar { .bottom-bar {
position: fixed; position: fixed;
bottom: 0; left: 0; right: 0; bottom: 0; left: 0; right: 0;
height: 52px; height: 84px;
background: rgba(250, 238, 218, 0.98); background: rgba(250, 238, 218, 0.98);
border-top: 2px solid var(--amber); border-top: 2px solid var(--amber);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
padding: 0 20px; padding: 0 20px;
z-index: 100; z-index: 100;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
.bottom-actions {
/* SLIDE NAV */ display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.footer-btn {
font-family: 'Syne', sans-serif;
font-size: 13px;
font-weight: 600;
padding: 11px 24px;
border-radius: 40px;
border: 1.5px solid var(--amber);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1;
transition: all 0.2s;
}
.footer-btn:hover { transform: translateY(-1px); }
.footer-btn:active { transform: scale(0.98); }
.footer-btn.is-primary { background: var(--amber); color: #fff; }
.footer-btn.is-primary:hover { background: var(--amber-dark); }
.footer-btn.is-secondary { background: transparent; color: var(--amber-dark); }
.footer-btn.is-secondary:hover { background: rgba(186,117,23,0.08); }
.slide-counter { .slide-counter {
position: absolute;
left: 20px;
font-size: 12px; font-size: 12px;
font-family: 'Syne', sans-serif; font-family: 'Syne', sans-serif;
font-weight: 600; font-weight: 600;
color: var(--amber-dark); color: var(--amber-dark);
} }
@media (max-width: 560px) {
/* BACK BUTTON */ .slide-counter { display: none; }
.back-btn {
background: none;
border: 0.5px solid var(--amber);
border-radius: 20px;
padding: 6px 16px;
font-size: 12px;
color: var(--amber-dark);
cursor: pointer;
transition: all 0.2s;
font-family: 'DM Sans', sans-serif;
} }
.back-btn:hover { background: rgba(186,117,23,0.08); }
/* FORMATEUR SELECTOR */ /* FORMATEUR SELECTOR */
.formateur-btn { .formateur-btn {
font-family: 'Syne', sans-serif; flex: 1; font-family: 'DM Sans', sans-serif; font-size: 13.5px;
font-size: 14px; padding: 9px 13px; border: 1px solid var(--border); border-radius: 10px;
font-weight: 600; background: var(--bg); color: var(--text); cursor: pointer; transition: all 0.2s ease;
padding: 10px 24px;
border-radius: 30px;
border: 1.5px solid var(--border);
background: white;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
flex: 1;
} }
.formateur-btn:hover { border-color: var(--amber); color: var(--amber); } .formateur-btn:hover { border-color: var(--amber); }
.formateur-btn.selected { background: var(--amber-light); border-color: var(--amber); color: var(--amber-dark); font-weight: 700; } .formateur-btn.selected { background: var(--amber); border-color: var(--amber); color: #fff; }
/* CARDS & BUTTONS */ /* CARDS & BUTTONS */
.btn { .btn {
@@ -246,40 +240,42 @@
.q-item { flex-direction: column; align-items: stretch; } .q-item { flex-direction: column; align-items: stretch; }
.q-item .q-head { display: flex; align-items: flex-start; gap: 12px; } .q-item .q-head { display: flex; align-items: flex-start; gap: 12px; }
/* IDENTIFICATION SCREEN */ /* IDENTIFICATION SCREEN — carte modèle BioCID / FDS */
.id-form { .identity-card {
background: white;
border: 0.5px solid var(--border);
border-radius: 14px;
padding: 16px 18px;
max-width: 460px;
width: 100%; width: 100%;
max-width: 420px; margin: 1.25rem auto;
display: flex; text-align: left;
flex-direction: column;
gap: 14px;
margin: 1.25rem 0;
} }
.id-field label { .identity-card .identity-title {
display: block;
font-family: 'Syne', sans-serif; font-family: 'Syne', sans-serif;
font-size: 11px; font-size: 12px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-muted); color: var(--amber-dark);
margin-bottom: 6px; margin-bottom: 10px;
text-align: left; display: flex; align-items: center; gap: 6px;
} }
.id-field input { .identity-fields { display: flex; flex-direction: column; gap: 8px; }
width: 100%; .identity-fields input {
font-family: 'DM Sans', sans-serif; font-family: 'DM Sans', sans-serif;
font-size: 15px; font-size: 13.5px;
padding: 12px 16px;
border: 1.5px solid var(--border);
border-radius: 10px;
background: white;
color: var(--text); color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 9px 13px;
width: 100%;
box-sizing: border-box;
} }
.id-field input:focus { .identity-fields input:focus { outline: none; border-color: var(--amber); background: white; }
outline: none; .formateur-label { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted); margin: 4px 0 2px; }
border-color: var(--amber); .formateur-row { display: flex; gap: 8px; }
}
/* TABLE FORM CONTROLS */ /* TABLE FORM CONTROLS */
.risk-table input, .risk-table textarea { .risk-table input, .risk-table textarea {
@@ -496,6 +492,19 @@
line-height: 1.6; line-height: 1.6;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.info-card {
background: var(--amber-light);
border-radius: 12px;
padding: 16px 20px;
max-width: 620px;
width: 100%;
font-size: 14px;
color: var(--amber-dark);
line-height: 1.6;
display: flex;
gap: 10px;
align-items: flex-start;
}
.steps-row { .steps-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -574,7 +583,7 @@
gap: 12px; gap: 12px;
max-width: 640px; max-width: 640px;
width: 100%; width: 100%;
margin: 1.5rem 0; margin: 1.5rem auto;
} }
.final-card { .final-card {
background: white; background: white;
@@ -606,7 +615,7 @@
/* SCROLLABLE AREA */ /* SCROLLABLE AREA */
.scroll-area { .scroll-area {
overflow-y: auto; overflow-y: auto;
max-height: calc(100vh - 232px); max-height: calc(100vh - 200px);
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -647,13 +656,14 @@
<!-- Progress bar --> <!-- Progress bar -->
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div> <div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
<!-- Phase badge -->
<div class="phase-badge" id="phaseBadge" style="display:none"></div>
<!-- Bottom bar --> <!-- Bottom bar -->
<div class="bottom-bar" id="bottomBar" style="display:none;"> <div class="bottom-bar" id="bottomBar" style="display:none;">
<button class="back-btn" id="backBtn" onclick="goBack()" style="display:none;">← Retour</button>
<div class="slide-counter" id="slideCounter"></div> <div class="slide-counter" id="slideCounter"></div>
<div class="bottom-actions">
<button class="footer-btn is-secondary" id="backBtn" onclick="goBack()" style="display:none;">← Retour</button>
<button class="footer-btn is-secondary" id="footerHintBtn" onclick="showHintCurrent()" style="display:none;">💡 Besoin d'aide ?</button>
<button class="footer-btn is-primary" id="footerNextBtn" onclick="goNext()" style="display:none;">Étape suivante →</button>
</div>
</div> </div>
<!-- Modal overlay --> <!-- Modal overlay -->
@@ -666,48 +676,40 @@
</div> </div>
</div> </div>
<!-- ======================== SLIDE 0 — IDENTIFICATION ======================== --> <!-- ======================== SLIDE 0 — ACCUEIL ======================== -->
<div class="slide active fade-in" id="slide-0"> <div class="slide active fade-in" id="slide-0">
<div class="deco-circle" style="width:320px;height:320px;background:var(--amber-light);top:-80px;left:-80px;opacity:0.5;"></div> <div class="deco-circle" style="width:400px;height:400px;background:var(--amber-light);top:-100px;right:-80px;opacity:0.6;"></div>
<div style="text-align:center; z-index:1; width:100%;"> <div class="deco-circle" style="width:200px;height:200px;background:var(--amber-light);bottom:60px;left:-60px;opacity:0.5;"></div>
<div class="section-tag" style="background:var(--amber-light);color:var(--amber-dark);">Avant de commencer</div> <div style="text-align:center;">
<div class="slide-title">Identification du groupe</div> <div class="hero-label">Certibiocide — Travaux pratiques</div>
<p class="slide-intro" style="margin:0.5rem auto 0;max-width:420px;">Ces informations permettront au formateur de retrouver vos réponses.</p> <div class="hero-title">Étude de cas<br>Nuisibles</div>
<div class="id-form" style="margin-left:auto;margin-right:auto;"> <p class="hero-sub">Votre mission, si vous l'acceptez : maîtriser l'identification, le traitement et la sécurité face aux nuisibles.</p>
<div class="id-field"> <button class="btn btn-amber" onclick="goTo(1)">Démarrer la mission →</button>
<label for="inputNom">Nom du stagiaire ou du groupe</label>
<input type="text" id="inputNom" placeholder="Ex : Groupe 3 - Dupont / Martin" autocomplete="off">
</div>
<div class="id-field">
<label for="inputDate">Date de la session</label>
<input type="date" id="inputDate">
</div>
<div class="id-field">
<label>Formateur de la session</label>
<div style="display:flex;gap:10px;margin-top:2px;">
<button type="button" class="formateur-btn" id="btnNathan" onclick="selectFormateur('Nathan')">Nathan</button>
<button type="button" class="formateur-btn" id="btnGauthier" onclick="selectFormateur('Gauthier')">Gauthier</button>
</div>
</div>
</div>
<div id="idError" style="color:var(--coral);font-size:13px;margin-bottom:0.5rem;display:none;">Merci de renseigner au moins le nom du groupe.</div>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;">
<button class="btn btn-teal" onclick="confirmIdentite()">Continuer →</button>
<a href="correction-tp-nuisibles.html" class="btn btn-outline btn-teal">Corriger</a>
</div>
</div> </div>
</div> </div>
<!-- ======================== SLIDE 1 — ACCUEIL ======================== --> <!-- ======================== SLIDE 1 — CONTEXTE & IDENTIFICATION ======================== -->
<div class="slide" id="slide-1"> <div class="slide" id="slide-1">
<div class="deco-circle" style="width:400px;height:400px;background:var(--amber-light);top:-100px;right:-80px;opacity:0.6;"></div> <div class="deco-circle" style="width:300px;height:300px;background:var(--amber-light);top:-60px;left:-60px;opacity:0.4;"></div>
<div class="deco-circle" style="width:200px;height:200px;background:var(--amber-light);bottom:60px;left:-60px;opacity:0.5;"></div> <div class="section-tag" style="background:var(--amber-light);color:var(--amber-dark);">Avant de commencer</div>
<div style="text-align:center; z-index:1;"> <div class="slide-title">Contexte & accès</div>
<div class="hero-label">Formation</div> <p class="slide-intro">3 travaux pratiques vous attendent : protocole d'intervention, recherche produit sur BioCID, puis analyse de risques. Ces informations permettront au formateur de retrouver vos réponses.</p>
<div class="hero-title">CERTIBIOCIDE</div> <div class="info-card" style="max-width:620px;">
<div style="font-family:'Syne',sans-serif;font-size:clamp(14px,2.5vw,22px);font-weight:600;color:var(--amber-mid);letter-spacing:0.04em;margin-bottom:1.5rem;">TRAVAUX PRATIQUES</div> <span>🎯</span>
<p class="hero-sub">Votre mission, si vous l'acceptez : maîtriser l'identification, le traitement et la sécurité face aux nuisibles.</p> <div>Vous allez définir un <strong>protocole d'intervention</strong>, trouver votre <strong>produit sur BioCID</strong>, puis réaliser l'<strong>analyse de risques</strong>.</div>
<button class="btn btn-teal" onclick="goTo(2)">Démarrer la mission →</button> </div>
<div class="identity-card" style="max-width:620px;margin-top:0.5rem;">
<div class="identity-title">👥 Identification du groupe</div>
<div class="identity-fields">
<input type="text" id="inputNom" placeholder="Nom du groupe / stagiaires (ex : Groupe 3 — Dupont / Martin)" autocomplete="off">
<input type="date" id="inputDate">
<div class="formateur-label">Formateur de la session</div>
<div class="formateur-row">
<button type="button" class="formateur-btn" id="btnNathan" onclick="selectFormateur('Nathan')">Nathan</button>
<button type="button" class="formateur-btn" id="btnGauthier" onclick="selectFormateur('Gauthier')">Gauthier</button>
</div>
</div>
<div id="idError" style="color:var(--coral);font-size:13px;margin-top:8px;display:none;">Merci de renseigner au moins le nom du groupe.</div>
</div> </div>
</div> </div>
@@ -715,8 +717,12 @@
<div class="slide" id="slide-2"> <div class="slide" id="slide-2">
<div class="deco-circle" style="width:300px;height:300px;background:var(--amber-light);top:-60px;left:-60px;opacity:0.4;"></div> <div class="deco-circle" style="width:300px;height:300px;background:var(--amber-light);top:-60px;left:-60px;opacity:0.4;"></div>
<div class="section-tag" style="background:var(--amber-light);color:var(--amber-dark);">Étape 1 / 3</div> <div class="section-tag" style="background:var(--amber-light);color:var(--amber-dark);">Étape 1 / 3</div>
<div class="slide-title">Choisissez votre mission</div> <div class="slide-title">🎯 Choisissez votre mission</div>
<p class="slide-intro">Chaque groupe choisit un cas pratique. Vous travaillerez avec ce scénario tout au long des 3 exercices.</p> <p class="slide-intro">Chaque groupe choisit un cas pratique. Vous travaillerez avec ce scénario tout au long des 3 exercices.</p>
<div class="info-card" style="margin-bottom:1.25rem;">
<span>💡</span>
<div>Le cas retenu conditionne <strong>tous les exercices suivants</strong> et apparaît dans le PDF final. Choisissez celui qui se rapproche le plus de votre pratique.</div>
</div>
<div class="case-grid"> <div class="case-grid">
<div class="case-card" onclick="selectCase('punaises','🛏️','Hôtel — Punaises de lit','Infestation importante dans un hôtel')"> <div class="case-card" onclick="selectCase('punaises','🛏️','Hôtel — Punaises de lit','Infestation importante dans un hôtel')">
<div class="case-icon">🛏️</div> <div class="case-icon">🛏️</div>
@@ -744,9 +750,6 @@
<div class="case-sub">Camping</div> <div class="case-sub">Camping</div>
</div> </div>
</div> </div>
<div class="actions-row">
<button class="btn btn-teal" id="btnConfirmCase" onclick="goTo(3)" style="display:none">Confirmer le choix →</button>
</div>
</div> </div>
<!-- ======================== SLIDE 3 — CONSIGNES ======================== --> <!-- ======================== SLIDE 3 — CONSIGNES ======================== -->
@@ -774,7 +777,6 @@
<span>💡</span> <span>💡</span>
<div>À tout moment, cliquez sur <strong>« Besoin d'aide ? »</strong> pour accéder à un indice. Le formateur corrigera à l'oral en fin de séance.</div> <div>À tout moment, cliquez sur <strong>« Besoin d'aide ? »</strong> pour accéder à un indice. Le formateur corrigera à l'oral en fin de séance.</div>
</div> </div>
<button class="btn btn-teal" onclick="goTo(4)">Commencer le TP 1 →</button>
</div> </div>
<!-- ======================== SLIDE 4 — TP1 INTRO ======================== --> <!-- ======================== SLIDE 4 — TP1 INTRO ======================== -->
@@ -792,7 +794,6 @@
<div class="step-pill">2. Intervention</div> <div class="step-pill">2. Intervention</div>
<div class="step-pill">3. Évaluation</div> <div class="step-pill">3. Évaluation</div>
</div> </div>
<button class="btn btn-amber" onclick="goTo(5)">Commencer →</button>
</div> </div>
<!-- ======================== SLIDE 5 — TP1 PREVENTION ======================== --> <!-- ======================== SLIDE 5 — TP1 PREVENTION ======================== -->
@@ -833,10 +834,6 @@
<textarea class="answer-field" id="ans_prevention_4" placeholder="Votre réponse…" data-question="Éléments observés / preuve de présence du nuisible"></textarea> <textarea class="answer-field" id="ans_prevention_4" placeholder="Votre réponse…" data-question="Éléments observés / preuve de présence du nuisible"></textarea>
</li> </li>
</ul> </ul>
<div class="actions-row">
<button class="hint-btn" onclick="showHint('prevention')">💡 Besoin d'aide ?</button>
<button class="btn btn-amber" onclick="goTo(6)">Étape suivante →</button>
</div>
</div> </div>
</div> </div>
@@ -871,10 +868,6 @@
<textarea class="answer-field" id="ans_intervention_3" placeholder="Votre réponse…" data-question="Type de produit (TP) et mode de traitement"></textarea> <textarea class="answer-field" id="ans_intervention_3" placeholder="Votre réponse…" data-question="Type de produit (TP) et mode de traitement"></textarea>
</li> </li>
</ul> </ul>
<div class="actions-row">
<button class="hint-btn" onclick="showHint('intervention')">💡 Besoin d'aide ?</button>
<button class="btn btn-amber" onclick="goTo(7)">Étape suivante →</button>
</div>
</div> </div>
</div> </div>
@@ -905,10 +898,6 @@
<div style="background:var(--amber-light);border-radius:12px;padding:14px 18px;max-width:680px;width:100%;font-size:13px;color:var(--amber-dark);margin:0.75rem 0;"> <div style="background:var(--amber-light);border-radius:12px;padding:14px 18px;max-width:680px;width:100%;font-size:13px;color:var(--amber-dark);margin:0.75rem 0;">
<strong>TP 1 terminé !</strong> Préparez-vous à identifier le produit adapté à votre cas sur biocid-anses.fr. <strong>TP 1 terminé !</strong> Préparez-vous à identifier le produit adapté à votre cas sur biocid-anses.fr.
</div> </div>
<div class="actions-row">
<button class="hint-btn" onclick="showHint('evaluation')">💡 Besoin d'aide ?</button>
<button class="btn btn-coral" onclick="goTo(8)">Passer au TP 2 →</button>
</div>
</div> </div>
</div> </div>
@@ -930,7 +919,6 @@
</div> </div>
<a href="https://biocid-anses.fr" target="_blank" class="resource-link">Ouvrir le site ↗</a> <a href="https://biocid-anses.fr" target="_blank" class="resource-link">Ouvrir le site ↗</a>
</div> </div>
<button class="btn btn-coral" onclick="goTo(9)">Commencer →</button>
</div> </div>
<!-- ======================== SLIDE 9 — TP2 FDS & AMM ======================== --> <!-- ======================== SLIDE 9 — TP2 FDS & AMM ======================== -->
@@ -971,10 +959,6 @@
<textarea class="answer-field" id="ans_fds_4" placeholder="Votre réponse…" data-question="Risques pour la santé"></textarea> <textarea class="answer-field" id="ans_fds_4" placeholder="Votre réponse…" data-question="Risques pour la santé"></textarea>
</li> </li>
</ul> </ul>
<div class="actions-row">
<button class="hint-btn" onclick="showHint('fds')">💡 Besoin d'aide ?</button>
<button class="btn btn-coral" onclick="goTo(10)">Partie suivante →</button>
</div>
</div> </div>
</div> </div>
@@ -1019,10 +1003,6 @@
<div style="background:var(--amber-light);border-radius:12px;padding:14px 18px;max-width:680px;width:100%;font-size:13px;color:var(--amber-dark);margin:0.75rem 0;"> <div style="background:var(--amber-light);border-radius:12px;padding:14px 18px;max-width:680px;width:100%;font-size:13px;color:var(--amber-dark);margin:0.75rem 0;">
<strong>TP 2 terminé !</strong> Passez maintenant à l'analyse de risques complète. <strong>TP 2 terminé !</strong> Passez maintenant à l'analyse de risques complète.
</div> </div>
<div class="actions-row">
<button class="hint-btn" onclick="showHint('epi')">💡 Besoin d'aide ?</button>
<button class="btn btn-purple" onclick="goTo(11)">Passer au TP 3 →</button>
</div>
</div> </div>
</div> </div>
@@ -1039,9 +1019,6 @@
<div class="pyr-level" style="width:320px;background:#D85A30;">Communication</div> <div class="pyr-level" style="width:320px;background:#D85A30;">Communication</div>
<div class="pyr-level" style="width:360px;background:#BA7517;">EPI (dernier recours)</div> <div class="pyr-level" style="width:360px;background:#BA7517;">EPI (dernier recours)</div>
</div> </div>
<div class="actions-row" style="margin-top:1rem;">
<button class="btn btn-purple" onclick="goTo(12)">Remplir le tableau →</button>
</div>
</div> </div>
<!-- ======================== SLIDE 12 — TP3 TABLEAU ======================== --> <!-- ======================== SLIDE 12 — TP3 TABLEAU ======================== -->
@@ -1110,10 +1087,6 @@
<div style="background:var(--amber-light);border-radius:12px;padding:14px 18px;max-width:780px;width:100%;font-size:13px;color:var(--amber-dark);margin:0.25rem 0;"> <div style="background:var(--amber-light);border-radius:12px;padding:14px 18px;max-width:780px;width:100%;font-size:13px;color:var(--amber-dark);margin:0.25rem 0;">
<strong>TP 3 terminé !</strong> Présentez vos réponses au formateur pour la correction. <strong>TP 3 terminé !</strong> Présentez vos réponses au formateur pour la correction.
</div> </div>
<div class="actions-row">
<button class="hint-btn" onclick="showHint('analyse')">💡 Aide générale</button>
<button class="btn btn-blue" onclick="goTo(13)">Terminer la mission →</button>
</div>
</div> </div>
</div> </div>
@@ -1143,15 +1116,10 @@
<div class="final-card-sub">Analyse de risques</div> <div class="final-card-sub">Analyse de risques</div>
</div> </div>
</div> </div>
<div class="submit-zone"> <div class="actions-row">
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;"> <button class="btn btn-amber" onclick="exporterPDF()">⬇ Télécharger mes réponses (PDF)</button>
<button class="btn btn-teal" onclick="exporterPDF()">⬇ Télécharger en PDF</button> <a href="corriger-exercice.html?exo=tp" class="btn btn-outline btn-teal">✔ Corriger un rendu</a>
<a href="correction-tp-nuisibles.html" class="btn btn-outline btn-teal">Corriger</a> <button class="btn btn-outline btn-teal" onclick="restartAll()">↺ Recommencer</button>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:0.75rem;">
<a href="https://biocid-anses.fr" target="_blank" class="btn btn-outline btn-teal" style="font-size:13px;padding:10px 20px;">biocid-anses.fr ↗</a>
<button class="btn btn-teal" onclick="restartAll()">↺ Recommencer depuis le début</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1164,6 +1132,22 @@ const totalSlides = 13;
const selectedCase = { key: '', icon: '', title: '', desc: '' }; const selectedCase = { key: '', icon: '', title: '', desc: '' };
const stagiaire = { nom: '', date: '', formateur: '' }; const stagiaire = { nom: '', date: '', formateur: '' };
// Configuration des boutons du footer par slide (modèle BioCID)
const slideConfig = {
1: { next: 2, nextLabel: 'Continuer →' },
2: { next: 3, nextLabel: 'Confirmer le choix →' },
3: { next: 4, nextLabel: 'Commencer le TP 1 →' },
4: { next: 5, nextLabel: 'Commencer →' },
5: { next: 6, nextLabel: 'Étape suivante →', hint: 'prevention' },
6: { next: 7, nextLabel: 'Étape suivante →', hint: 'intervention' },
7: { next: 8, nextLabel: 'Passer au TP 2 →', hint: 'evaluation' },
8: { next: 9, nextLabel: 'Commencer →' },
9: { next: 10, nextLabel: 'Partie suivante →', hint: 'fds' },
10: { next: 11, nextLabel: 'Passer au TP 3 →', hint: 'epi' },
11: { next: 12, nextLabel: 'Remplir le tableau →' },
12: { next: 13, nextLabel: 'Terminer la mission →', hint: 'analyse' }
};
const hints = { const hints = {
prevention: { prevention: {
icon: '💡', icon: '💡',
@@ -1250,11 +1234,28 @@ function goTo(n) {
currentSlide = n; currentSlide = n;
next.style.display = 'flex'; next.style.display = 'flex';
setTimeout(() => { next.classList.add('active', 'fade-in'); }, 10); setTimeout(() => { next.classList.add('active', 'fade-in'); }, 10);
const sa = next.querySelector('.scroll-area');
if (sa) sa.scrollTop = 0;
if (n === 13) setTimeout(launchConfetti, 200); if (n === 13) setTimeout(launchConfetti, 200);
updateUI(); updateUI();
updateCaseBanners(); updateCaseBanners();
} }
function goNext() {
const cfg = slideConfig[currentSlide];
if (!cfg || cfg.next == null) return;
// Slide 1 : validation du nom du groupe avant de continuer
if (currentSlide === 1) { confirmIdentite(); return; }
// Slide 2 : un cas doit être sélectionné
if (currentSlide === 2 && !selectedCase.key) return;
goTo(cfg.next);
}
function showHintCurrent() {
const cfg = slideConfig[currentSlide];
if (cfg && cfg.hint) showHint(cfg.hint);
}
function goBack() { function goBack() {
if (history.length === 0) return; if (history.length === 0) return;
const prev = history.pop(); const prev = history.pop();
@@ -1279,37 +1280,31 @@ function updateUI() {
if (bottomBar) bottomBar.style.display = currentSlide === 0 ? 'none' : 'flex'; if (bottomBar) bottomBar.style.display = currentSlide === 0 ? 'none' : 'flex';
const backBtn = document.getElementById('backBtn'); const backBtn = document.getElementById('backBtn');
backBtn.style.display = history.length > 0 ? 'block' : 'none'; backBtn.style.display = history.length > 0 ? 'inline-flex' : 'none';
const badge = document.getElementById('phaseBadge'); const cfg = slideConfig[currentSlide] || {};
if (currentSlide === 0 || currentSlide === 1 || currentSlide === 2 || currentSlide === 3 || currentSlide === 13) { const hintBtn = document.getElementById('footerHintBtn');
badge.style.display = 'none'; const nextBtn = document.getElementById('footerNextBtn');
} else if (currentSlide >= 4 && currentSlide <= 7) { hintBtn.style.display = cfg.hint ? 'inline-flex' : 'none';
badge.style.display = 'block'; if (cfg.next != null) {
badge.style.background = 'var(--amber-light)'; // Slide 2 : le bouton n'apparaît qu'une fois le cas choisi
badge.style.color = 'var(--amber-dark)'; const gated = currentSlide === 2 && !selectedCase.key;
badge.textContent = 'TP 1 — Protocole'; nextBtn.style.display = gated ? 'none' : 'inline-flex';
} else if (currentSlide >= 8 && currentSlide <= 10) { nextBtn.textContent = cfg.nextLabel;
badge.style.display = 'block'; } else {
badge.style.background = 'var(--coral-light)'; nextBtn.style.display = 'none';
badge.style.color = 'var(--coral)';
badge.textContent = 'TP 2 — Produit';
} else if (currentSlide >= 11 && currentSlide <= 12) {
badge.style.display = 'block';
badge.style.background = 'var(--amber-light)';
badge.style.color = 'var(--amber-dark)';
badge.textContent = 'TP 3 — Risques';
} }
} }
function selectCase(key, icon, title, desc) { function selectCase(key, icon, title, desc) {
marquerModifie();
selectedCase.key = key; selectedCase.key = key;
selectedCase.icon = icon; selectedCase.icon = icon;
selectedCase.title = title; selectedCase.title = title;
selectedCase.desc = desc; selectedCase.desc = desc;
document.querySelectorAll('.case-card').forEach(c => c.classList.remove('selected')); document.querySelectorAll('.case-card').forEach(c => c.classList.remove('selected'));
event.currentTarget.classList.add('selected'); event.currentTarget.classList.add('selected');
document.getElementById('btnConfirmCase').style.display = 'inline-flex'; updateUI();
} }
function updateCaseBanners() { function updateCaseBanners() {
@@ -1379,7 +1374,7 @@ function confirmIdentite() {
document.getElementById('idError').style.display = 'none'; document.getElementById('idError').style.display = 'none';
stagiaire.nom = nom; stagiaire.nom = nom;
stagiaire.date = date || new Date().toISOString().slice(0, 10); stagiaire.date = date || new Date().toISOString().slice(0, 10);
goTo(1); goTo(2);
} }
/* ======================== TABLEAU TP3 — LIGNES DYNAMIQUES ======================== */ /* ======================== TABLEAU TP3 — LIGNES DYNAMIQUES ======================== */
@@ -1606,6 +1601,156 @@ function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
/* ======================== SAUVEGARDE AUTOMATIQUE ======================== */
/* Les réponses sont enregistrées dans le navigateur au fil de la saisie, puis
proposées à la restauration au rechargement. En file://, localStorage peut être
indisponible (Safari) : dans ce cas la page fonctionne normalement, sans filet. */
const CLE_SAUVEGARDE = 'certibiocide:tp-nuisibles:v1';
let reponsesModifiees = false;
function marquerModifie() { reponsesModifiees = true; sauvegarder(); }
document.addEventListener('input', marquerModifie, true);
document.addEventListener('change', marquerModifie, true);
// Avertit avant tout rechargement ou départ. Le texte de la boîte est imposé
// par le navigateur, il n'est pas personnalisable.
window.addEventListener('beforeunload', e => {
if (!reponsesModifiees) return;
e.preventDefault();
e.returnValue = '';
});
function etatCourant() {
const champs = {};
document.querySelectorAll('input[id], textarea[id]').forEach(el => {
if (el.value) champs[el.id] = el.value;
});
// Les cellules du tableau TP3 n'ont pas d'id : on les repère par leur position.
const risques = Array.from(document.querySelectorAll('#riskTableBody .risk-row'))
.map(row => Array.from(row.querySelectorAll('.risk-cell')).map(c => c.value));
return {
savedAt: new Date().toISOString(),
slide: currentSlide,
champs,
risques,
cas: selectedCase.key || '',
formateur: stagiaire.formateur || '',
nom: stagiaire.nom || '',
date: stagiaire.date || '',
};
}
let minuteurSauvegarde = null;
function sauvegarder() {
clearTimeout(minuteurSauvegarde);
minuteurSauvegarde = setTimeout(() => {
try {
localStorage.setItem(CLE_SAUVEGARDE, JSON.stringify(etatCourant()));
} catch (e) { /* navigation privée ou quota : on continue sans sauvegarde */ }
}, 400);
}
function lireSauvegarde() {
try { return JSON.parse(localStorage.getItem(CLE_SAUVEGARDE)); }
catch (e) { return null; }
}
function effacerSauvegarde() {
try { localStorage.removeItem(CLE_SAUVEGARDE); } catch (e) {}
}
function appliquerSauvegarde(data) {
Object.entries(data.champs || {}).forEach(([id, v]) => {
const el = document.getElementById(id);
if (el) el.value = v;
});
// Recrée autant de lignes de risques que nécessaire avant de les remplir
const risques = data.risques || [];
const tbody = document.getElementById('riskTableBody');
while (tbody.querySelectorAll('.risk-row').length < risques.length) addRiskRow();
tbody.querySelectorAll('.risk-row').forEach((row, i) => {
const valeurs = risques[i] || [];
row.querySelectorAll('.risk-cell').forEach((cell, j) => { cell.value = valeurs[j] || ''; });
});
// Le cas pratique et le formateur passent par leurs boutons, qui remettent
// aussi l'interface (carte sélectionnée, bouton de confirmation) dans le bon état.
if (data.cas) {
const carte = Array.from(document.querySelectorAll('.case-card'))
.find(c => (c.getAttribute('onclick') || '').includes("'" + data.cas + "'"));
if (carte) carte.click();
}
if (data.formateur) {
const btn = document.getElementById('btn' + data.formateur);
if (btn) btn.click();
}
stagiaire.nom = data.nom || stagiaire.nom;
stagiaire.date = data.date || stagiaire.date;
reponsesModifiees = true;
if (typeof data.slide === 'number' && data.slide > 0) goTo(data.slide);
updateUI();
}
/* Bandeau de restauration : on ne restaure jamais sans demander, pour qu'un
nouveau groupe n'hérite pas des réponses du précédent sur un poste partagé. */
function injecterStylesBandeau() {
const style = document.createElement('style');
style.textContent = `
.restore-bar {
position: fixed; top: 84px; left: 50%; transform: translateX(-50%);
z-index: 900; max-width: min(680px, calc(100vw - 32px));
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
background: var(--white); border: 1.5px solid var(--amber);
border-radius: 14px; padding: 14px 18px;
box-shadow: 0 8px 28px rgba(0,0,0,0.12); font-size: 13.5px;
}
.restore-bar .restore-text { flex: 1; min-width: 220px; line-height: 1.5; }
.restore-bar .restore-actions { display: flex; gap: 8px; flex-shrink: 0; }
.restore-bar button {
font-family: 'Syne', sans-serif; font-weight: 700; font-size: 12.5px;
padding: 8px 16px; border-radius: 20px; cursor: pointer;
border: 1px solid var(--border); background: transparent; color: var(--text-muted);
}
.restore-bar button:hover { border-color: var(--amber); color: var(--amber); }
.restore-bar button.primary {
background: var(--amber); border-color: var(--amber); color: #fff;
}
.restore-bar button.primary:hover { background: var(--amber-dark); border-color: var(--amber-dark); color: #fff; }
`;
document.head.appendChild(style);
}
function proposerRestauration() {
const data = lireSauvegarde();
if (!data) return;
const aDesReponses = (data.champs && Object.keys(data.champs).length > 0)
|| (data.risques || []).some(r => r.some(v => v));
if (!aDesReponses) return;
const quand = new Date(data.savedAt);
const libelle = isNaN(quand.getTime()) ? '' :
` du ${quand.toLocaleDateString('fr-FR')} à ${quand.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
injecterStylesBandeau();
const bar = document.createElement('div');
bar.className = 'restore-bar';
bar.innerHTML = `
<span class="restore-text">💾 Des réponses enregistrées${libelle} ont été retrouvées sur ce poste.</span>
<span class="restore-actions">
<button type="button" class="primary" id="btnRestaurer">Restaurer</button>
<button type="button" id="btnRepartir">Repartir de zéro</button>
</span>`;
document.body.appendChild(bar);
document.getElementById('btnRestaurer').onclick = () => { appliquerSauvegarde(data); bar.remove(); };
document.getElementById('btnRepartir').onclick = () => { effacerSauvegarde(); bar.remove(); };
}
proposerRestauration();
updateUI(); updateUI();
</script> </script>
</body> </body>

View File

@@ -1,525 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Correction BioCID — Certibiocide</title>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../header-style.css"/>
<style>
body {
padding: calc(36px + 70px) 20px 80px;
}
.wrap { max-width: 820px; margin: 0 auto; }
.syne { font-family: 'Syne', sans-serif; }
.eyebrow {
font-family: 'Syne', sans-serif;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--amber);
margin-bottom: 6px;
}
h1 {
font-family: 'Syne', sans-serif;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.5px;
margin-bottom: 28px;
}
/* DROPZONE */
#dropzone {
background: #fff;
border: 2px dashed rgba(186,117,23,0.35);
border-radius: 16px;
padding: 56px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
#filePicker { display: none; }
.btn-pick {
font-family: 'Syne', sans-serif;
display: inline-block;
background: var(--amber);
color: #fff;
font-weight: 700;
font-size: 13px;
padding: 10px 26px;
border-radius: 30px;
cursor: pointer;
border: none;
}
#status { margin-top: 16px; font-size: 13px; }
#status.loading { color: var(--amber); }
#status.error { color: var(--coral); }
/* RESULT */
#result { display: none; }
.toprow {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
#fileName { font-size: 12.5px; color: var(--text-muted); }
.btn-reset {
font-family: 'Syne', sans-serif;
background: transparent;
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
color: var(--text-muted);
}
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
/* BANDEAU PARTICIPANTS */
#participantsBanner {
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
border-radius: 16px;
padding: 20px 26px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 16px;
color: #fff;
flex-wrap: wrap;
}
#participantsBanner .pb-icon {
width: 48px; height: 48px;
border-radius: 12px;
background: rgba(255,255,255,0.2);
display: flex; align-items: center; justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
#participantsBanner .pb-group {
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.85;
margin-bottom: 4px;
}
#participantsBanner .pb-names {
font-family: 'Syne', sans-serif;
font-size: 18px;
font-weight: 700;
}
#participantsBanner .pb-meta {
margin-left: auto;
font-size: 12.5px;
text-align: right;
opacity: 0.92;
line-height: 1.5;
}
.progress-row { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-size: 13px; color: var(--text-muted); }
.progress-track { flex: 1; height: 7px; background: rgba(0,0,0,0.08); border-radius: 6px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--amber); transition: width 0.4s ease; }
.progress-row strong { font-family: 'Syne', sans-serif; color: var(--text); }
/* CARROUSEL DE THEMES */
.carousel-wrap { margin-bottom: 36px; }
.carousel-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.nav-btn {
width: 38px; height: 38px;
border-radius: 50%;
border: 1px solid var(--border);
background: #fff;
font-size: 16px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, border-color 0.15s;
}
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
.nav-btn:disabled { opacity: 0.3; cursor: default; }
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
.dots { display: flex; gap: 7px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
.dot.active { background: var(--amber); transform: scale(1.3); }
.theme-panel {
background: #fff;
border: 1px solid var(--border);
border-radius: 18px;
padding: 26px 28px;
min-height: 360px;
}
.theme-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.theme-icon {
width: 44px; height: 44px;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
.cards { display: flex; flex-direction: column; gap: 10px; }
.qcard {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 18px;
display: flex;
gap: 12px;
align-items: flex-start;
transition: box-shadow 0.2s;
}
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
.qcard .badge {
width: 22px; height: 22px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px;
flex-shrink: 0;
margin-top: 1px;
}
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
.qcard .badge.miss { background: var(--coral-light); color: var(--coral); }
.qcard .qtext { flex: 1; }
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
.qcard .qanswer { font-size: 13.5px; white-space: pre-wrap; }
.qcard .qanswer.empty { color: var(--coral); font-style: italic; }
</style>
</head>
<body>
<header>
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
<nav>
<a href="../index.html">Accueil</a>
<a href="../index.html#formations">Exercices</a>
<a href="../index.html#documentation">Documentation</a>
<a href="../index.html#rapports">Rapports</a>
<a href="../index.html#about">À propos</a>
<a href="../index.html#contact">Contact</a>
</nav>
</header>
<div class="wrap">
<div id="participantsBanner" style="display:none;"></div>
<div class="eyebrow">Certibiocide — Correction d'exercice</div>
<h1>Plateforme BioCID — Lecture du rendu</h1>
<div id="dropzone">
<div class="icon">📄</div>
<p class="title">Déposez le PDF du groupe ici</p>
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis l'exercice BioCID</p>
<label class="btn-pick">Choisir un fichier
<input type="file" id="filePicker" accept="application/pdf">
</label>
<div id="status"></div>
</div>
<div id="result">
<div class="toprow">
<span id="fileName"></span>
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
</div>
<div class="progress-row">
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
<span><strong id="progressCount"></strong> questions renseignées</span>
</div>
<div class="carousel-wrap">
<div class="carousel-nav">
<button class="nav-btn" id="prevBtn" onclick="goTo(currentTheme - 1)"></button>
<div class="dots" id="dots"></div>
<button class="nav-btn" id="nextBtn" onclick="goTo(currentTheme + 1)"></button>
</div>
<div id="themePanel"></div>
</div>
</div>
</div>
<footer>
<p>&copy; 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
// Les 4 exercices, avec les intitulés exacts exportés (data-question) comme clés.
const THEMES = [
{
title: "Exercice 1 — Recherche par nom",
icon: "🔎", color: "amber",
items: [
"Ex1 — Produit : type, substance active, fabricant",
"Ex1 — Date d'autorisation",
"Ex1 — Conditions d'utilisation",
]
},
{
title: "Exercice 2 — Recherche par type",
icon: "🗂️", color: "blue",
items: [
"Ex2 — Nombre de produits dans la catégorie",
"Ex2 — Comparaison entre catégories",
]
},
{
title: "Exercice 3 — Recherche par substance",
icon: "🧪", color: "teal",
items: [
"Ex3 — 3 produits contenant la substance",
"Ex3 — Formulations et concentrations comparées",
]
},
{
title: "Exercice 4 — Cas de synthèse",
icon: "📞", color: "coral",
items: [
"Ex4 — 3 produits applicables",
"Ex4 — Fiche de chaque produit",
"Ex4 — Argumentaire (+ bonus FDS)",
]
},
];
const ALL_SECTIONS = THEMES.flatMap(t => t.items);
const COLOR_MAP = {
amber: { bg: "var(--amber-light)", fg: "var(--amber-dark)" },
blue: { bg: "var(--blue-light)", fg: "var(--blue)" },
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
};
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function escapeHtml(s) {
if (!s) return "";
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function isEmpty(v) {
if (!v) return true;
const t = v.trim();
return t === "" || /^\(sans réponse\)/i.test(t);
}
function captureBetween(text, label, nextLabels) {
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
const m = startRx.exec(text);
if (!m) return "";
const from = m.index + m[0].length;
let to = text.length;
for (const nl of nextLabels) {
const endRx = new RegExp(escapeRx(nl), "i");
const m2 = endRx.exec(text.slice(from));
if (m2 && from + m2.index < to) to = from + m2.index;
}
return text.slice(from, to).trim();
}
function parsePdfText(raw) {
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
// Retire les en-têtes/pieds d'impression du navigateur (date, titre, about:blank) sans toucher au corps
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4},?\s+\d{1,2}:\d{2}/g, " ");
clean = clean.replace(/Exercices BioCID\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ");
clean = clean.replace(/about:blank\s*\d{1,3}\s*\/\s*\d{1,3}/gi, " ");
clean = clean.replace(/about:blank/gi, " ");
clean = clean.replace(/\s+\d{1,3}\/\d{1,3}\s*$/, " ");
clean = clean.replace(/\s+/g, " ").trim();
const meta = {
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
formateur: captureBetween(clean, "Formateur", ["Réponses aux exercices"]),
};
// Sépare "Groupe N" du nom des participants
const groupMatch = /^Groupe\s*(\d+)\s*[—\-:]*\s*(.*)$/i.exec(meta.nom.trim());
if (groupMatch) {
meta.groupe = "Groupe " + groupMatch[1];
meta.participants = groupMatch[2].trim();
} else {
meta.groupe = "";
meta.participants = meta.nom.trim();
}
const startIdx = clean.search(/Réponses aux exercices/i);
const block = startIdx >= 0 ? clean.slice(startIdx) : clean;
const answers = {};
for (let i = 0; i < ALL_SECTIONS.length; i++) {
const label = ALL_SECTIONS[i];
const nextLabel = ALL_SECTIONS[i + 1];
answers[label] = captureBetween(block, label, nextLabel ? [nextLabel] : []);
}
return { meta, answers };
}
let currentThemeData = null;
let currentTheme = 0;
function renderThemePanel() {
const theme = THEMES[currentTheme];
const data = currentThemeData;
const colors = COLOR_MAP[theme.color];
const themeAnswered = theme.items.filter(s => !isEmpty(data.answers[s])).length;
document.getElementById("themePanel").innerHTML = `
<div class="theme-panel">
<div class="theme-head">
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${theme.icon}</div>
<div class="theme-title">${theme.title}</div>
<div class="theme-count">${themeAnswered}/${theme.items.length}</div>
</div>
<div class="cards">
${theme.items.map(label => {
const value = data.answers[label] || "";
const empty = isEmpty(value);
const displayLabel = label.replace(/^Ex\d+\s*[—-]\s*/, "");
return `
<div class="qcard">
<div class="badge ${empty ? 'miss' : 'ok'}">${empty ? '!' : '✓'}</div>
<div class="qtext">
<div class="qlabel">${escapeHtml(displayLabel)}</div>
<div class="qanswer ${empty ? 'empty' : ''}">${empty ? 'Sans réponse' : escapeHtml(value)}</div>
</div>
</div>
`;
}).join("")}
</div>
</div>
`;
document.getElementById("prevBtn").disabled = currentTheme === 0;
document.getElementById("nextBtn").disabled = currentTheme === THEMES.length - 1;
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentTheme));
}
function goTo(idx) {
if (idx < 0 || idx >= THEMES.length) return;
currentTheme = idx;
renderThemePanel();
}
function renderResult(data) {
currentThemeData = data;
currentTheme = 0;
const banner = document.getElementById("participantsBanner");
const noms = data.meta.participants || data.meta.nom;
if (noms) {
banner.style.display = "flex";
banner.innerHTML = `
<div class="pb-icon">👥</div>
<div>
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
<div class="pb-names">${escapeHtml(noms)}</div>
</div>
<div class="pb-meta">
${data.meta.date_session ? "Session : " + escapeHtml(data.meta.date_session) + "<br>" : ""}
${data.meta.formateur ? "Formateur : " + escapeHtml(data.meta.formateur) : ""}
</div>
`;
} else {
banner.style.display = "none";
}
const answeredCount = ALL_SECTIONS.filter(s => !isEmpty(data.answers[s])).length;
document.getElementById("progressFill").style.width = (answeredCount / ALL_SECTIONS.length * 100) + "%";
document.getElementById("progressCount").textContent = `${answeredCount} / ${ALL_SECTIONS.length}`;
document.getElementById("dots").innerHTML = THEMES.map((_, i) =>
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
).join("");
renderThemePanel();
document.getElementById("dropzone").style.display = "none";
document.getElementById("result").style.display = "block";
}
async function handleFile(file) {
if (!file) return;
const statusEl = document.getElementById("status");
statusEl.className = "loading";
statusEl.textContent = "Lecture du PDF en cours…";
document.getElementById("fileName").textContent = file.name;
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
let fullText = "";
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p);
const content = await page.getTextContent();
fullText += content.items.map(it => it.str).join(" ") + "\n";
}
const data = parsePdfText(fullText);
renderResult(data);
statusEl.textContent = "";
} catch (e) {
console.error(e);
statusEl.className = "error";
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export de l'exercice BioCID.";
}
}
function resetAll() {
document.getElementById("result").style.display = "none";
document.getElementById("dropzone").style.display = "block";
document.getElementById("participantsBanner").style.display = "none";
document.getElementById("status").textContent = "";
document.getElementById("filePicker").value = "";
}
const dropzone = document.getElementById("dropzone");
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
dropzone.addEventListener("drop", e => {
e.preventDefault();
dropzone.classList.remove("drag");
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
});
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
// Chargement automatique depuis la page « Corriger un exercice » (?auto=1)
(function autoLoadFromHub() {
const params = new URLSearchParams(location.search);
if (params.get("auto") !== "1") return;
const dataUrl = localStorage.getItem("correctionFile");
const name = localStorage.getItem("correctionFileName") || "rendu.pdf";
localStorage.removeItem("correctionFile");
localStorage.removeItem("correctionFileName");
localStorage.removeItem("correctionExo");
if (!dataUrl) return;
try {
const bin = atob(dataUrl.split(",").pop());
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
handleFile(new File([bytes], name, { type: "application/pdf" }));
} catch (e) { console.error(e); }
})();
</script>
</body>
</html>

View File

@@ -1,622 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Correction FDS, FT & Étiquettes — Certibiocide</title>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../header-style.css"/>
<style>
body {
padding: calc(36px + 70px) 20px 80px;
}
.wrap { max-width: 820px; margin: 0 auto; }
.syne { font-family: 'Syne', sans-serif; }
.eyebrow {
font-family: 'Syne', sans-serif;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--amber);
margin-bottom: 6px;
}
h1 {
font-family: 'Syne', sans-serif;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.5px;
margin-bottom: 28px;
}
/* DROPZONE */
#dropzone {
background: #fff;
border: 2px dashed rgba(186,117,23,0.35);
border-radius: 16px;
padding: 56px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
#filePicker { display: none; }
.btn-pick {
font-family: 'Syne', sans-serif;
display: inline-block;
background: var(--amber);
color: #fff;
font-weight: 700;
font-size: 13px;
padding: 10px 26px;
border-radius: 30px;
cursor: pointer;
border: none;
}
#status { margin-top: 16px; font-size: 13px; }
#status.loading { color: var(--amber); }
#status.error { color: var(--coral); }
/* RESULT */
#result { display: none; }
.toprow {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
#fileName { font-size: 12.5px; color: var(--text-muted); }
.btn-reset {
font-family: 'Syne', sans-serif;
background: transparent;
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
color: var(--text-muted);
}
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
/* BANDEAU PARTICIPANTS */
#participantsBanner {
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
border-radius: 16px;
padding: 20px 26px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 16px;
color: #fff;
flex-wrap: wrap;
}
#participantsBanner .pb-icon {
width: 48px; height: 48px;
border-radius: 12px;
background: rgba(255,255,255,0.2);
display: flex; align-items: center; justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
#participantsBanner .pb-group {
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.85;
margin-bottom: 4px;
}
#participantsBanner .pb-names {
font-family: 'Syne', sans-serif;
font-size: 18px;
font-weight: 700;
}
#participantsBanner .pb-meta {
margin-left: auto;
font-size: 12.5px;
text-align: right;
opacity: 0.92;
line-height: 1.5;
}
/* SCORE GLOBAL */
.score-card {
background: #fff;
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px 24px;
margin-bottom: 26px;
display: flex;
align-items: center;
gap: 22px;
flex-wrap: wrap;
}
.score-ring {
width: 84px; height: 84px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
flex-direction: column;
flex-shrink: 0;
color: #fff;
font-family: 'Syne', sans-serif;
}
.score-ring .score-pct { font-size: 22px; font-weight: 800; line-height: 1; }
.score-ring .score-lbl { font-size: 9px; letter-spacing: 0.06em; opacity: 0.9; }
.score-detail .score-main { font-family: 'Syne', sans-serif; font-size: 18px; font-weight: 800; }
.score-detail .score-sub { font-size: 13px; color: var(--text-muted); margin-top: 3px; }
.score-parts { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap; }
.score-pill {
font-size: 12px;
padding: 6px 12px;
border-radius: 20px;
background: var(--bg);
border: 1px solid var(--border);
font-family: 'Syne', sans-serif;
font-weight: 600;
}
/* CARROUSEL DE PARTIES */
.carousel-wrap { margin-bottom: 36px; }
.carousel-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.nav-btn {
width: 38px; height: 38px;
border-radius: 50%;
border: 1px solid var(--border);
background: #fff;
font-size: 16px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, border-color 0.15s;
}
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
.nav-btn:disabled { opacity: 0.3; cursor: default; }
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
.dots { display: flex; gap: 7px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
.dot.active { background: var(--amber); transform: scale(1.3); }
.theme-panel {
background: #fff;
border: 1px solid var(--border);
border-radius: 18px;
padding: 26px 28px;
min-height: 360px;
}
.theme-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.theme-icon {
width: 44px; height: 44px;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
.cards { display: flex; flex-direction: column; gap: 10px; }
.qcard {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 18px;
display: flex;
gap: 12px;
align-items: flex-start;
transition: box-shadow 0.2s;
}
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
.qcard .badge {
width: 22px; height: 22px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px;
flex-shrink: 0;
margin-top: 1px;
}
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
.qcard .badge.ko { background: var(--coral-light); color: var(--coral); }
.qcard .badge.miss { background: rgba(0,0,0,0.06); color: var(--text-muted); }
.qcard .qtext { flex: 1; }
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 5px; }
.qcard .qgroup { font-size: 13px; }
.qcard .qgroup.ok { color: var(--teal); }
.qcard .qgroup.ko { color: var(--coral); }
.qcard .qgroup.miss { color: var(--text-muted); font-style: italic; }
.qcard .qexpected { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
</style>
</head>
<body>
<header>
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
<nav>
<a href="../index.html">Accueil</a>
<a href="../index.html#formations">Exercices</a>
<a href="../index.html#documentation">Documentation</a>
<a href="../index.html#rapports">Rapports</a>
<a href="../index.html#about">À propos</a>
<a href="../index.html#contact">Contact</a>
</nav>
</header>
<div class="wrap">
<div id="participantsBanner" style="display:none;"></div>
<div class="eyebrow">Certibiocide — Correction de TD</div>
<h1>FDS, FT & Étiquettes — Correction du rendu</h1>
<div id="dropzone">
<div class="icon">📄</div>
<p class="title">Déposez le PDF du groupe ici</p>
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis le TD FDS, FT &amp; Étiquettes</p>
<label class="btn-pick">Choisir un fichier
<input type="file" id="filePicker" accept="application/pdf">
</label>
<div id="status"></div>
</div>
<div id="result">
<div class="toprow">
<span id="fileName"></span>
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
</div>
<div class="score-card" id="scoreCard"></div>
<div class="carousel-wrap">
<div class="carousel-nav">
<button class="nav-btn" id="prevBtn" onclick="goTo(currentPart - 1)"></button>
<div class="dots" id="dots"></div>
<button class="nav-btn" id="nextBtn" onclick="goTo(currentPart + 1)"></button>
</div>
<div id="themePanel"></div>
</div>
</div>
</div>
<footer>
<p>&copy; 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
/* ==== Clé de correction (identique à l'exercice) ==== */
const fdsQuestions = [
{ id: 1, text: "Quel est le nom du produit et qui est le fabricant ?", answer: "1" },
{ id: 2, text: "Quels sont les dangers du produit ?", answer: "2" },
{ id: 3, text: "Quelles sont les molécules actives et leurs concentrations ?", answer: "3" },
{ id: 4, text: "Quels gestes adopter en premiers secours ?", answer: "4" },
{ id: 5, text: "En cas d'incendie, quels sont les moyens d'extinction conseillés ?", answer: "5" },
{ id: 6, text: "Que faire en cas de dispersion accidentelle ?", answer: "6" },
{ id: 7, text: "Dans quelles conditions stocker mon produit ?", answer: "7" },
{ id: 8, text: "Quels équipements de protection individuelle (EPI) dois-je porter ?", answer: "8" },
{ id: 9, text: "Quel est le pH de ce produit ?", answer: "9" },
{ id: 10, text: "Le produit est-il stable dans des conditions normales ?", answer: "10" },
{ id: 11, text: "Quels sont les effets potentiels sur la santé ?", answer: "11" },
{ id: 12, text: "Quel est le degré de toxicité pour les poissons ? Potentiel de bioaccumulation ?", answer: "12" },
{ id: 13, text: "Comment éliminer le produit ?", answer: "13" },
{ id: 14, text: "Est-ce que mon produit est classé dangereux pour le transport ?", answer: "14" },
{ id: 15, text: "En cas d'ingestion du produit, que faire ?", answer: "4,11" },
{ id: 16, text: "Où trouver le mode d'emploi du produit ?", answer: "absent" }
];
const ftQuestions = [
{ id: 1, text: "Quel est le nom du produit et qui est le fabricant ?", answer: "Description" },
{ id: 2, text: "Sur quelles matières ce produit est-il compatible ?", answer: "Description" },
{ id: 3, text: "Quel est (sont) le(s) type(s) de ce produit ?", answer: "Propriétés biocides" },
{ id: 4, text: "Quels sont les types de conditionnements disponibles ?", answer: "Conditionnements" },
{ id: 5, text: "Où retrouver les différents modes d'utilisation de ce produit ?", answer: "Utilisations" }
];
const etiquetteQuestions = [
{ id: 1, text: "Quel est le nom du produit et le nom de l'entreprise ?", answer: "Identité" },
{ id: 2, text: "Quels pictogrammes de danger sont affichés ?", answer: "Pictogrammes" },
{ id: 3, text: "Quels sont les mots d'avertissement (« Attention » ou « Danger ») ?", answer: "Mots d'avertissement" },
{ id: 4, text: "Quelles sont les mentions de danger (H-statements) identifiées ?", answer: "Mentions de danger" },
{ id: 5, text: "Quels équipements de protection individuelle (EPI) sont recommandés ?", answer: "Conseils de prudence" },
{ id: 6, text: "Quels premiers secours sont indiqués ?", answer: "Conseils de prudence" },
{ id: 7, text: "Comment éliminer l'emballage après utilisation ?", answer: "Conseils de prudence" },
{ id: 8, text: "Quel est le contenu net du produit ?", answer: "Contenu net" },
{ id: 9, text: "Y a-t-il un numéro d'appel d'urgence mentionné ?", answer: "Numéro d'urgence" },
{ id: 10, text: "Quelles consignes de stockage sont affichées ?", answer: "Conseils de prudence" }
];
const PARTS = [
{ prefix: "P1", title: "Partie 1 — FDS", icon: "📄", color: "amber", questions: fdsQuestions, kind: "fds" },
{ prefix: "P2", title: "Partie 2 — Fiche Technique", icon: "📋", color: "coral", questions: ftQuestions, kind: "text" },
{ prefix: "P3", title: "Partie 3 — Étiquette", icon: "🏷️", color: "teal", questions: etiquetteQuestions, kind: "text" },
];
const COLOR_MAP = {
amber: { bg: "var(--amber-light)", fg: "var(--amber-dark)" },
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
};
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function escapeHtml(s) {
if (!s) return "";
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function captureBetween(text, label, nextLabels) {
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
const m = startRx.exec(text);
if (!m) return "";
const from = m.index + m[0].length;
let to = text.length;
for (const nl of nextLabels) {
const endRx = new RegExp(escapeRx(nl), "i");
const m2 = endRx.exec(text.slice(from));
if (m2 && from + m2.index < to) to = from + m2.index;
}
return text.slice(from, to).trim();
}
// Extrait la réponse du groupe pour un marqueur [Px-Qn]
function extractSelected(fullText, marker, nextMarker) {
const seg = captureBetween(fullText, marker, nextMarker ? [nextMarker] : []);
const m = /Réponse du groupe\s*:?\s*([\s\S]*?)\s*[—–-]*\s*Attendu\s*:/i.exec(seg);
return m ? m[1].trim() : "";
}
/* ==== Correction ==== */
function normFdsToken(sel) {
if (!sel || /non répondu/i.test(sel)) return null;
if (/^absent$/i.test(sel.trim())) return "absent";
const m = /(\d+)/.exec(sel);
return m ? m[1] : "";
}
function scoreFds(q, sel) {
const tok = normFdsToken(sel);
if (tok === null) return { state: "miss" };
return { state: q.answer.split(",").includes(tok) ? "ok" : "ko" };
}
function scoreText(q, sel) {
if (!sel || /non répondu/i.test(sel)) return { state: "miss" };
const norm = /^absent$/i.test(sel.trim()) ? "absent" : sel.trim();
return { state: norm.toLowerCase() === q.answer.toLowerCase() ? "ok" : "ko" };
}
function expectedLabel(part, q) {
if (part.kind === "fds") {
const parts = q.answer.split(",");
if (parts.length > 1) return "Rubrique " + parts.join(" ou ");
return parts[0] === "absent" ? "Absent" : "Rubrique " + parts[0];
}
return q.answer;
}
function parsePdfText(raw) {
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
// Nettoie les en-têtes/pieds d'impression du navigateur sans toucher au corps du document
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4},?\s+\d{1,2}:\d{2}/g, " ");
clean = clean.replace(/FDS, FT (&|&amp;) Étiquettes\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ");
clean = clean.replace(/about:blank\s*\d{1,3}\s*\/\s*\d{1,3}/gi, " ");
clean = clean.replace(/about:blank/gi, " ");
clean = clean.replace(/\s+/g, " ").trim();
const meta = {
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
formateur: captureBetween(clean, "Formateur", ["Score global"]),
};
const groupMatch = /^Groupe\s*(\d+)\s*[—\-:]*\s*(.*)$/i.exec(meta.nom.trim());
if (groupMatch) {
meta.groupe = "Groupe " + groupMatch[1];
meta.participants = groupMatch[2].trim();
} else {
meta.groupe = "";
meta.participants = meta.nom.trim();
}
// Liste ordonnée de tous les marqueurs pour borner chaque extraction
const markers = [];
PARTS.forEach(p => p.questions.forEach((q, i) => markers.push("[" + p.prefix + "-Q" + (i + 1) + "]")));
const selected = {};
let mIdx = 0;
PARTS.forEach(p => {
p.questions.forEach((q, i) => {
const marker = "[" + p.prefix + "-Q" + (i + 1) + "]";
const next = markers[mIdx + 1];
selected[marker] = extractSelected(clean, marker, next);
mIdx++;
});
});
return { meta, selected };
}
let currentData = null;
let currentPart = 0;
function partResults(part) {
return part.questions.map((q, i) => {
const marker = "[" + part.prefix + "-Q" + (i + 1) + "]";
const sel = (currentData.selected[marker] || "").trim();
const res = part.kind === "fds" ? scoreFds(q, sel) : scoreText(q, sel);
return { q, i, sel, state: res.state };
});
}
function renderPart() {
const part = PARTS[currentPart];
const colors = COLOR_MAP[part.color];
const results = partResults(part);
const ok = results.filter(r => r.state === "ok").length;
document.getElementById("themePanel").innerHTML = `
<div class="theme-panel">
<div class="theme-head">
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${part.icon}</div>
<div class="theme-title">${part.title}</div>
<div class="theme-count">${ok}/${results.length} corrects</div>
</div>
<div class="cards">
${results.map(r => {
const badge = r.state === "ok" ? "ok" : (r.state === "ko" ? "ko" : "miss");
const badgeIcon = r.state === "ok" ? "✓" : (r.state === "ko" ? "✗" : "?");
const groupTxt = r.state === "miss" ? "Sans réponse" : escapeHtml(r.sel);
const showExpected = r.state !== "ok";
return `
<div class="qcard">
<div class="badge ${badge}">${badgeIcon}</div>
<div class="qtext">
<div class="qlabel">Q${r.i + 1}. ${escapeHtml(r.q.text)}</div>
<div class="qgroup ${badge}">Réponse du groupe : ${groupTxt}</div>
${showExpected ? `<div class="qexpected">Attendu : ${escapeHtml(expectedLabel(part, r.q))}</div>` : ""}
</div>
</div>
`;
}).join("")}
</div>
</div>
`;
document.getElementById("prevBtn").disabled = currentPart === 0;
document.getElementById("nextBtn").disabled = currentPart === PARTS.length - 1;
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentPart));
}
function goTo(idx) {
if (idx < 0 || idx >= PARTS.length) return;
currentPart = idx;
renderPart();
}
function renderResult(data) {
currentData = data;
currentPart = 0;
const banner = document.getElementById("participantsBanner");
const noms = data.meta.participants || data.meta.nom;
if (noms) {
banner.style.display = "flex";
banner.innerHTML = `
<div class="pb-icon">👥</div>
<div>
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
<div class="pb-names">${escapeHtml(noms)}</div>
</div>
<div class="pb-meta">
${data.meta.date_session ? "Session : " + escapeHtml(data.meta.date_session) + "<br>" : ""}
${data.meta.formateur ? "Formateur : " + escapeHtml(data.meta.formateur) : ""}
</div>
`;
} else {
banner.style.display = "none";
}
// Score global + par partie
let totalOk = 0, totalNb = 0;
const pillHtml = PARTS.map(p => {
const results = partResults(p);
const ok = results.filter(r => r.state === "ok").length;
totalOk += ok; totalNb += results.length;
return `<div class="score-pill">${p.icon} ${ok}/${results.length}</div>`;
}).join("");
const pct = totalNb ? Math.round((totalOk / totalNb) * 100) : 0;
const ringColor = pct >= 80 ? "var(--teal-mid)" : (pct >= 60 ? "var(--amber)" : "var(--coral-mid)");
const verdict = pct >= 80 ? "Très bon rendu" : (pct >= 60 ? "Rendu correct" : "À revoir en priorité");
document.getElementById("scoreCard").innerHTML = `
<div class="score-ring" style="background:${ringColor};">
<div class="score-pct">${pct}%</div>
<div class="score-lbl">RÉUSSITE</div>
</div>
<div class="score-detail">
<div class="score-main">${totalOk} / ${totalNb} bonnes réponses</div>
<div class="score-sub">${verdict}</div>
</div>
<div class="score-parts">${pillHtml}</div>
`;
document.getElementById("dots").innerHTML = PARTS.map((_, i) =>
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
).join("");
renderPart();
document.getElementById("dropzone").style.display = "none";
document.getElementById("result").style.display = "block";
}
async function handleFile(file) {
if (!file) return;
const statusEl = document.getElementById("status");
statusEl.className = "loading";
statusEl.textContent = "Lecture du PDF en cours…";
document.getElementById("fileName").textContent = file.name;
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
let fullText = "";
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p);
const content = await page.getTextContent();
fullText += content.items.map(it => it.str).join(" ") + "\n";
}
const data = parsePdfText(fullText);
renderResult(data);
statusEl.textContent = "";
} catch (e) {
console.error(e);
statusEl.className = "error";
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export du TD FDS, FT & Étiquettes.";
}
}
function resetAll() {
document.getElementById("result").style.display = "none";
document.getElementById("dropzone").style.display = "block";
document.getElementById("participantsBanner").style.display = "none";
document.getElementById("status").textContent = "";
document.getElementById("filePicker").value = "";
}
const dropzone = document.getElementById("dropzone");
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
dropzone.addEventListener("drop", e => {
e.preventDefault();
dropzone.classList.remove("drag");
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
});
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
// Chargement automatique depuis la page « Corriger un exercice » (?auto=1)
(function autoLoadFromHub() {
const params = new URLSearchParams(location.search);
if (params.get("auto") !== "1") return;
const dataUrl = localStorage.getItem("correctionFile");
const name = localStorage.getItem("correctionFileName") || "rendu.pdf";
localStorage.removeItem("correctionFile");
localStorage.removeItem("correctionFileName");
localStorage.removeItem("correctionExo");
if (!dataUrl) return;
try {
const bin = atob(dataUrl.split(",").pop());
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
handleFile(new File([bytes], name, { type: "application/pdf" }));
} catch (e) { console.error(e); }
})();
</script>
</body>
</html>

View File

@@ -1,668 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Correction TP Nuisibles — Certibiocide</title>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../header-style.css"/>
<style>
body {
padding: calc(36px + 70px) 20px 80px;
}
.wrap { max-width: 820px; margin: 0 auto; }
.syne { font-family: 'Syne', sans-serif; }
.eyebrow {
font-family: 'Syne', sans-serif;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--amber);
margin-bottom: 6px;
}
h1 {
font-family: 'Syne', sans-serif;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.5px;
margin-bottom: 28px;
}
/* DROPZONE */
#dropzone {
background: #fff;
border: 2px dashed rgba(186,117,23,0.35);
border-radius: 16px;
padding: 56px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
#filePicker { display: none; }
.btn-pick {
font-family: 'Syne', sans-serif;
display: inline-block;
background: var(--amber);
color: #fff;
font-weight: 700;
font-size: 13px;
padding: 10px 26px;
border-radius: 30px;
cursor: pointer;
border: none;
}
#status { margin-top: 16px; font-size: 13px; }
#status.loading { color: var(--amber); }
#status.error { color: var(--coral); }
/* RESULT */
#result { display: none; }
.toprow {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
#fileName { font-size: 12.5px; color: var(--text-muted); }
.btn-reset {
font-family: 'Syne', sans-serif;
background: transparent;
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
color: var(--text-muted);
}
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
.id-card { display: none; }
/* BANDEAU PARTICIPANTS */
#participantsBanner {
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
border-radius: 16px;
padding: 20px 26px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 16px;
color: #fff;
}
#participantsBanner .pb-icon {
width: 48px; height: 48px;
border-radius: 12px;
background: rgba(255,255,255,0.2);
display: flex; align-items: center; justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
#participantsBanner .pb-group {
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.85;
margin-bottom: 4px;
}
#participantsBanner .pb-names {
font-family: 'Syne', sans-serif;
font-size: 18px;
font-weight: 700;
}
.id-card .label {
font-family: 'Syne', sans-serif;
font-size: 10.5px;
font-weight: 700;
color: var(--amber-dark);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.id-card .value { font-size: 14.5px; margin-top: 2px; }
.progress-row { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-size: 13px; color: var(--text-muted); }
.progress-track { flex: 1; height: 7px; background: rgba(0,0,0,0.08); border-radius: 6px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--teal); transition: width 0.4s ease; }
.progress-row strong { font-family: 'Syne', sans-serif; color: var(--text); }
/* CARROUSEL DE THEMES */
.carousel-wrap { margin-bottom: 36px; }
.carousel-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.nav-btn {
width: 38px; height: 38px;
border-radius: 50%;
border: 1px solid var(--border);
background: #fff;
font-size: 16px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, border-color 0.15s;
}
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
.nav-btn:disabled { opacity: 0.3; cursor: default; }
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
.dots { display: flex; gap: 7px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
.dot.active { background: var(--amber); transform: scale(1.3); }
.theme-panel {
background: #fff;
border: 1px solid var(--border);
border-radius: 18px;
padding: 26px 28px;
min-height: 420px;
}
.theme-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.theme-icon {
width: 44px; height: 44px;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
.cards { display: flex; flex-direction: column; gap: 10px; }
.qcard {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 18px;
display: flex;
gap: 12px;
align-items: flex-start;
transition: box-shadow 0.2s;
}
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
.qcard .badge {
width: 22px; height: 22px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px;
flex-shrink: 0;
margin-top: 1px;
}
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
.qcard .badge.miss { background: var(--coral-light); color: var(--coral); }
.qcard .qtext { flex: 1; }
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
.qcard .qanswer { font-size: 13.5px; white-space: pre-wrap; }
.qcard .qanswer.empty { color: var(--coral); font-style: italic; }
.table-wrap {
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 18px;
font-size: 13px;
margin-bottom: 14px;
}
/* TABLEAU ANALYSE DE RISQUES */
.risk-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.risk-table th {
font-family: 'Syne', sans-serif;
text-align: left;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--amber-dark);
background: var(--amber-light);
padding: 10px 12px;
}
.risk-table th:first-child { border-radius: 8px 0 0 8px; }
.risk-table th:last-child { border-radius: 0 8px 8px 0; }
.risk-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: top;
white-space: pre-wrap;
}
.risk-table tr:last-child td { border-bottom: none; }
.meta-line { font-size: 12.5px; color: var(--text-muted); margin-top: 20px; }
.meta-line strong { color: var(--text); }
</style>
</head>
<body>
<header>
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
<nav>
<a href="../index.html">Accueil</a>
<a href="../index.html#formations">Exercices</a>
<a href="../index.html#documentation">Documentation</a>
<a href="../index.html#rapports">Rapports</a>
<a href="../index.html#about">À propos</a>
<a href="../index.html#contact">Contact</a>
</nav>
</header>
<div class="wrap">
<div id="participantsBanner" style="display:none;"></div>
<div class="eyebrow">Certibiocide — Correction de TP</div>
<h1>TP Nuisibles — Lecture du rendu</h1>
<div id="dropzone">
<div class="icon">📄</div>
<p class="title">Déposez le PDF du stagiaire ici</p>
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis le TP</p>
<label class="btn-pick">Choisir un fichier
<input type="file" id="filePicker" accept="application/pdf">
</label>
<div id="status"></div>
</div>
<div id="result">
<div class="toprow">
<span id="fileName"></span>
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
</div>
<div class="id-card" id="idCard"></div>
<div class="progress-row">
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
<span><strong id="progressCount"></strong> questions répondues</span>
</div>
<div class="carousel-wrap">
<div class="carousel-nav">
<button class="nav-btn" id="prevBtn" onclick="goTo(currentTheme - 1)"></button>
<div class="dots" id="dots"></div>
<button class="nav-btn" id="nextBtn" onclick="goTo(currentTheme + 1)"></button>
</div>
<div id="themePanel"></div>
</div>
<h2 class="syne" style="font-size:16px;font-weight:800;margin-bottom:12px;color:var(--amber-dark);">Analyse de risques</h2>
<div class="table-wrap" id="tp3Meta"></div>
<div class="table-wrap" id="tp3Table" style="display:none;"></div>
<p class="meta-line" id="suiviLine"></p>
</div>
</div>
<footer>
<p>&copy; 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
// Thèmes regroupant les sections du TP, avec icône et couleur
const THEMES = [
{
title: "Premier contact & diagnostic",
icon: "🗣️", color: "purple",
items: [
"Questions posées au client lors du premier contact",
"Conseils donnés en premier lieu",
"Actions de prévention menées sur le site",
"Éléments observés / preuve de présence du nuisible",
]
},
{
title: "Solutions envisagées",
icon: "🧰", color: "blue",
items: [
"Solutions alternatives non-biocides",
"Solutions biocides mises en place",
"Type de produit (TP) et mode de traitement",
"Suivi mis en place et durée",
"Gestion des déchets",
]
},
{
title: "Produit & traçabilité",
icon: "🧪", color: "teal",
items: [
"FDS trouvée (éléments principaux)",
"Nom du produit et fabricant/fournisseur",
"Numéro AMM",
]
},
{
title: "Sécurité & santé",
icon: "🛡️", color: "coral",
items: [
"Risques pour la santé",
"EPI à porter",
"Risques environnement / dispersion accidentelle",
"DL50 substance(s) active(s)",
"Gestes de premiers secours",
]
},
];
const ALL_SECTIONS = THEMES.flatMap(t => t.items);
const COLOR_MAP = {
purple: { bg: "var(--purple-light)", fg: "var(--purple)" },
blue: { bg: "var(--blue-light)", fg: "var(--blue)" },
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
};
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function escapeHtml(s) {
if (!s) return "";
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function isEmpty(v) {
if (!v) return true;
const t = v.trim();
return t === "" || /^\(sans réponse\)$/i.test(t);
}
function captureBetween(text, label, nextLabels) {
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
const m = startRx.exec(text);
if (!m) return "";
const from = m.index + m[0].length;
let to = text.length;
for (const nl of nextLabels) {
const endRx = new RegExp(escapeRx(nl), "i");
const m2 = endRx.exec(text.slice(from));
if (m2 && from + m2.index < to) to = from + m2.index;
}
return text.slice(from, to).trim();
}
function parsePdfText(raw) {
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
// Retire les répétitions d'en-tête de page (ex: "17/06/2026 15:42 Résultats TP — ... about:blank 1/2")
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4}\s+\d{1,2}:\d{2}\s+Résultats TP[^]*?about:blank\s*\d\/\d/gi, " ");
const meta = {
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
formateur: captureBetween(clean, "Formateur", ["Cas pratique"]),
cas: captureBetween(clean, "Cas pratique", ["Réponses TP1"]),
};
// Sépare "Groupe N" du nom des participants (ex: "Groupe 1 Romain Kevin Valere Greg P")
const groupMatch = /^Groupe\s*(\d+)\s*(.*)$/i.exec(meta.nom.trim());
if (groupMatch) {
meta.groupe = "Groupe " + groupMatch[1];
meta.participants = groupMatch[2].trim();
} else {
meta.groupe = "";
meta.participants = meta.nom.trim();
}
const startIdx = clean.search(/Réponses TP1\s*&?\s*TP2/i);
const endIdx = clean.search(/TP3\s*[—-]\s*Analyse de risques/i);
const block = startIdx >= 0 ? clean.slice(startIdx, endIdx >= 0 ? endIdx : clean.length) : clean;
const answers = {};
for (let i = 0; i < ALL_SECTIONS.length; i++) {
const label = ALL_SECTIONS[i];
const nextLabel = ALL_SECTIONS[i + 1];
answers[label] = captureBetween(block, label, nextLabel ? [nextLabel] : []);
}
const tp3Zone = endIdx >= 0 ? clean.slice(endIdx) : "";
const tp3 = {
intitule: captureBetween(tp3Zone, "Intitulé", ["Description"]),
description: captureBetween(tp3Zone, "Description", ["Lieu"]),
lieu: captureBetween(tp3Zone, "Lieu", ["Début prévu le"]),
debut: captureBetween(tp3Zone, "Début prévu le", ["Étapes"]),
};
// Tableau de risques : on découpe en s'appuyant sur les intitulés d'étapes connus du TP
const KNOWN_STEPS = [
"remplir la poudreuse",
"sécurisation du site",
"application traitements",
"rangement du matériel. purger la perche",
"retrait du nid",
"gestion des déchets. élimination par des filiaires spé.",
];
let riskRows = [];
const tableMatch = /Étapes\s*Risques\s*Mesures de prévention([\s\S]*?)(Réalisée par|$)/i.exec(tp3Zone);
if (tableMatch) {
let raw = tableMatch[1].trim();
if (raw) {
// Repère les positions de chaque étape connue dans le texte aplati
const positions = [];
KNOWN_STEPS.forEach(step => {
const idx = raw.toLowerCase().indexOf(step.toLowerCase());
if (idx >= 0) positions.push({ step, idx });
});
positions.sort((a, b) => a.idx - b.idx);
if (positions.length > 0) {
for (let i = 0; i < positions.length; i++) {
const from = positions[i].idx + positions[i].step.length;
const to = i + 1 < positions.length ? positions[i + 1].idx : raw.length;
let rest = raw.slice(from, to).trim();
// rest contient "risque... mesure..." -> on coupe sur les mots clés de mesures (gants/masques, barrieres, etc.)
const measureRx = /(gants\s*\/\s*masques\s*\/\s*lunettes[^.]*|barrieres\.?\s*epi)/i;
const mm = measureRx.exec(rest);
let risque = rest, mesure = "";
if (mm) {
risque = rest.slice(0, mm.index).trim();
mesure = rest.slice(mm.index).trim();
}
risque = risque.replace(/^[\s\-.]+/, "").trim();
riskRows.push({ etape: positions[i].step, risque: risque || "—", mesure: mesure || "—" });
}
} else {
riskRows.push({ etape: raw, risque: "", mesure: "" });
}
}
}
const mReal = clean.match(/Réalisée par\s*:\s*([^—\n]*)/i);
const mVerif = clean.match(/Vérifiée par\s*:\s*([^—\n]*)/i);
const mMaj = clean.match(/Mise à jour par\s*:\s*([^—\n]*)/i);
return {
meta, answers, tp3, riskRows,
suivi: {
realise: mReal ? mReal[1].trim() : "",
verifie: mVerif ? mVerif[1].trim() : "",
maj: mMaj ? mMaj[1].trim() : "",
}
};
}
let currentThemeData = null;
let currentTheme = 0;
function renderThemePanel() {
const theme = THEMES[currentTheme];
const data = currentThemeData;
const colors = COLOR_MAP[theme.color];
const themeAnswered = theme.items.filter(s => !isEmpty(data.answers[s])).length;
document.getElementById("themePanel").innerHTML = `
<div class="theme-panel">
<div class="theme-head">
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${theme.icon}</div>
<div class="theme-title">${theme.title}</div>
<div class="theme-count">${themeAnswered}/${theme.items.length}</div>
</div>
<div class="cards">
${theme.items.map(label => {
const value = data.answers[label] || "";
const empty = isEmpty(value);
return `
<div class="qcard">
<div class="badge ${empty ? 'miss' : 'ok'}">${empty ? '!' : '✓'}</div>
<div class="qtext">
<div class="qlabel">${escapeHtml(label)}</div>
<div class="qanswer ${empty ? 'empty' : ''}">${empty ? 'Sans réponse' : escapeHtml(value)}</div>
</div>
</div>
`;
}).join("")}
</div>
</div>
`;
document.getElementById("prevBtn").disabled = currentTheme === 0;
document.getElementById("nextBtn").disabled = currentTheme === THEMES.length - 1;
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentTheme));
}
function goTo(idx) {
if (idx < 0 || idx >= THEMES.length) return;
currentTheme = idx;
renderThemePanel();
}
function renderResult(data) {
currentThemeData = data;
currentTheme = 0;
const banner = document.getElementById("participantsBanner");
if (data.meta.participants) {
banner.style.display = "flex";
banner.innerHTML = `
<div class="pb-icon">👥</div>
<div>
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
<div class="pb-names">${escapeHtml(data.meta.participants)}</div>
</div>
`;
} else {
banner.style.display = "none";
}
document.getElementById("idCard").innerHTML = `
<div><div class="label">Groupe / Stagiaire</div><div class="value">${escapeHtml(data.meta.nom) || "—"}</div></div>
<div><div class="label">Date de session</div><div class="value">${escapeHtml(data.meta.date_session) || "—"}</div></div>
<div><div class="label">Formateur</div><div class="value">${escapeHtml(data.meta.formateur) || "—"}</div></div>
<div><div class="label">Cas pratique</div><div class="value">${escapeHtml(data.meta.cas) || "—"}</div></div>
`;
const answeredCount = ALL_SECTIONS.filter(s => !isEmpty(data.answers[s])).length;
document.getElementById("progressFill").style.width = (answeredCount / ALL_SECTIONS.length * 100) + "%";
document.getElementById("progressCount").textContent = `${answeredCount} / ${ALL_SECTIONS.length}`;
document.getElementById("dots").innerHTML = THEMES.map((_, i) =>
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
).join("");
renderThemePanel();
document.getElementById("tp3Meta").innerHTML = `
<p style="margin-bottom:6px;"><strong>Intitulé :</strong> ${escapeHtml(data.tp3.intitule) || '<em style="color:var(--coral)">—</em>'}</p>
<p style="margin-bottom:6px;"><strong>Description :</strong> ${escapeHtml(data.tp3.description) || '<em style="color:var(--coral)">—</em>'}</p>
<p style="margin-bottom:6px;"><strong>Lieu :</strong> ${escapeHtml(data.tp3.lieu) || '<em style="color:var(--coral)">—</em>'}</p>
<p><strong>Début prévu le :</strong> ${escapeHtml(data.tp3.debut) || '<em style="color:var(--coral)">—</em>'}</p>
`;
const tp3TableEl = document.getElementById("tp3Table");
if (data.riskRows && data.riskRows.length > 0) {
tp3TableEl.style.display = "block";
tp3TableEl.innerHTML = `
<table class="risk-table">
<thead><tr><th>Étapes</th><th>Risques</th><th>Mesures de prévention</th></tr></thead>
<tbody>
${data.riskRows.map(r => `
<tr>
<td>${escapeHtml(r.etape) || "—"}</td>
<td>${escapeHtml(r.risque) || "—"}</td>
<td>${escapeHtml(r.mesure) || "—"}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
} else {
tp3TableEl.style.display = "none";
}
document.getElementById("suiviLine").innerHTML =
`Réalisée par : <strong>${escapeHtml(data.suivi.realise) || "—"}</strong> — Vérifiée par : <strong>${escapeHtml(data.suivi.verifie) || "—"}</strong> — Mise à jour par : <strong>${escapeHtml(data.suivi.maj) || "—"}</strong>`;
document.getElementById("dropzone").style.display = "none";
document.getElementById("result").style.display = "block";
}
async function handleFile(file) {
if (!file) return;
const statusEl = document.getElementById("status");
statusEl.className = "loading";
statusEl.textContent = "Lecture du PDF en cours…";
document.getElementById("fileName").textContent = file.name;
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
let fullText = "";
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p);
const content = await page.getTextContent();
fullText += content.items.map(it => it.str).join(" ") + "\n";
}
const data = parsePdfText(fullText);
renderResult(data);
statusEl.textContent = "";
} catch (e) {
console.error(e);
statusEl.className = "error";
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export du TP.";
}
}
function resetAll() {
document.getElementById("result").style.display = "none";
document.getElementById("dropzone").style.display = "block";
document.getElementById("participantsBanner").style.display = "none";
document.getElementById("status").textContent = "";
document.getElementById("filePicker").value = "";
document.getElementById("tp3Table").style.display = "none";
}
const dropzone = document.getElementById("dropzone");
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
dropzone.addEventListener("drop", e => {
e.preventDefault();
dropzone.classList.remove("drag");
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
});
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
// Chargement automatique depuis la page « Corriger un exercice » (?auto=1)
(function autoLoadFromHub() {
const params = new URLSearchParams(location.search);
if (params.get("auto") !== "1") return;
const dataUrl = localStorage.getItem("correctionFile");
const name = localStorage.getItem("correctionFileName") || "rendu.pdf";
localStorage.removeItem("correctionFile");
localStorage.removeItem("correctionFileName");
localStorage.removeItem("correctionExo");
if (!dataUrl) return;
try {
const bin = atob(dataUrl.split(",").pop());
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
handleFile(new File([bytes], name, { type: "application/pdf" }));
} catch (e) { console.error(e); }
})();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
# API du classement Memory (ASP.NET + SQL Server)
Petit service **ASP.NET (C#) + SQL Server** qui héberge en ligne le
**classement partagé** du jeu Memory. Tous les joueurs enregistrent leurs
scores au même endroit et voient le même top 10 par mode (facile / normal /
difficile).
> Adapté à l'hébergement **FreeASPHosting.net / bsite.net** (Windows, IIS,
> base MSSQL 2016). Le fichier `scores.ashx` est compilé au vol par IIS :
> **aucune étape de build**, il suffit de le déposer.
## Contenu
| Fichier | Rôle |
|---|---|
| `scores.ashx` | Point d'entrée de l'API (GET pour lire, POST pour enregistrer). Crée la table automatiquement au premier appel. |
| `web.config.example` | Modèle de configuration à copier en `web.config`. |
| `web.config` | **À créer sur le serveur** avec le mot de passe SQL Server (clé `MemoryDb`). Non versionné. |
| `README.md` | Ce fichier. |
## Installation sur bsite.net
1. **Récupérer le mot de passe** de la base dans le panneau
*Manage Databases*. La base existe déjà :
- Serveur : `sql.bsite.net\MSSQL2016`
- Base / utilisateur : `nextgnformation_reponses_exercices`
2. **Créer `web.config`** à partir du modèle (`cp web.config.example web.config`),
puis remplacer `VOTRE_MOT_DE_PASSE` par le mot de passe réel dans la clé
`MemoryDb`.
3. **Déposer le dossier `api/`** via *My Files*, au même endroit que
`memory.html` (chemin attendu par le jeu :
`exercices/memory-pictogrammes/api/scores.ashx`).
4. C'est prêt : au premier score enregistré (ou premier appel GET), la table
`memory_scores` est créée automatiquement.
## Vérification rapide
- Lecture du classement :
`GET https://nextgnformation.bsite.net/exercices/memory-pictogrammes/api/scores.ashx?difficulty=all`
doit renvoyer `{"ok":true,"scores":[...]}`.
- Seuls les 10 meilleurs de chaque mode sont conservés : après chaque
enregistrement, les scores hors top 10 du mode concerné sont supprimés.
## Points d'attention
- **Ne poussez jamais `web.config` avec le vrai mot de passe dans un dépôt
public.** Éditez-le directement sur le serveur via *My Files*.
- Les statistiques personnelles (« Vos statistiques ») restent stockées
localement dans le navigateur de chaque joueur ; seul le classement est en ligne.
- Le score étant calculé côté navigateur, l'API valide les types et les bornes
mais ne garantit pas l'absence de triche (recalcul serveur possible plus tard).

View File

@@ -0,0 +1,329 @@
<%@ WebHandler Language="C#" Class="ScoresHandler" %>
/*
* API du classement Memory (pictogrammes de sécurité) — ASP.NET + SQL Server.
*
* GET scores.ashx?difficulty=easy|normal|hard|all[&limit=10]
* -> renvoie le classement (top N) trié par score décroissant.
*
* POST scores.ashx (corps JSON)
* { playerName, userId, score, moves, timeSeconds, difficulty, isPerfect }
* -> enregistre le score, ne conserve que le top N par difficulté,
* renvoie { ok, saved, rank, isTop }.
*
* Seul le top N (défaut 10) de chaque mode est conservé : les scores plus
* faibles sont supprimés après chaque insertion.
*
* Fichier compilé au vol par IIS : il suffit de le déposer, aucune build.
* La chaîne de connexion est lue dans web.config (clé "MemoryDb").
*/
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.IO;
using System.Text;
using System.Web;
using System.Web.Configuration;
using System.Web.Script.Serialization;
public class ScoresHandler : IHttpHandler
{
static readonly string[] Difficulties = { "easy", "normal", "hard" };
const int TopN = 10;
public bool IsReusable { get { return false; } }
public void ProcessRequest(HttpContext ctx)
{
ctx.Response.ContentType = "application/json; charset=utf-8";
ctx.Response.AddHeader("X-Content-Type-Options", "nosniff");
var connString = WebConfigurationManager.ConnectionStrings["MemoryDb"] != null
? WebConfigurationManager.ConnectionStrings["MemoryDb"].ConnectionString
: null;
if (string.IsNullOrEmpty(connString))
{
Fail(ctx, 500, "Configuration manquante (connectionString 'MemoryDb' dans web.config).");
return;
}
try
{
using (var conn = new SqlConnection(connString))
{
conn.Open();
EnsureTable(conn);
if (ctx.Request.HttpMethod == "GET")
{
HandleGet(ctx, conn);
}
else if (ctx.Request.HttpMethod == "POST")
{
HandlePost(ctx, conn);
}
else
{
ctx.Response.StatusCode = 405;
ctx.Response.AddHeader("Allow", "GET, POST");
Write(ctx, new Dictionary<string, object> {
{ "ok", false }, { "error", "Méthode non autorisée." }
});
}
}
}
catch (Exception)
{
Fail(ctx, 500, "Erreur serveur.");
}
}
// --- Création de la table si besoin -------------------------------------
void EnsureTable(SqlConnection conn)
{
const string sql =
"IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'memory_scores') " +
"CREATE TABLE memory_scores (" +
" id INT IDENTITY(1,1) PRIMARY KEY," +
" player_name NVARCHAR(40) NOT NULL," +
" user_id NVARCHAR(64) NOT NULL," +
" score INT NOT NULL," +
" moves INT NOT NULL," +
" time_seconds INT NOT NULL," +
" difficulty NVARCHAR(16) NOT NULL," +
" is_perfect BIT NOT NULL DEFAULT 0," +
" created_at DATETIME NOT NULL" +
");";
using (var cmd = new SqlCommand(sql, conn)) { cmd.ExecuteNonQuery(); }
}
// --- GET : renvoie le classement ----------------------------------------
void HandleGet(HttpContext ctx, SqlConnection conn)
{
var difficulty = (ctx.Request.QueryString["difficulty"] ?? "all").Trim();
int limit;
if (!int.TryParse(ctx.Request.QueryString["limit"], out limit)) limit = TopN;
if (limit < 1) limit = 1;
if (limit > TopN) limit = TopN;
var rows = new List<Dictionary<string, object>>();
if (difficulty == "all")
{
foreach (var diff in Difficulties)
rows.AddRange(FetchTop(conn, diff, TopN));
rows.Sort(CompareRows);
if (rows.Count > limit) rows = rows.GetRange(0, limit);
}
else
{
if (Array.IndexOf(Difficulties, difficulty) < 0)
{
Fail(ctx, 400, "Difficulté inconnue.");
return;
}
rows = FetchTop(conn, difficulty, limit);
}
Write(ctx, new Dictionary<string, object> { { "ok", true }, { "scores", rows } });
}
// --- POST : enregistre un score puis élague au top N --------------------
void HandlePost(HttpContext ctx, SqlConnection conn)
{
string body;
using (var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8))
body = reader.ReadToEnd();
Dictionary<string, object> data = null;
try { data = new JavaScriptSerializer().Deserialize<Dictionary<string, object>>(body); }
catch { data = null; }
if (data == null)
{
Fail(ctx, 400, "Corps JSON invalide.");
return;
}
var difficulty = Str(data, "difficulty");
if (Array.IndexOf(Difficulties, difficulty) < 0)
{
Fail(ctx, 400, "Difficulté inconnue.");
return;
}
// Nettoyage / bornage des entrées
var playerName = Str(data, "playerName").Trim();
playerName = StripControlChars(playerName);
if (playerName.Length == 0) playerName = "Anonyme";
if (playerName.Length > 40) playerName = playerName.Substring(0, 40);
var userId = OnlyIdChars(Str(data, "userId"));
if (userId.Length > 64) userId = userId.Substring(0, 64);
if (userId.Length == 0) userId = "anon";
int score = Clamp(Int(data, "score"), 0, 100000000);
int moves = Clamp(Int(data, "moves"), 0, 100000);
int time = Clamp(Int(data, "timeSeconds"), 0, 1000000);
int isPerfect = Bool(data, "isPerfect") ? 1 : 0;
int insertedId;
using (var cmd = new SqlCommand(
"INSERT INTO memory_scores " +
"(player_name, user_id, score, moves, time_seconds, difficulty, is_perfect, created_at) " +
"OUTPUT INSERTED.id " +
"VALUES (@name, @uid, @score, @moves, @time, @diff, @perfect, GETUTCDATE())", conn))
{
cmd.Parameters.Add("@name", SqlDbType.NVarChar, 40).Value = playerName;
cmd.Parameters.Add("@uid", SqlDbType.NVarChar, 64).Value = userId;
cmd.Parameters.Add("@score", SqlDbType.Int).Value = score;
cmd.Parameters.Add("@moves", SqlDbType.Int).Value = moves;
cmd.Parameters.Add("@time", SqlDbType.Int).Value = time;
cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty;
cmd.Parameters.Add("@perfect", SqlDbType.Bit).Value = isPerfect;
insertedId = (int)cmd.ExecuteScalar();
}
// Ne conserver que le top N pour ce mode
using (var cmd = new SqlCommand(
"WITH ranked AS (" +
" SELECT id, ROW_NUMBER() OVER (" +
" PARTITION BY difficulty ORDER BY score DESC, time_seconds ASC, id ASC) AS rn" +
" FROM memory_scores WHERE difficulty = @diff" +
") DELETE FROM memory_scores WHERE id IN (SELECT id FROM ranked WHERE rn > @topN)", conn))
{
cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty;
cmd.Parameters.Add("@topN", SqlDbType.Int).Value = TopN;
cmd.ExecuteNonQuery();
}
// Le score inséré a-t-il survécu à l'élagage ? Quel rang ?
var top = FetchTop(conn, difficulty, TopN);
int? rank = null;
for (int i = 0; i < top.Count; i++)
{
if ((int)top[i]["id"] == insertedId) { rank = i + 1; break; }
}
Write(ctx, new Dictionary<string, object> {
{ "ok", true },
{ "saved", true },
{ "isTop", rank.HasValue },
{ "rank", rank.HasValue ? (object)rank.Value : null }
});
}
// --- Accès données -------------------------------------------------------
List<Dictionary<string, object>> FetchTop(SqlConnection conn, string difficulty, int limit)
{
var result = new List<Dictionary<string, object>>();
using (var cmd = new SqlCommand(
"SELECT TOP (@limit) id, player_name, user_id, score, moves, time_seconds, " +
"difficulty, is_perfect, created_at " +
"FROM memory_scores WHERE difficulty = @diff " +
"ORDER BY score DESC, time_seconds ASC, id ASC", conn))
{
cmd.Parameters.Add("@limit", SqlDbType.Int).Value = limit;
cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty;
using (var r = cmd.ExecuteReader())
{
while (r.Read())
{
result.Add(new Dictionary<string, object> {
{ "id", (int)r["id"] },
{ "playerName", r["player_name"].ToString() },
{ "userId", r["user_id"].ToString() },
{ "score", (int)r["score"] },
{ "moves", (int)r["moves"] },
{ "timeSeconds", (int)r["time_seconds"] },
{ "difficulty", r["difficulty"].ToString() },
{ "isPerfect", Convert.ToBoolean(r["is_perfect"]) },
{ "date", ((DateTime)r["created_at"]).ToString("yyyy-MM-ddTHH:mm:ss") + "Z" }
});
}
}
}
return result;
}
int CompareRows(Dictionary<string, object> a, Dictionary<string, object> b)
{
int sa = (int)a["score"], sb = (int)b["score"];
if (sa != sb) return sb - sa;
return (int)a["timeSeconds"] - (int)b["timeSeconds"];
}
// --- Sérialisation / helpers --------------------------------------------
void Write(HttpContext ctx, object payload)
{
// On retire la clé "id" (interne) des lignes avant l'envoi.
var scrubbed = Scrub(payload);
ctx.Response.Write(new JavaScriptSerializer().Serialize(scrubbed));
}
object Scrub(object payload)
{
var dict = payload as Dictionary<string, object>;
if (dict != null && dict.ContainsKey("scores"))
{
var list = dict["scores"] as List<Dictionary<string, object>>;
if (list != null)
foreach (var row in list) row.Remove("id");
}
return payload;
}
void Fail(HttpContext ctx, int status, string message)
{
ctx.Response.StatusCode = status;
ctx.Response.Write(new JavaScriptSerializer().Serialize(
new Dictionary<string, object> { { "ok", false }, { "error", message } }));
}
static string Str(Dictionary<string, object> d, string key)
{
object v; return (d.TryGetValue(key, out v) && v != null) ? v.ToString() : "";
}
static int Int(Dictionary<string, object> d, string key)
{
object v; int n;
if (d.TryGetValue(key, out v) && v != null && int.TryParse(v.ToString(), out n)) return n;
return 0;
}
static bool Bool(Dictionary<string, object> d, string key)
{
object v;
if (!d.TryGetValue(key, out v) || v == null) return false;
if (v is bool) return (bool)v;
var s = v.ToString().ToLowerInvariant();
return s == "true" || s == "1";
}
static int Clamp(int value, int min, int max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
static string StripControlChars(string s)
{
var sb = new StringBuilder(s.Length);
foreach (var c in s) if (!char.IsControl(c)) sb.Append(c);
return sb.ToString();
}
static string OnlyIdChars(string s)
{
var sb = new StringBuilder(s.Length);
foreach (var c in s)
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-')
sb.Append(c);
return sb.ToString();
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!--
MODÈLE de configuration. Sur le serveur bsite.net :
1. Copiez ce fichier en « web.config ».
2. Renseignez le mot de passe fourni par le panneau « Manage Databases ».
Le vrai « web.config » n'est PAS versionné (voir .gitignore) afin de ne
jamais exposer le mot de passe dans l'historique Git.
-->
<connectionStrings>
<add name="MemoryDb"
connectionString="Server=sql.bsite.net\MSSQL2016;Database=nextgnformation_reponses_exercices;User Id=nextgnformation_reponses_exercices;Password=VOTRE_MOT_DE_PASSE;"
providerName="System.Data.SqlClient" />
</connectionStrings>
</configuration>

View File

@@ -93,6 +93,13 @@
overflow: hidden; overflow: hidden;
} }
/* Ligne du joueur courant dans le classement */
.rank-item.is-me {
border-left-color: var(--amber-dark);
background: linear-gradient(135deg, var(--amber-light), var(--white));
box-shadow: 0 2px 8px rgba(186, 117, 23, 0.15);
}
.rank-top { .rank-top {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,90 +1,114 @@
/* ============================================ /* ============================================
LEADERBOARD & PLAYER STATISTICS LEADERBOARD & PLAYER STATISTICS
============================================
Le CLASSEMENT est désormais partagé EN LIGNE : tous les joueurs
enregistrent leurs scores via l'API PHP (api/scores.php) et voient le
même top 10 par mode de jeu.
Les STATISTIQUES PERSONNELLES (« Vos statistiques ») restent stockées
localement dans le navigateur du joueur, car le serveur ne conserve que
le top 10 de chaque mode (et non l'historique complet de chacun).
============================================ */ ============================================ */
/** /**
* Manager du classement * Manager du classement
*/ */
class LeaderboardManager { class LeaderboardManager {
static KEY_LEADERBOARD = 'nextgn_leaderboard'; // Point d'entrée de l'API (relatif à memory.html) — ASP.NET + SQL Server
static MAX_SCORES = 100; static API_URL = 'api/scores.ashx';
// Historique local des parties du joueur (pour ses stats perso)
static KEY_HISTORY = 'nextgn_player_history';
static MAX_HISTORY = 500;
// Cache mémoire du dernier classement récupéré (par difficulté)
static _cache = {};
/** /**
* Ajouter un score au classement * Enregistrer une partie :
* - envoie le score au classement en ligne
* - conserve la partie dans l'historique local (stats perso)
* @returns {Promise<Object>} résultat de l'API ({ ok, isTop, rank } ou { ok:false })
*/ */
static addScore(gameData) { static async addScore(gameData) {
const scores = this.getAllScores(); const record = {
const newScore = {
sessionId: generateUUID(),
playerName: getPlayerName(), playerName: getPlayerName(),
userId: getUserId(), userId: getUserId(),
score: gameData.score, score: gameData.score,
moves: gameData.moves, moves: gameData.moves,
timeSeconds: gameData.duration, timeSeconds: gameData.duration,
difficulty: gameData.difficulty, difficulty: gameData.difficulty,
date: new Date().toISOString(),
isPerfect: gameData.moves === DIFFICULTY_MODES[gameData.difficulty].paires, isPerfect: gameData.moves === DIFFICULTY_MODES[gameData.difficulty].paires,
won: gameData.won !== false won: gameData.won !== false,
date: new Date().toISOString()
}; };
scores.push(newScore); // 1) Historique local (statistiques personnelles)
this._recordLocalHistory(record);
// Limiter à MAX_SCORES (garder les plus récents)
if (scores.length > this.MAX_SCORES) { // 2) Classement en ligne
scores.sort((a, b) => new Date(b.date) - new Date(a.date));
scores.splice(this.MAX_SCORES);
}
localStorage.setItem(this.KEY_LEADERBOARD, JSON.stringify(scores));
DEBUG.log('Score added:', newScore);
return newScore;
}
/**
* Obtenir tous les scores
*/
static getAllScores() {
try { try {
const scores = JSON.parse(localStorage.getItem(this.KEY_LEADERBOARD)) || []; const res = await fetch(this.API_URL, {
return scores; method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(record)
});
const data = await res.json();
DEBUG.log('Score submitted:', data);
return data;
} catch (e) {
console.error('Leaderboard submit error:', e);
return { ok: false, error: 'network' };
}
}
/**
* Ajouter la partie à l'historique local du joueur
*/
static _recordLocalHistory(record) {
const history = this._getLocalHistory();
history.push(record);
if (history.length > this.MAX_HISTORY) {
history.splice(0, history.length - this.MAX_HISTORY);
}
try {
localStorage.setItem(this.KEY_HISTORY, JSON.stringify(history));
} catch (e) {
console.error('History save error:', e);
}
}
/**
* Historique local des parties du joueur
*/
static _getLocalHistory() {
try {
return JSON.parse(localStorage.getItem(this.KEY_HISTORY)) || [];
} catch (e) { } catch (e) {
console.error('Leaderboard error:', e);
return []; return [];
} }
} }
/** /**
* Obtenir le classement (top X) * Récupérer le classement en ligne (top X) pour un mode donné.
* @returns {Promise<Array>}
*/ */
static getLeaderboard(difficulty = 'all', limit = 10) { static async getLeaderboard(difficulty = 'all', limit = 10) {
let scores = this.getAllScores(); const url = `${this.API_URL}?difficulty=${encodeURIComponent(difficulty)}&limit=${limit}`;
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
// Filtrer par difficulté const data = await res.json();
if (difficulty !== 'all') { if (!data || data.ok !== true || !Array.isArray(data.scores)) {
scores = scores.filter(s => s.difficulty === difficulty); throw new Error('Réponse invalide du serveur');
} }
this._cache[difficulty] = data.scores;
// Trier par score (décroissant) return data.scores;
scores.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
// En cas d'égalité, par temps
return a.timeSeconds - b.timeSeconds;
});
return scores.slice(0, limit);
} }
/** /**
* Obtenir les stats d'un joueur * Statistiques du joueur, calculées à partir de son historique LOCAL.
*/ */
static getPlayerStats(userId) { static getPlayerStats() {
const scores = this.getAllScores().filter(s => s.userId === userId); const scores = this._getLocalHistory();
if (scores.length === 0) { if (scores.length === 0) {
return { return {
totalGames: 0, totalGames: 0,
@@ -96,14 +120,14 @@ class LeaderboardManager {
perfectGames: 0 perfectGames: 0
}; };
} }
const bestScore = Math.max(...scores.map(s => s.score), 0); const bestScore = Math.max(...scores.map(s => s.score), 0);
const totalScore = scores.reduce((a, b) => a + b.score, 0); const totalScore = scores.reduce((a, b) => a + b.score, 0);
const totalTime = scores.reduce((a, b) => a + b.timeSeconds, 0); const totalTime = scores.reduce((a, b) => a + b.timeSeconds, 0);
const bestTime = Math.min(...scores.map(s => s.timeSeconds), Infinity); const bestTime = Math.min(...scores.map(s => s.timeSeconds), Infinity);
const perfectGames = scores.filter(s => s.isPerfect).length; const perfectGames = scores.filter(s => s.isPerfect).length;
const winRate = (perfectGames / scores.length) * 100; const winRate = (perfectGames / scores.length) * 100;
return { return {
totalGames: scores.length, totalGames: scores.length,
bestScore: bestScore, bestScore: bestScore,
@@ -114,63 +138,37 @@ class LeaderboardManager {
perfectGames: perfectGames perfectGames: perfectGames
}; };
} }
/** /**
* Obtenir le rang d'un score * Mettre à jour l'affichage du classement (asynchrone).
*/ */
static getPlayerRank(userId, difficulty = 'all') { static async updateDisplay(difficulty = 'all') {
const leaderboard = this.getLeaderboard(difficulty, Infinity);
const rank = leaderboard.findIndex(s => s.userId === userId) + 1;
return rank > 0 ? rank : null;
}
/**
* Vérifier si c'est un nouveau record personnel
*/
static isPersonalRecord(userId, newScore) {
const stats = this.getPlayerStats(userId);
return newScore > stats.bestScore;
}
/**
* Obtenir les statistiques globales
*/
static getGlobalStats() {
const scores = this.getAllScores();
if (scores.length === 0) {
return {
totalGames: 0,
totalPlayers: 0,
averageScore: 0,
highestScore: 0,
fastestCompletion: Infinity
};
}
const uniquePlayers = new Set(scores.map(s => s.userId));
const totalScore = scores.reduce((a, b) => a + b.score, 0);
const highestScore = Math.max(...scores.map(s => s.score), 0);
const fastestCompletion = Math.min(...scores.map(s => s.timeSeconds), Infinity);
return {
totalGames: scores.length,
totalPlayers: uniquePlayers.size,
averageScore: Math.round(totalScore / scores.length),
highestScore: highestScore,
fastestCompletion: fastestCompletion === Infinity ? 0 : fastestCompletion
};
}
/**
* Mettre à jour l'affichage du classement
*/
static updateDisplay(difficulty = 'all') {
const leaderboard = this.getLeaderboard(difficulty, 10);
const leaderboardList = document.getElementById('leaderboard-list'); const leaderboardList = document.getElementById('leaderboard-list');
if (!leaderboardList) return; if (!leaderboardList) return;
// État de chargement
leaderboardList.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⏳</div>
<div class="empty-state-text">Chargement du classement…</div>
</div>
`;
let leaderboard;
try {
leaderboard = await this.getLeaderboard(difficulty, 10);
} catch (e) {
console.error('Leaderboard load error:', e);
leaderboardList.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<div class="empty-state-text">Classement indisponible</div>
<div class="empty-state-hint">Vérifiez votre connexion et réessayez.</div>
</div>
`;
return;
}
if (leaderboard.length === 0) { if (leaderboard.length === 0) {
leaderboardList.innerHTML = ` leaderboardList.innerHTML = `
<div class="empty-state"> <div class="empty-state">
@@ -181,16 +179,20 @@ class LeaderboardManager {
`; `;
return; return;
} }
const myUserId = getUserId();
leaderboardList.innerHTML = ''; leaderboardList.innerHTML = '';
leaderboard.forEach((score, index) => { leaderboard.forEach((score, index) => {
const rankItem = document.createElement('div'); const rankItem = document.createElement('div');
rankItem.className = 'rank-item'; rankItem.className = 'rank-item';
if (score.userId === myUserId) {
rankItem.classList.add('is-me');
}
const rankNumber = this.getRankDisplay(index); const rankNumber = this.getRankDisplay(index);
const medal = index < 3 ? `class="rank-number medal-${index + 1}"` : 'class="rank-number"'; const medal = index < 3 ? `class="rank-number medal-${index + 1}"` : 'class="rank-number"';
rankItem.innerHTML = ` rankItem.innerHTML = `
<div class="rank-top"> <div class="rank-top">
<div ${medal}>${rankNumber}</div> <div ${medal}>${rankNumber}</div>
@@ -205,96 +207,53 @@ class LeaderboardManager {
</div> </div>
</div> </div>
`; `;
leaderboardList.appendChild(rankItem); leaderboardList.appendChild(rankItem);
}); });
analyticsManager.track('leaderboard_viewed', { filter: difficulty }); if (typeof analyticsManager !== 'undefined') {
analyticsManager.track('leaderboard_viewed', { filter: difficulty });
}
} }
/** /**
* Mettre à jour les statistiques du joueur * Mettre à jour les statistiques du joueur (locales).
*/ */
static updatePlayerStats() { static updatePlayerStats() {
const userId = getUserId(); const stats = this.getPlayerStats();
const stats = this.getPlayerStats(userId);
const totalGamesEl = document.getElementById('total-games');
// Remplir les éléments HTML const bestScoreEl = document.getElementById('best-score');
document.getElementById('total-games').textContent = stats.totalGames; const bestTimeEl = document.getElementById('best-time');
document.getElementById('best-score').textContent = formatNumber(stats.bestScore); const winRateEl = document.getElementById('win-rate');
document.getElementById('best-time').textContent = stats.bestTime === Infinity ? '--' : formatTime(stats.bestTime);
document.getElementById('win-rate').textContent = `${stats.winRate}%`; if (totalGamesEl) totalGamesEl.textContent = stats.totalGames;
if (bestScoreEl) bestScoreEl.textContent = formatNumber(stats.bestScore);
// Ajouter des classes pour animation si valeurs élevées if (bestTimeEl) bestTimeEl.textContent = stats.bestTime === Infinity || stats.bestTime === 0 ? '--' : formatTime(stats.bestTime);
if (stats.bestScore > 5000) { if (winRateEl) winRateEl.textContent = `${stats.winRate}%`;
document.getElementById('best-score').parentElement.classList.add('high-value');
if (bestScoreEl && stats.bestScore > 5000) {
bestScoreEl.parentElement.classList.add('high-value');
} }
if (winRateEl && stats.winRate >= 50) {
if (stats.winRate >= 50) { winRateEl.parentElement.classList.add('high-value');
document.getElementById('win-rate').parentElement.classList.add('high-value');
} }
} }
/** /**
* Afficher le rang du joueur * Effacer l'historique local du joueur (DEBUG).
* N'affecte PAS le classement en ligne partagé.
*/ */
static updatePlayerRank(difficulty = 'all') { static clearLocalHistory() {
const userId = getUserId(); if (confirm('Effacer VOS statistiques locales ? (le classement en ligne n\'est pas touché)')) {
const rank = this.getPlayerRank(userId, difficulty); localStorage.removeItem(this.KEY_HISTORY);
if (rank) {
const rankDisplay = document.getElementById('player-rank');
if (rankDisplay) {
rankDisplay.textContent = `Vous êtes #{rank}`;
}
}
}
/**
* Afficher les statistiques globales
*/
static displayGlobalStats() {
const globalStats = this.getGlobalStats();
DEBUG.log('Global Statistics:', globalStats);
return globalStats;
}
/**
* Nettoyer les scores (DEBUG)
*/
static clearAll() {
if (confirm('Êtes-vous ABSOLUMENT certain de vouloir supprimer TOUS les scores?')) {
localStorage.removeItem(this.KEY_LEADERBOARD);
this.updateDisplay();
this.updatePlayerStats(); this.updatePlayerStats();
DEBUG.log('✓ Tous les scores ont été supprimés'); DEBUG.log('✓ Historique local effacé');
return true; return true;
} }
return false; return false;
} }
/**
* Exporter les scores en CSV
*/
static exportCSV() {
const scores = this.getAllScores();
let csv = 'Rang,Joueur,Score,Difficulté,Temps (s),Mouvements,Parfait,Date\n';
scores.forEach((score, index) => {
csv += `${index + 1},"${score.playerName}",${score.score},"${score.difficulty}",${score.timeSeconds},${score.moves},${score.isPerfect ? 'Oui' : 'Non'},"${score.date}"\n`;
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `leaderboard-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}
/** /**
* Obtenir l'affichage du rang (numéro ou médaille) * Obtenir l'affichage du rang (numéro ou médaille)
*/ */
@@ -302,7 +261,7 @@ class LeaderboardManager {
const medals = ['🥇', '🥈', '🥉']; const medals = ['🥇', '🥈', '🥉'];
return medals[index] || `${index + 1}`; return medals[index] || `${index + 1}`;
} }
/** /**
* Échapper le HTML (sécurité) * Échapper le HTML (sécurité)
*/ */
@@ -313,78 +272,11 @@ class LeaderboardManager {
} }
} }
/**
* Statistiques avancées
*/
class AdvancedStats {
/**
* Obtenir la distribution des scores par difficulté
*/
static getDistributionByDifficulty() {
const scores = LeaderboardManager.getAllScores();
const distribution = {};
Object.keys(DIFFICULTY_MODES).forEach(difficulty => {
distribution[difficulty] = scores.filter(s => s.difficulty === difficulty).length;
});
return distribution;
}
/**
* Obtenir la tendance temporelle
*/
static getTimelineTrend(days = 7) {
const scores = LeaderboardManager.getAllScores();
const now = new Date();
const timeline = {};
for (let i = 0; i < days; i++) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().slice(0, 10);
timeline[dateStr] = scores.filter(s => s.date.slice(0, 10) === dateStr).length;
}
return timeline;
}
/**
* Obtenir la moyenne de temps par difficulté
*/
static getAverageTimeByDifficulty() {
const scores = LeaderboardManager.getAllScores();
const averages = {};
Object.keys(DIFFICULTY_MODES).forEach(difficulty => {
const diffScores = scores.filter(s => s.difficulty === difficulty);
if (diffScores.length > 0) {
const totalTime = diffScores.reduce((a, b) => a + b.timeSeconds, 0);
averages[difficulty] = Math.round(totalTime / diffScores.length);
}
});
return averages;
}
/**
* Taux de réussite global
*/
static getGlobalSuccessRate() {
const scores = LeaderboardManager.getAllScores();
if (scores.length === 0) return 0;
const perfectGames = scores.filter(s => s.isPerfect).length;
return Math.round((perfectGames / scores.length) * 100);
}
}
// ============================================ // ============================================
// INITIALISATION // INITIALISATION
// ============================================ // ============================================
// Exporter pour utilisation globale // Exporter pour utilisation globale
window.LeaderboardManager = LeaderboardManager; window.LeaderboardManager = LeaderboardManager;
window.AdvancedStats = AdvancedStats;
DEBUG.log('Leaderboard Manager initialized'); DEBUG.log('Leaderboard Manager initialized (online mode)');

View File

@@ -347,12 +347,16 @@ class MemoryGameEngine {
achievements: achievements.map(a => a.id) achievements: achievements.map(a => a.id)
}); });
// Sauvegarder score // Sauvegarder le score (classement en ligne) puis rafraîchir l'affichage
LeaderboardManager.addScore({ LeaderboardManager.addScore({
...gameData, ...gameData,
score: finalScore.totalScore score: finalScore.totalScore
}).then(() => {
const activeFilter = document.querySelector('.tab-btn.active')?.getAttribute('data-filter') || 'all';
LeaderboardManager.updateDisplay(activeFilter);
LeaderboardManager.updatePlayerStats();
}); });
// Afficher modal victoire // Afficher modal victoire
this.showVictoryModal(finalScore, achievements, isWon); this.showVictoryModal(finalScore, achievements, isWon);
} }

View File

@@ -292,10 +292,10 @@ function getRank(index) {
} }
/** /**
* Vérifier si le score est dans le top 10 * Vérifier si le score est dans le top 10 (classement en ligne, asynchrone)
*/ */
function isTopScore(score, difficulty = 'all') { async function isTopScore(score, difficulty = 'all') {
const scores = LeaderboardManager.getLeaderboard(difficulty, 10); const scores = await LeaderboardManager.getLeaderboard(difficulty, 10);
return scores.some(s => s.score === score.score && s.userId === score.userId); return scores.some(s => s.score === score.score && s.userId === score.userId);
} }
@@ -318,57 +318,25 @@ function formatDate(isoDate) {
} }
/** /**
* Obtenir les statistiques d'un joueur * Obtenir les statistiques locales du joueur
*/ */
function getPlayerStatistics(userId) { function getPlayerStatistics() {
const scores = LeaderboardManager.getAllScores().filter(s => s.userId === userId); return LeaderboardManager.getPlayerStats();
if (scores.length === 0) {
return {
totalGames: 0,
bestScore: 0,
averageScore: 0,
averageTime: 0,
bestTime: Infinity,
winRate: 0,
perfectGames: 0
};
}
const bestScore = Math.max(...scores.map(s => s.score), 0);
const totalScore = scores.reduce((a, b) => a + b.score, 0);
const totalTime = scores.reduce((a, b) => a + b.timeSeconds, 0);
const bestTime = Math.min(...scores.map(s => s.timeSeconds), Infinity);
const perfectGames = scores.filter(s => s.isPerfect).length;
const winRate = (perfectGames / scores.length) * 100;
return {
totalGames: scores.length,
bestScore: bestScore,
averageScore: Math.round(totalScore / scores.length),
averageTime: Math.round(totalTime / scores.length),
bestTime: bestTime === Infinity ? 0 : bestTime,
winRate: Math.round(winRate),
perfectGames: perfectGames
};
} }
/** /**
* Déboguer : afficher tous les scores en console * Déboguer : afficher l'historique local du joueur en console
*/ */
function debugScores() { function debugScores() {
console.table(LeaderboardManager.getAllScores()); console.table(LeaderboardManager._getLocalHistory());
} }
/** /**
* Déboguer : nettoyer tous les scores * Déboguer : effacer les statistiques locales du joueur
* (le classement en ligne partagé n'est pas affecté)
*/ */
function clearAllScores() { function clearAllScores() {
if (confirm('Êtes-vous sûr de vouloir supprimer tous les scores?')) { LeaderboardManager.clearLocalHistory();
localStorage.removeItem('nextgn_leaderboard');
console.log('✓ Tous les scores ont été supprimés');
location.reload();
}
} }
/** /**

View File

@@ -32,8 +32,8 @@
<div class="logo">Gauthier Chombart & Nathan Chauwin</div> <div class="logo">Gauthier Chombart & Nathan Chauwin</div>
<nav> <nav>
<a href="#formations">Exercices interactifs</a> <a href="#formations">Exercices interactifs</a>
<a href="#documentation">Documentation</a> <a href="#documentation" style="display:none;">Documentation</a>
<a href="#rapports">Rapports de veille</a> <a href="#rapports" style="display:none;">Rapports de veille</a>
<a href="#about">À propos</a> <a href="#about">À propos</a>
<a href="#contact">Contact</a> <a href="#contact">Contact</a>
</nav> </nav>
@@ -53,8 +53,8 @@
<div class="cta-group"> <div class="cta-group">
<a href="#formations" class="btn btn-primary">Suivre les exercices interactifs</a> <a href="#formations" class="btn btn-primary">Suivre les exercices interactifs</a>
<a href="#documentation" class="btn btn-secondary">Documentation</a> <a href="#documentation" class="btn btn-secondary" style="display:none;">Documentation</a>
<a href="#rapports" class="btn btn-secondary">Rapports de veille</a> <a href="#rapports" class="btn btn-secondary" style="display:none;">Rapports de veille</a>
</div> </div>
<div class="stats"> <div class="stats">
@@ -141,7 +141,7 @@
<!-- Correction : page unique pour tous les exercices --> <!-- Correction : page unique pour tous les exercices -->
<div class="formation-card"> <div class="formation-card">
<div class="formation-header"> <div class="formation-header">
<div class="formation-icon">✔️</div> <div class="formation-icon"></div>
<h3 class="formation-title">Corriger un exercice</h3> <h3 class="formation-title">Corriger un exercice</h3>
<p class="formation-subtitle">Correction des rendus</p> <p class="formation-subtitle">Correction des rendus</p>
</div> </div>
@@ -154,7 +154,7 @@
</section> </section>
<!-- DOCUMENTATION TECHNIQUE SECTION --> <!-- DOCUMENTATION TECHNIQUE SECTION -->
<section id="documentation" class="documentation-section"> <section id="documentation" class="documentation-section" style="display:none;">
<div class="section-header"> <div class="section-header">
<h2>Documentation technique et biologie des espèces</h2> <h2>Documentation technique et biologie des espèces</h2>
<p>Accédez à notre base de connaissances : études détaillées, analyses scientifiques et rapports techniques sur les nuisibles, les biocides et les enjeux réglementaires.</p> <p>Accédez à notre base de connaissances : études détaillées, analyses scientifiques et rapports techniques sur les nuisibles, les biocides et les enjeux réglementaires.</p>
@@ -191,7 +191,7 @@
</section> </section>
<!-- RAPPORTS DE VEILLE HEBDOMADAIRES --> <!-- RAPPORTS DE VEILLE HEBDOMADAIRES -->
<section id="rapports" class="rapports-section"> <section id="rapports" class="rapports-section" style="display:none;">
<div class="section-header"> <div class="section-header">
<h2>Rapports de veille hebdomadaires</h2> <h2>Rapports de veille hebdomadaires</h2>
<p>Suivez chaque semaine l'actualité réglementaire, les opportunités de marché et les tendances du secteur Certibiocide en France et en Europe.</p> <p>Suivez chaque semaine l'actualité réglementaire, les opportunités de marché et les tendances du secteur Certibiocide en France et en Europe.</p>

File diff suppressed because it is too large Load Diff