Ajout mode présentation

This commit is contained in:
Esteban committed 2026-08-28 00:04:48 +02:00
1 parent 4862d61ccd
commit 08875d3b5a
18 files changed
+1781 -270

No files matched your search

+181 -45
View File
@@ -14,6 +14,7 @@ require_once('../functions/listSchool.php');
require_once('../functions/listSkills.php');
require_once('../functions/listSettings.php');
require_once('../functions/listLogos.php');
require_once('../functions/presentationAuth.php');
// L'utilisateur doit être administrateur
@@ -62,6 +63,11 @@ $contactAddressLine1 = recupererSetting('contact_address_line1') ?? '20 Grande r
$contactAddressLine2 = recupererSetting('contact_address_line2') ?? '52500 Broncourt';
$contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
// Contenu markdown actuel du mode présentation (voir pages/presentation.php)
// Le code PIN, lui, n'est jamais relu ici : seul son hash est en base, impossible à
// retransformer en code lisible (voir functions/presentationAuth.php)
$presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
// Page du panneau d'administration
?>
@@ -79,6 +85,7 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<a href="#admin-experiences"><i class="fa-solid fa-briefcase"></i> Expériences</a>
<a href="#admin-ecoles"><i class="fa-solid fa-graduation-cap"></i> Écoles</a>
<a href="#admin-competences"><i class="fa-solid fa-code"></i> Compétences</a>
<a href="#admin-presentation"><i class="fa-solid fa-lock"></i> Mode présentation</a>
<a href="#admin-logos"><i class="fa-solid fa-images"></i> Logos</a>
<a href="#admin-contact"><i class="fa-solid fa-address-card"></i> Contact</a>
<a href="#admin-notification"><i class="fa-solid fa-bell"></i> Notification</a>
@@ -101,9 +108,13 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<form method="POST" enctype="multipart/form-data" action="<?php echo $projetAModifier ? '../scripts/updateProject.php' : '../scripts/addProject.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($projetAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $projetAModifier['id']; ?>">
<?php endif; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($projetAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $projetAModifier['id'] . '">';
}
?>
<label>Titre *</label>
<input type="text" name="title" required value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['title']) : ''; ?>">
@@ -124,16 +135,22 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon projet&#10;&#10;Description complète en **markdown**..."><?php echo $projetAModifier ? htmlspecialchars($projetAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($projetAModifier && !empty($projetAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($projetAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=projet&id=<?php echo (int) $projetAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<?php
// Si un PDF est déjà enregistré, on le rappelle avec un lien pour le consulter
// et une case à cocher pour le supprimer à l'enregistrement du formulaire.
if ($projetAModifier && !empty($projetAModifier['pdf_filename'])) {
$lienPdf = '../scripts/viewPdf.php?type=projet&id=' . (int) $projetAModifier['id'];
echo '<div class="pdf-current">';
echo '<i class="fa-solid fa-file-pdf"></i>';
echo '<span>' . htmlspecialchars($projetAModifier['pdf_filename']) . '</span>';
echo '<a href="' . $lienPdf . '" target="_blank" rel="noopener noreferrer">Voir</a>';
echo '<label class="checkbox-row pdf-remove-row">';
echo '<input type="checkbox" name="remove_pdf"> Supprimer ce PDF';
echo '</label>';
echo '</div>';
}
?>
<div class="pdf-dropzone" data-input="pdf-project">
<i class="fa-solid fa-cloud-arrow-up"></i>
<p>Glisse un PDF ici, ou <span class="pdf-dropzone-browse">clique pour choisir un fichier</span></p>
@@ -166,9 +183,13 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<form method="POST" enctype="multipart/form-data" action="<?php echo $xpAModifier ? '../scripts/updateXP.php' : '../scripts/addXP.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($xpAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $xpAModifier['id']; ?>">
<?php endif; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($xpAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $xpAModifier['id'] . '">';
}
?>
<label>Titre *</label>
<input type="text" name="title" required value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['title']) : ''; ?>">
@@ -195,16 +216,22 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon expérience&#10;&#10;Description complète en **markdown**..."><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($xpAModifier && !empty($xpAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($xpAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=experience&id=<?php echo (int) $xpAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<?php
// Si un PDF est déjà enregistré, on le rappelle avec un lien pour le consulter
// et une case à cocher pour le supprimer à l'enregistrement du formulaire.
if ($xpAModifier && !empty($xpAModifier['pdf_filename'])) {
$lienPdf = '../scripts/viewPdf.php?type=experience&id=' . (int) $xpAModifier['id'];
echo '<div class="pdf-current">';
echo '<i class="fa-solid fa-file-pdf"></i>';
echo '<span>' . htmlspecialchars($xpAModifier['pdf_filename']) . '</span>';
echo '<a href="' . $lienPdf . '" target="_blank" rel="noopener noreferrer">Voir</a>';
echo '<label class="checkbox-row pdf-remove-row">';
echo '<input type="checkbox" name="remove_pdf"> Supprimer ce PDF';
echo '</label>';
echo '</div>';
}
?>
<div class="pdf-dropzone" data-input="pdf-xp">
<i class="fa-solid fa-cloud-arrow-up"></i>
<p>Glisse un PDF ici, ou <span class="pdf-dropzone-browse">clique pour choisir un fichier</span></p>
@@ -237,9 +264,13 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<form method="POST" enctype="multipart/form-data" action="<?php echo $ecoleAModifier ? '../scripts/updateSchool.php' : '../scripts/addSchool.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($ecoleAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $ecoleAModifier['id']; ?>">
<?php endif; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($ecoleAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $ecoleAModifier['id'] . '">';
}
?>
<label>Nom de l'école *</label>
<input type="text" name="title" required value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['title']) : ''; ?>">
@@ -263,16 +294,22 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon école&#10;&#10;Description complète en **markdown**..."><?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($ecoleAModifier && !empty($ecoleAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($ecoleAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=ecole&id=<?php echo (int) $ecoleAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<?php
// Si un PDF est déjà enregistré, on le rappelle avec un lien pour le consulter
// et une case à cocher pour le supprimer à l'enregistrement du formulaire.
if ($ecoleAModifier && !empty($ecoleAModifier['pdf_filename'])) {
$lienPdf = '../scripts/viewPdf.php?type=ecole&id=' . (int) $ecoleAModifier['id'];
echo '<div class="pdf-current">';
echo '<i class="fa-solid fa-file-pdf"></i>';
echo '<span>' . htmlspecialchars($ecoleAModifier['pdf_filename']) . '</span>';
echo '<a href="' . $lienPdf . '" target="_blank" rel="noopener noreferrer">Voir</a>';
echo '<label class="checkbox-row pdf-remove-row">';
echo '<input type="checkbox" name="remove_pdf"> Supprimer ce PDF';
echo '</label>';
echo '</div>';
}
?>
<div class="pdf-dropzone" data-input="pdf-school">
<i class="fa-solid fa-cloud-arrow-up"></i>
<p>Glisse un PDF ici, ou <span class="pdf-dropzone-browse">clique pour choisir un fichier</span></p>
@@ -305,9 +342,13 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<form method="POST" action="<?php echo $skillAModifier ? '../scripts/updateSkill.php' : '../scripts/addSkill.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($skillAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $skillAModifier['id']; ?>">
<?php endif; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($skillAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $skillAModifier['id'] . '">';
}
?>
<label>Titre de la catégorie *</label>
<input type="text" name="title" required value="<?php echo $skillAModifier ? htmlspecialchars($skillAModifier['title']) : ''; ?>">
@@ -333,6 +374,67 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
</div>
</section>
<!-- ========== SECTION MODE PRÉSENTATION ========== -->
<section id="admin-presentation" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-lock"></i> Mode présentation</h2>
<!-- Contenu affiché une fois le code PIN entré (voir pages/presentation.php) :
même principe que le markdown des projets/expériences/écoles, avec les mêmes
images possibles via ![légende](url) -->
<div class="project-container">
<div class="project-form-card">
<!-- on envoie les données à updateSettings.php -->
<form method="POST" action="../scripts/updateSettings.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="hidden" name="settings_group" value="presentation">
<label>Contenu (markdown, comme pour les fiches projets)</label>
<!-- Petit bouton d'aide : insère la syntaxe markdown d'une image au
curseur, pour ne pas avoir à s'en souvenir -->
<div class="markdown-toolbar">
<button type="button" class="markdown-insert-image" data-target="presentation_markdown">
<i class="fa-solid fa-image"></i> Insérer une image
</button>
</div>
<textarea name="presentation_markdown" id="presentation_markdown" class="markdown-textarea" placeholder="# Titre&#10;&#10;Texte de présentation en **markdown**...&#10;&#10;![Légende de l'image](https://...)"><?php echo htmlspecialchars($presentationMarkdown); ?></textarea>
<input type="submit" name="submitsettings" value="Enregistrer le contenu" class="btn-submit">
</form>
</div>
</div>
<!-- Code PIN à part : formulaire séparé car il doit être haché avant d'être
enregistré (voir scripts/updatePresentationPin.php), contrairement au markdown
juste au-dessus qui est stocké tel quel -->
<div class="project-container">
<div class="project-form-card">
<form method="POST" action="../scripts/updatePresentationPin.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<label>Code PIN (exactement 9 chiffres)</label>
<input type="text" name="pin" inputmode="numeric" pattern="\d{9}" minlength="9" maxlength="9" placeholder="9 chiffres" required>
<input type="submit" value="Changer le code PIN" class="btn-submit">
</form>
<!-- Le code actuel n'est jamais réaffiché : seul son hash est stocké en
base, impossible à relire (comme un mot de passe) -->
<p class="admin-data-text">Le code actuel n'apparaît jamais ici, seul son hash est stocké en base.</p>
</div>
</div>
<!-- IP actuellement bloquées après 4 échecs : un visiteur légitime qui se trompe
plusieurs fois peut se retrouver bloqué, ce bouton permet de le débloquer
sans attendre les 24h de blocage automatique -->
<div class="project-container">
<div class="project-form-card">
<h3 class="admin-data-title"><i class="fa-solid fa-lock"></i> IP bloquées</h3>
<?php afficherTentativesVerrouilleesAdmin(); ?>
</div>
</div>
</section>
<!-- ========== SECTION LOGOS (bandeaux page d'accueil) ========== -->
<section id="admin-logos" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-images"></i> Logos partenaires (page d'accueil)</h2>
@@ -350,9 +452,13 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<form method="POST" action="<?php echo $logoAModifier ? '../scripts/updateLogo.php' : '../scripts/addLogo.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($logoAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $logoAModifier['id']; ?>">
<?php endif; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($logoAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $logoAModifier['id'] . '">';
}
?>
<!-- Seul le bandeau "Ils m'ont fait confiance" est géré ici -->
<input type="hidden" name="category" value="partner">
@@ -604,6 +710,36 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
})();
</script>
<!-- Bouton "Insérer une image" du mode présentation : ajoute la syntaxe markdown d'une
image (![légende](url)) à l'endroit où était le curseur dans le champ, pour ne pas
avoir à se souvenir de la syntaxe -->
<script>
(function () {
document.querySelectorAll('.markdown-insert-image').forEach(function (bouton) {
bouton.addEventListener('click', function () {
var champ = document.getElementById(bouton.dataset.target);
if (!champ) return;
var url = prompt('Lien de l\'image (URL) :');
if (!url) return;
var legende = prompt('Légende de l\'image (optionnel) :') || '';
var markdown = '![' + legende + '](' + url + ')';
// On insère au niveau du curseur plutôt qu'à la fin, pour pouvoir placer
// l'image n'importe où dans le texte déjà écrit
var debut = champ.selectionStart;
var fin = champ.selectionEnd;
champ.value = champ.value.slice(0, debut) + markdown + champ.value.slice(fin);
champ.focus();
var curseur = debut + markdown.length;
champ.setSelectionRange(curseur, curseur);
});
});
})();
</script>
<?php
// On ajoute ici le footer
require_once('../elements/footer.php'); ?>
+12 -7
View File
@@ -24,13 +24,18 @@ $contactAddressEnabled = (recupererSetting('contact_address_enabled') ?? '1') ==
<div class="contact-info">
<p class="contact-title">Contact</p>
<p class="contact-name">Esteban MOUSSU</p>
<?php if ($contactEmailEnabled && !empty($contactEmail)): ?>
<p class="contact-line"><i class="fa-solid fa-envelope"></i> <?php echo htmlspecialchars($contactEmail); ?></p>
<?php endif; ?>
<?php if ($contactAddressEnabled && (!empty($contactAddressLine1) || !empty($contactAddressLine2))): ?>
<p class="contact-line"><i class="fa-solid fa-location-dot"></i> <?php echo htmlspecialchars($contactAddressLine1); ?></p>
<p class="contact-line"><?php echo htmlspecialchars($contactAddressLine2); ?></p>
<?php endif; ?>
<?php
// L'e-mail n'est affiché que s'il est renseigné et activé dans le panel admin
if ($contactEmailEnabled && !empty($contactEmail)) {
echo '<p class="contact-line"><i class="fa-solid fa-envelope"></i> ' . htmlspecialchars($contactEmail) . '</p>';
}
// Même principe pour l'adresse postale (deux lignes)
if ($contactAddressEnabled && (!empty($contactAddressLine1) || !empty($contactAddressLine2))) {
echo '<p class="contact-line"><i class="fa-solid fa-location-dot"></i> ' . htmlspecialchars($contactAddressLine1) . '</p>';
echo '<p class="contact-line">' . htmlspecialchars($contactAddressLine2) . '</p>';
}
?>
</div>
<!-- Formulaire d'envoi -->
+77 -49
View File
@@ -87,58 +87,78 @@ if (!empty($element['tags'])) {
<article class="details-card">
<?php if (!empty($image)): ?>
<div class="details-banner project-banner">
<img src="<?php echo $image; ?>" alt="Image : <?php echo $titre; ?>">
<?php if (!empty($status)): ?>
<div class="project-state"><?php echo $status; ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php
// Bandeau d'image, seulement si une image est renseignée pour cet élément
if (!empty($image)) {
echo '<div class="details-banner project-banner">';
echo '<img src="' . $image . '" alt="Image : ' . $titre . '">';
// Pastille d'état (En cours, Terminé...) posée par-dessus l'image
if (!empty($status)) {
echo '<div class="project-state">' . $status . '</div>';
}
echo '</div>';
}
?>
<div class="details-content">
<span class="details-type"><?php echo $infos['label']; ?></span>
<h1><?php echo $titre; ?></h1>
<!-- Métadonnées : type / durée si renseignés -->
<?php if (!empty($xptype) || !empty($duration)): ?>
<p class="details-meta">
<?php if (!empty($xptype)): ?><span><i class="fa-solid fa-tag"></i> <?php echo $xptype; ?></span><?php endif; ?>
<?php if (!empty($duration)): ?><span><i class="fa-solid fa-clock"></i> <?php echo $duration; ?></span><?php endif; ?>
</p>
<?php endif; ?>
<?php
// Métadonnées : type d'expérience et durée, affichés seulement si renseignés
if (!empty($xptype) || !empty($duration)) {
echo '<p class="details-meta">';
<?php if (!empty($tags)): ?>
<div class="project-tags">
<?php foreach ($tags as $tag): ?>
<span class="tag"><?php echo htmlspecialchars($tag); ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
if (!empty($xptype)) {
echo '<span><i class="fa-solid fa-tag"></i> ' . $xptype . '</span>';
}
<?php if ($hasMarkdown): ?>
<!-- Le markdown brut est placé ici (échappé), puis rendu en HTML par le script plus bas -->
<div id="markdown-source" hidden><?php echo htmlspecialchars($element['markdown']); ?></div>
<div class="markdown-content" id="markdown-target">
<p>Chargement du contenu...</p>
</div>
<?php elseif ($hasPdf): ?>
<!-- Mini lecteur PDF : le fichier est servi par viewPdf.php depuis la base de données -->
<div class="pdf-viewer-wrap">
<div class="pdf-viewer-toolbar">
<span><i class="fa-solid fa-file-pdf"></i> <?php echo htmlspecialchars($element['pdf_filename']); ?></span>
<a class="btn btn-ghost pdf-download-btn" href="../scripts/viewPdf.php?type=<?php echo $type; ?>&id=<?php echo (int) $id; ?>&download=1" download>
<i class="fa-solid fa-download"></i> Télécharger
</a>
</div>
<iframe
class="pdf-viewer"
src="../scripts/viewPdf.php?type=<?php echo $type; ?>&id=<?php echo (int) $id; ?>"
title="Aperçu PDF : <?php echo $titre; ?>"
></iframe>
</div>
<?php endif; ?>
if (!empty($duration)) {
echo '<span><i class="fa-solid fa-clock"></i> ' . $duration . '</span>';
}
echo '</p>';
}
// Étiquettes (technologies, mots-clés), une par tag séparé par une virgule en BDD
if (!empty($tags)) {
echo '<div class="project-tags">';
foreach ($tags as $tag) {
echo '<span class="tag">' . htmlspecialchars($tag) . '</span>';
}
echo '</div>';
}
?>
<?php
// Deux contenus possibles, jamais les deux en même temps : soit un texte en
// markdown, soit un PDF. Le markdown est prioritaire quand les deux existent.
if ($hasMarkdown) {
// Le markdown brut est placé ici (échappé), puis rendu en HTML par le script plus bas
echo '<div id="markdown-source" hidden>' . htmlspecialchars($element['markdown']) . '</div>';
echo '<div class="markdown-content" id="markdown-target">';
echo '<p>Chargement du contenu...</p>';
echo '</div>';
} elseif ($hasPdf) {
// Mini lecteur PDF : le fichier est servi par viewPdf.php depuis la base de données
$lienPdf = '../scripts/viewPdf.php?type=' . $type . '&id=' . (int) $id;
echo '<div class="pdf-viewer-wrap">';
echo '<div class="pdf-viewer-toolbar">';
echo '<span><i class="fa-solid fa-file-pdf"></i> ' . htmlspecialchars($element['pdf_filename']) . '</span>';
echo '<a class="btn btn-ghost pdf-download-btn" href="' . $lienPdf . '&download=1" download>';
echo '<i class="fa-solid fa-download"></i> Télécharger';
echo '</a>';
echo '</div>';
echo '<iframe class="pdf-viewer" src="' . $lienPdf . '" title="Aperçu PDF : ' . $titre . '"></iframe>';
echo '</div>';
}
?>
</div>
@@ -146,10 +166,16 @@ if (!empty($element['tags'])) {
</div>
<?php if ($hasMarkdown): ?>
<!-- marked.js : convertit le markdown en HTML / DOMPurify : nettoie le HTML généré (sécurité) -->
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<?php
// Ces deux librairies ne sont chargées que sur les pages qui ont réellement du markdown :
// marked.js convertit le markdown en HTML, DOMPurify nettoie le HTML généré (sécurité XSS)
if ($hasMarkdown) {
echo '<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>';
echo '<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>';
// <<<'JS' ... JS; = "nowdoc" : un bloc de texte sur plusieurs lignes, recopié tel quel.
// Pratique ici pour ne pas avoir à échapper les guillemets du JavaScript.
echo <<<'JS'
<script>
(function () {
var source = document.getElementById('markdown-source');
@@ -159,7 +185,9 @@ if (!empty($element['tags'])) {
cible.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
})();
</script>
<?php endif; ?>
JS;
}
?>
<?php
// Ajout du footer
+47 -29
View File
@@ -57,27 +57,39 @@ $statsAffichables = array_filter([
'technologies' => ['valeur' => $statsHome['technologies'], 'label' => 'Technologies maîtrisées'],
], function ($stat) { return $stat['valeur'] > 0; });
?>
<?php if (!empty($statsAffichables)): ?>
<!-- Bandeau de chiffres clés : compteurs réels, calculés depuis la BDD -->
<section class="stats-strip">
<?php foreach ($statsAffichables as $stat): ?>
<div class="stat-item">
<span class="stat-number"><?php echo (int) $stat['valeur']; ?></span>
<span class="stat-label"><?php echo htmlspecialchars($stat['label']); ?></span>
</div>
<?php endforeach; ?>
</section>
<?php endif; ?>
<?php
// Bandeau de chiffres clés : compteurs réels, calculés depuis la BDD.
// Rien n'est affiché s'il n'y a aucun compteur à montrer.
if (!empty($statsAffichables)) {
echo '<section class="stats-strip">';
<?php if (!empty(listeIconesCompetences())): ?>
<!-- Bandeau défilant des outils, dérivé de la page Compétences -->
<section class="logo-banner-section">
<div class="logo-banner-header">
<h2>Outils que je sais manipuler</h2>
</div>
<?php afficherOutilsCompetences(); ?>
</section>
<?php endif; ?>
// Une tuile par compteur
foreach ($statsAffichables as $stat) {
echo '<div class="stat-item">';
echo '<span class="stat-number">' . (int) $stat['valeur'] . '</span>';
echo '<span class="stat-label">' . htmlspecialchars($stat['label']) . '</span>';
echo '</div>';
}
echo '</section>';
}
?>
<?php
// Bandeau défilant des outils, dérivé des icônes de la page Compétences.
// Si aucune compétence n'a d'icône reconnaissable, la section entière est masquée.
if (!empty(listeIconesCompetences())) {
echo '<section class="logo-banner-section">';
echo '<div class="logo-banner-header">';
echo '<h2>Outils que je sais manipuler</h2>';
echo '</div>';
// Cette fonction affiche elle-même le bandeau (voir functions/listSkills.php)
afficherOutilsCompetences();
echo '</section>';
}
?>
<!-- Section d'exploration rapide -->
<section class="quick-links-section" id="explorer">
@@ -123,15 +135,21 @@ $statsAffichables = array_filter([
</div>
</section>
<?php if (!empty(listeLogos('partner'))): ?>
<!-- Bandeau défilant des entreprises partenaires, géré depuis le panel Admin -->
<section class="logo-banner-section">
<div class="logo-banner-header">
<h2>Ils m'ont fait confiance</h2>
</div>
<?php afficherLogos('partner'); ?>
</section>
<?php endif; ?>
<?php
// Bandeau défilant des entreprises partenaires, géré depuis le panel Admin.
// Tant qu'aucun logo "partner" n'est enregistré, la section n'apparaît pas.
if (!empty(listeLogos('partner'))) {
echo '<section class="logo-banner-section">';
echo '<div class="logo-banner-header">';
echo '<h2>Ils m\'ont fait confiance</h2>';
echo '</div>';
// Cette fonction affiche elle-même le bandeau (voir functions/listLogos.php)
afficherLogos('partner');
echo '</section>';
}
?>
<?php
// Ajout du footer
+11 -1
View File
@@ -15,9 +15,13 @@ if (isset($_SESSION['user_id'])){
exit;
}
// Redirection optionnelle après connexion, whitelist stricte (pas d'URL arbitraire ici :
// on ne veut pas transformer ce champ en redirection ouverte). Utilisé quand le mode
// présentation renvoie ici après un blocage (voir pages/presentation.php)
$redirect = ($_GET['redirect'] ?? '') === 'presentation' ? 'presentation' : '';
?>
<!-- CSS Login (fait par Intelligence Arti) -->
<div class="login-container">
<div class="login-card">
@@ -26,6 +30,12 @@ if (isset($_SESSION['user_id'])){
<p>Connexion</p>
<?php
if ($redirect !== '') {
echo '<input type="hidden" name="redirect" value="' . htmlspecialchars($redirect) . '">';
}
?>
<!-- Deux champs de texte, un "username" et un "password" -->
<input type="text" name="username" id="username" placeholder="Votre identifiant">
<input type="password" name="password" id="password" placeholder="Votre mot de passe">
+483
View File
@@ -0,0 +1,483 @@
<?php
// Page "Mode présentation" : contenu markdown protégé par un code PIN à 9 chiffres.
// Le compteur de tentatives est géré côté serveur par IP (voir functions/presentationAuth.php)
// pour ne pas pouvoir être remis à zéro en vidant les cookies. Au bout de 4 tentatives
// ratées, on ne redemande plus le code : on propose une connexion admin à la place.
//
// Une fois débloquée (code correct, ou compte admin connecté), la page se rend sans le
// header ni le footer du site : contenu markdown en plein écran, lecture immersive.
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/databaseConnection.php');
require_once('../functions/listSettings.php');
require_once('../functions/presentationAuth.php');
$adminConnecte = isset($_SESSION['role']) && $_SESSION['role'] === 'admin';
$ipHash = hashIpPresentation();
$maxTentatives = 4;
// Un administrateur connecté a toujours accès, et ça lève un éventuel blocage sur son IP
// (pratique s'il teste lui-même depuis chez lui après s'être fait bloquer en visiteur)
if ($adminConnecte) {
reinitialiserTentativesPresentation($ipHash);
$_SESSION['presentation_unlocked'] = true;
}
$deverrouille = $adminConnecte || (($_SESSION['presentation_unlocked'] ?? false) === true);
$verrouille = false;
$erreur = '';
if (!$deverrouille) {
$verrouille = presentationEstVerrouillee($ipHash, $maxTentatives);
if (!$verrouille && $_SERVER['REQUEST_METHOD'] === 'POST') {
$pinSaisi = preg_replace('/\D/', '', $_POST['pin'] ?? '');
if (verifierPinPresentation($pinSaisi)) {
reinitialiserTentativesPresentation($ipHash);
// Empêche la fixation de session, comme à la connexion admin
session_regenerate_id(true);
$_SESSION['presentation_unlocked'] = true;
$deverrouille = true;
} else {
$etat = enregistrerEchecPresentation($ipHash, $maxTentatives);
$verrouille = (int) $etat['attempts'] >= $maxTentatives;
$erreur = $verrouille ? '' : 'Code incorrect. Réessaie.';
}
}
}
// Tentatives restantes à afficher (pastilles), calculées après un éventuel échec ci-dessus
$tentativesRestantes = $maxTentatives;
$etatActuel = null;
if (!$deverrouille) {
$etatActuel = recupererTentativePresentation($ipHash);
$tentativesRestantes = $maxTentatives - ($etatActuel ? (int) $etatActuel['attempts'] : 0);
if ($tentativesRestantes < 0) {
$tentativesRestantes = 0;
}
}
if ($deverrouille) {
// ---- Présentation en cours : page autonome plein écran, sans le header du site ----
require_once('../scripts/requireCaptcha.php');
require_once('../functions/sendLogs.php');
require_once(__DIR__ . '/../functions/assets.php');
sendPageLog();
$markdown = recupererSetting('presentation_markdown') ?? '';
// Diapositives séparées par une ligne ne contenant que --- (même convention que
// Marp/reveal.js : facile à retenir, et ça reste du markdown lisible tel quel).
// Sans --- dans le texte, tout le contenu forme une seule diapositive : dans ce
// cas on garde une simple page de lecture, sans flèches ni compteur inutiles.
$morceaux = preg_split('/^[ \t]*-{3,}[ \t]*$/m', $markdown);
$diapositives = [];
foreach ($morceaux as $morceau) {
$texte = trim($morceau);
if ($texte !== '') {
$diapositives[] = $texte;
}
}
$nombreDiapositives = count($diapositives);
$modeDiaporama = $nombreDiapositives > 1;
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mode présentation - Esteban MOUSSU</title>
<meta name="robots" content="noindex, nofollow">
<link rel="icon" type="image/png" href="../memoji.png">
<!-- Applique le thème sauvegardé AVANT le chargement du CSS pour éviter un flash sombre/clair -->
<script>
(function () {
try {
if (localStorage.getItem('theme') === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
} catch (e) {}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- On réutilise les variables et styles de base du site (thème clair/sombre, police...) -->
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('styles/primary_css.css')); ?>">
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('styles/presentation.css')); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
</head>
<body class="presentation-body">
<!-- Barre minimale : pas de nav du site, juste un rappel du mode + sortie -->
<div class="presentation-bar">
<span class="presentation-bar-tag"><i class="fa-solid fa-lock-open"></i> <span>Mode présentation</span></span>
<div class="presentation-bar-actions">
<button class="theme-toggle" type="button" aria-label="Changer de thème"
onclick="var h=document.documentElement;var l=h.getAttribute('data-theme')==='light';if(l){h.removeAttribute('data-theme');try{localStorage.setItem('theme','dark');}catch(e){}}else{h.setAttribute('data-theme','light');try{localStorage.setItem('theme','light');}catch(e){}}">
<i class="fa-solid fa-sun"></i>
<i class="fa-solid fa-moon"></i>
</button>
<a class="presentation-exit" href="../pages/home.php"><i class="fa-solid fa-xmark"></i> Quitter</a>
</div>
</div>
<?php if (!$modeDiaporama) { ?>
<!-- Un seul bloc de contenu : barre de progression de lecture (s'anime au scroll dans
les navigateurs qui supportent animation-timeline: scroll() ; reste simplement
invisible sinon, aucune régression) -->
<div class="presentation-progress" aria-hidden="true"></div>
<?php } ?>
<?php if ($nombreDiapositives === 0) { ?>
<main class="presentation-reader">
<p class="presentation-empty">Contenu à venir. Ajoute du texte depuis le panel admin, section « Mode présentation ».</p>
</main>
<?php } elseif (!$modeDiaporama) { ?>
<!-- Mode lecture simple : tout défile dans une seule colonne -->
<main class="presentation-reader">
<?php
// Même mécanisme que sur les pages de détail (projets/xp/écoles) : le markdown
// brut est déposé ici (échappé), puis rendu en HTML par marked.js plus bas.
// Les images s'insèrent avec la syntaxe markdown standard : ![légende](url)
echo '<div id="markdown-source" hidden>' . htmlspecialchars($diapositives[0]) . '</div>';
echo '<div class="markdown-content presentation-markdown" id="markdown-target"><p>Chargement du contenu...</p></div>';
?>
</main>
<?php } else { ?>
<!-- Mode diaporama : une diapositive à la fois, séparées dans le champ admin par
une ligne "---". Navigation en bas (flèches, barre de progression, compteur) -->
<main class="presentation-deck" data-total="<?php echo $nombreDiapositives; ?>">
<?php
foreach ($diapositives as $index => $texteDiapositive) {
$classeActive = $index === 0 ? ' is-active' : '';
echo '<section class="slide' . $classeActive . '" data-slide="' . $index . '">';
echo '<div class="slide-source" hidden>' . htmlspecialchars($texteDiapositive) . '</div>';
echo '<div class="markdown-content presentation-markdown slide-target"><p>Chargement du contenu...</p></div>';
echo '</section>';
}
?>
</main>
<nav class="slide-nav" aria-label="Navigation entre les diapositives">
<button type="button" class="slide-nav-btn slide-prev" aria-label="Diapositive précédente" disabled>
<i class="fa-solid fa-chevron-left"></i>
</button>
<div class="slide-progress" aria-hidden="true">
<div class="slide-progress-bar"></div>
</div>
<span class="slide-counter"><span class="slide-counter-current">1</span> / <?php echo $nombreDiapositives; ?></span>
<button type="button" class="slide-nav-btn slide-next" aria-label="Diapositive suivante">
<i class="fa-solid fa-chevron-right"></i>
</button>
</nav>
<?php } ?>
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<?php
if (!$modeDiaporama) {
// ---- Rendu simple : un seul bloc, apparition douce au fil du scroll ----
echo <<<'JS'
<script>
(function () {
var source = document.getElementById('markdown-source');
var cible = document.getElementById('markdown-target');
if (source && cible) {
var markdown = source.textContent;
cible.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
}
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!('IntersectionObserver' in window)) return;
// Chaque bloc du contenu (titre, paragraphe, image...) apparaît en douceur à la lecture
var cibles = document.querySelectorAll('.presentation-markdown > *');
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1, rootMargin: '0px 0px -60px 0px' });
cibles.forEach(function (el) {
el.classList.add('reveal');
observer.observe(el);
});
})();
</script>
JS;
} else {
// ---- Rendu diaporama : une diapositive affichée à la fois, navigation clavier/
// tactile/boutons, position mémorisée dans l'URL (#slide-3) pour pouvoir
// partager un lien direct vers une diapositive précise ----
echo <<<'JS'
<script>
(function () {
var slides = Array.prototype.slice.call(document.querySelectorAll('.slide'));
// Rendu du markdown de chaque diapositive (source échappée côté serveur,
// transformée en HTML ici, comme sur les fiches projets)
slides.forEach(function (slide) {
var source = slide.querySelector('.slide-source');
var cible = slide.querySelector('.slide-target');
if (source && cible) {
cible.innerHTML = DOMPurify.sanitize(marked.parse(source.textContent, { breaks: true }));
}
});
var index = 0;
var deck = document.querySelector('.presentation-deck');
var boutonPrec = document.querySelector('.slide-prev');
var boutonSuiv = document.querySelector('.slide-next');
var barre = document.querySelector('.slide-progress-bar');
var compteur = document.querySelector('.slide-counter-current');
function afficher(nouvelIndex) {
nouvelIndex = Math.max(0, Math.min(slides.length - 1, nouvelIndex));
slides[index].classList.remove('is-active');
index = nouvelIndex;
slides[index].classList.add('is-active');
boutonPrec.disabled = index === 0;
boutonSuiv.disabled = index === slides.length - 1;
if (barre) barre.style.width = ((index + 1) / slides.length * 100) + '%';
if (compteur) compteur.textContent = String(index + 1);
history.replaceState(null, '', '#slide-' + (index + 1));
}
boutonPrec.addEventListener('click', function () { afficher(index - 1); });
boutonSuiv.addEventListener('click', function () { afficher(index + 1); });
document.addEventListener('keydown', function (e) {
// On ignore les flèches si le focus est dans un champ de texte (aucun ici,
// mais garde-fou si jamais du contenu interactif est ajouté un jour)
var balise = (document.activeElement || {}).tagName;
if (balise === 'INPUT' || balise === 'TEXTAREA') return;
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); afficher(index + 1); }
if (e.key === 'ArrowLeft') { afficher(index - 1); }
});
// Clic sur la diapositive (comme Google Slides / Keynote) : moitié gauche de l'écran
// = diapositive précédente, moitié droite = suivante. On laisse passer le clic sans
// rien faire s'il vise un lien, un bouton ou la barre de navigation elle-même, pour
// ne pas voler le clic d'un lien présent dans le contenu markdown
deck.addEventListener('click', function (e) {
if (e.target.closest('a, button')) return;
afficher(e.clientX < window.innerWidth / 2 ? index - 1 : index + 1);
});
// Glissement tactile (mobile) : swipe vers la gauche = diapositive suivante
var startX = null;
deck.addEventListener('touchstart', function (e) {
startX = e.touches[0].clientX;
}, { passive: true });
deck.addEventListener('touchend', function (e) {
if (startX === null) return;
var delta = e.changedTouches[0].clientX - startX;
if (Math.abs(delta) > 50) {
afficher(delta < 0 ? index + 1 : index - 1);
}
startX = null;
}, { passive: true });
// Reprend la diapositive indiquée dans l'URL au chargement (#slide-3)
var depart = parseInt((location.hash.match(/slide-(\d+)/) || [])[1], 10);
afficher(isNaN(depart) ? 0 : depart - 1);
})();
</script>
JS;
}
?>
</body>
</html>
<?php
} else {
// ---- Écran de saisie du code, ou écran "trop de tentatives" : chrome normal du site ----
$pageTitle = 'Mode présentation';
require_once('../elements/header.php');
if ($verrouille) {
$jusqua = ($etatActuel && !empty($etatActuel['locked_until']))
? date('d/m/Y à H:i', strtotime($etatActuel['locked_until']))
: '';
?>
<div class="presentation-lock-container">
<div class="presentation-lock-card presentation-lock-card--blocked">
<span class="presentation-lock-badge presentation-lock-badge--danger">
<i class="fa-solid fa-triangle-exclamation"></i> Accès bloqué
</span>
<h1>Trop de tentatives</h1>
<p>Le code a été saisi plusieurs fois de suite sans succès. L'accès par code PIN est bloqué pour cet appareil<?php echo $jusqua !== '' ? ' jusqu\'au ' . htmlspecialchars($jusqua) : ''; ?>.</p>
<p>Connecte-toi en administrateur pour débloquer l'accès immédiatement.</p>
<a class="btn btn-primary presentation-lock-btn" href="../pages/login.php?redirect=presentation">
<i class="fa-solid fa-right-to-bracket"></i> Se connecter en administrateur
</a>
</div>
</div>
<?php
} else {
?>
<div class="presentation-lock-container">
<div class="presentation-lock-card">
<span class="presentation-lock-badge"><i class="fa-solid fa-lock"></i> Accès protégé</span>
<h1>Mode présentation</h1>
<p>Entre le code à 9 chiffres pour accéder au contenu.</p>
<?php
if ($erreur !== '') {
echo '<div class="presentation-lock-error">' . htmlspecialchars($erreur) . '</div>';
}
?>
<form method="POST" action="" class="presentation-pin-form" novalidate>
<div class="pin-boxes" role="group" aria-label="Code à 9 chiffres">
<?php
// type="password" : les chiffres s'affichent en points par défaut, comme un
// mot de passe. Le bouton œil juste en dessous les révèle, mais seulement
// tant qu'il reste appuyé (voir le JS plus bas)
for ($i = 0; $i < 9; $i++) {
echo '<input type="password" class="pin-box" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Chiffre ' . ($i + 1) . ' sur 9">';
}
?>
</div>
<!-- Maintenir appuyé pour voir le code, relâcher pour le remasquer -->
<button type="button" class="pin-reveal" aria-pressed="false">
<i class="fa-solid fa-eye"></i> <span>Maintenir pour afficher</span>
</button>
<input type="hidden" name="pin" id="pin-value">
<input type="submit" value="Valider" class="btn btn-primary presentation-pin-submit">
</form>
<div class="pin-attempts">
<span>Tentatives restantes</span>
<div class="pin-attempts-dots">
<?php
for ($i = 0; $i < $maxTentatives; $i++) {
echo '<span class="' . ($i < $tentativesRestantes ? 'dot-filled' : 'dot-empty') . '"></span>';
}
?>
</div>
</div>
</div>
</div>
<script>
(function () {
var boxes = Array.prototype.slice.call(document.querySelectorAll('.pin-box'));
var hidden = document.getElementById('pin-value');
var submit = document.querySelector('.presentation-pin-submit');
function syncHidden() {
var valeur = boxes.map(function (b) { return b.value; }).join('');
hidden.value = valeur;
submit.disabled = valeur.length !== 9;
}
boxes.forEach(function (box, index) {
box.addEventListener('input', function () {
box.value = box.value.replace(/\D/g, '').slice(0, 1);
if (box.value && boxes[index + 1]) {
boxes[index + 1].focus();
}
syncHidden();
});
box.addEventListener('keydown', function (e) {
if (e.key === 'Backspace' && !box.value && boxes[index - 1]) {
boxes[index - 1].focus();
}
});
box.addEventListener('paste', function (e) {
var texte = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '');
if (texte.length === 0) return;
e.preventDefault();
texte.slice(0, 9).split('').forEach(function (chiffre, i) {
if (boxes[i]) boxes[i].value = chiffre;
});
syncHidden();
var prochain = boxes[Math.min(texte.length, 8)];
if (prochain) prochain.focus();
});
});
// Bouton œil : le code n'est visible QUE tant que le bouton reste appuyé. Dès qu'on
// relâche (ou que le doigt/la souris quitte le bouton, ou que l'onglet perd le focus),
// on repasse en points. Évite de laisser le code affiché par inadvertance à l'écran.
var oeil = document.querySelector('.pin-reveal');
var icone = oeil.querySelector('i');
function afficherCode(visible) {
boxes.forEach(function (box) {
box.type = visible ? 'text' : 'password';
});
oeil.setAttribute('aria-pressed', visible ? 'true' : 'false');
oeil.classList.toggle('is-revealing', visible);
icone.className = visible ? 'fa-solid fa-eye-slash' : 'fa-solid fa-eye';
}
['mousedown', 'touchstart'].forEach(function (evt) {
oeil.addEventListener(evt, function (e) {
// Empêche le navigateur mobile de déclencher aussi un mousedown en double,
// et évite de voler le focus au champ en cours de saisie
e.preventDefault();
afficherCode(true);
});
});
['mouseup', 'mouseleave', 'touchend', 'touchcancel', 'blur'].forEach(function (evt) {
oeil.addEventListener(evt, function () {
afficherCode(false);
});
});
// Accessibilité clavier : Espace ou Entrée maintenus révèlent le code de la même façon
oeil.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
afficherCode(true);
}
});
oeil.addEventListener('keyup', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
afficherCode(false);
}
});
// Garde-fou : si l'utilisateur change d'onglet en gardant le bouton appuyé, le
// relâchement n'est jamais reçu par la page, donc on remasque de force
window.addEventListener('blur', function () {
afficherCode(false);
});
syncHidden();
if (boxes[0]) boxes[0].focus();
})();
</script>
<?php
}
require_once('../elements/footer.php');
}