Compare commits
10
Commits
main
..
a28e0891d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a28e0891d4 | ||
|
|
e5bd31fc65 | ||
|
|
a4ccd7e2a9 | ||
|
|
e26dbd388f | ||
|
|
a1c99f1de8 | ||
|
|
c04a9b5ab8 | ||
|
|
c28fae7061 | ||
|
|
91060537c8 | ||
|
|
5b76997895 | ||
|
|
4c35c727d5 |
No files matched your search
+1
-1
@@ -5,7 +5,7 @@
|
||||
# Éditeurs et outils locaux
|
||||
.vscode/
|
||||
.idea/
|
||||
.nexus/
|
||||
.claude/
|
||||
*.iml
|
||||
|
||||
# Logs
|
||||
|
||||
+127
-151
@@ -4,9 +4,6 @@ require_once(__DIR__ . '/functions/security.php');
|
||||
// Ajout des logs Discord (chemin via __DIR__ car captcha.php est à la racine du site)
|
||||
require_once(__DIR__ . '/functions/sendLogs.php');
|
||||
|
||||
// Versionnement des fichiers statiques (casse le cache navigateur à chaque modif du CSS)
|
||||
require_once(__DIR__ . '/functions/assets.php');
|
||||
|
||||
// Recharger une nouvelle série si demandé
|
||||
if (isset($_GET['reload']) && $_GET['reload'] === '1') {
|
||||
unset($_SESSION['captcha_round']);
|
||||
@@ -406,179 +403,158 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test anti-robot</title>
|
||||
<!-- Favicon servie par le CDN (voir elements/header.php) -->
|
||||
<link rel="icon" type="image/png" href="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.png">
|
||||
<link rel="icon" type="image/png" href="./memoji.png">
|
||||
<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">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('styles/captcha.css', './')); ?>">
|
||||
<link rel="stylesheet" href="./styles/captcha.css">
|
||||
</head>
|
||||
<body class="<?php echo $success ? 'success-mode' : ''; ?>">
|
||||
<main class="captcha-shell">
|
||||
<?php
|
||||
// Deux écrans possibles : la réussite (avec redirection automatique), ou le test lui-même.
|
||||
if ($success) {
|
||||
<?php if ($success): ?>
|
||||
<section class="success-screen">
|
||||
<div class="success-burst"></div>
|
||||
<div class="success-icon">✅</div>
|
||||
<h1>Vérification réussie</h1>
|
||||
<p>Bravo, tu as repéré le(s) mail(s) frauduleux. Redirection en cours...</p>
|
||||
|
||||
// --- Écran de réussite ---
|
||||
echo <<<'HTML'
|
||||
<section class="success-screen">
|
||||
<div class="success-burst"></div>
|
||||
<div class="success-icon">✅</div>
|
||||
<h1>Vérification réussie</h1>
|
||||
<p>Bravo, tu as repéré le(s) mail(s) frauduleux. Redirection en cours...</p>
|
||||
HTML;
|
||||
<?php if (!empty($foundReasons)): ?>
|
||||
<div class="success-reasons">
|
||||
<p class="success-reasons-title">Ce que tu as repéré :</p>
|
||||
<ul>
|
||||
<?php foreach ($foundReasons as $reason): ?>
|
||||
<li><i class="reason-dot"></i><?php echo htmlspecialchars($reason, ENT_QUOTES, 'UTF-8'); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
// Rappel de ce que le visiteur a correctement identifié, s'il y a quelque chose à dire
|
||||
if (!empty($foundReasons)) {
|
||||
echo '<div class="success-reasons">';
|
||||
echo '<p class="success-reasons-title">Ce que tu as repéré :</p>';
|
||||
echo '<ul>';
|
||||
<div class="success-loader">
|
||||
<span></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
foreach ($foundReasons as $reason) {
|
||||
echo '<li><i class="reason-dot"></i>' . htmlspecialchars($reason, ENT_QUOTES, 'UTF-8') . '</li>';
|
||||
}
|
||||
<script>
|
||||
setTimeout(function () {
|
||||
window.location.href = './index.php';
|
||||
}, 2400);
|
||||
</script>
|
||||
<?php else: ?>
|
||||
<form class="captcha-card" method="post" action="">
|
||||
<div class="captcha-badge">🛡️ Test anti-robot</div>
|
||||
|
||||
echo '</ul>';
|
||||
echo '</div>';
|
||||
}
|
||||
<div class="captcha-top">
|
||||
<div>
|
||||
<h1 class="captcha-title">Vérification humaine</h1>
|
||||
<p class="captcha-subtitle">
|
||||
Sélectionne <strong>tous les emails qui te semblent frauduleux</strong> parmi les messages
|
||||
ci-dessous. Certains sont plus dangereux que d'autres.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
echo <<<'HTML'
|
||||
<div class="success-loader">
|
||||
<span></span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="captcha-target">
|
||||
<span>Vérification demandée</span>
|
||||
<strong>Repérer le phishing</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
setTimeout(function () {
|
||||
window.location.href = './index.php';
|
||||
}, 2400);
|
||||
</script>
|
||||
HTML;
|
||||
<div class="captcha-stats">
|
||||
<div class="captcha-attempts">
|
||||
<span>Essais restants</span>
|
||||
<strong><?php echo htmlspecialchars((string) $attemptsLeft, ENT_QUOTES, 'UTF-8'); ?> / <?php echo htmlspecialchars((string) $maxAttempts, ENT_QUOTES, 'UTF-8'); ?></strong>
|
||||
<div class="attempts-dots">
|
||||
<?php for ($i = 0; $i < $maxAttempts; $i++): ?>
|
||||
<span class="<?php echo $i < $attemptsLeft ? 'dot-filled' : 'dot-empty'; ?>"></span>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
} else {
|
||||
<?php if ($locked): ?>
|
||||
<a class="captcha-reset" href="./captcha.php?reload=1">Recharger une nouvelle série</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
// --- Écran du test ---
|
||||
echo <<<'HTML'
|
||||
<form class="captcha-card" method="post" action="">
|
||||
<div class="captcha-badge">🛡️ Test anti-robot</div>
|
||||
<?php if ($error !== ''): ?>
|
||||
<div class="captcha-error"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="captcha-top">
|
||||
<div>
|
||||
<h1 class="captcha-title">Vérification humaine</h1>
|
||||
<p class="captcha-subtitle">
|
||||
Sélectionne <strong>tous les emails qui te semblent frauduleux</strong> parmi les messages
|
||||
ci-dessous. Certains sont plus dangereux que d'autres.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mailbox">
|
||||
<div class="mailbox-toolbar">
|
||||
<div class="mailbox-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="mailbox-label">Test anti-robot : cochez les mails qui ressemblent à une arnaque</div>
|
||||
</div>
|
||||
|
||||
<div class="captcha-target">
|
||||
<span>Vérification demandée</span>
|
||||
<strong>Repérer le phishing</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mail-list">
|
||||
<?php foreach ($round['emails'] as $index => $mail): ?>
|
||||
<label class="mail-option <?php echo $locked ? 'mail-option-disabled' : ''; ?>" style="--mail-index: <?php echo (int) $index; ?>">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="selected_mails[]"
|
||||
value="<?php echo htmlspecialchars($mail['id'], ENT_QUOTES, 'UTF-8'); ?>"
|
||||
<?php echo $locked ? 'disabled' : ''; ?>
|
||||
>
|
||||
<span class="mail-row">
|
||||
<span class="mail-check"><i class="fa-solid fa-check"></i></span>
|
||||
|
||||
<div class="captcha-stats">
|
||||
<div class="captcha-attempts">
|
||||
<span>Essais restants</span>
|
||||
HTML;
|
||||
<span class="mail-avatar">
|
||||
<?php echo htmlspecialchars(firstLetterSafe($mail['sender']), ENT_QUOTES, 'UTF-8'); ?>
|
||||
</span>
|
||||
|
||||
// Compteur d'essais restants
|
||||
echo '<strong>' . htmlspecialchars((string) $attemptsLeft, ENT_QUOTES, 'UTF-8')
|
||||
. ' / ' . htmlspecialchars((string) $maxAttempts, ENT_QUOTES, 'UTF-8') . '</strong>';
|
||||
<span class="mail-sender">
|
||||
<?php echo htmlspecialchars($mail['sender'], ENT_QUOTES, 'UTF-8'); ?>
|
||||
</span>
|
||||
|
||||
// Une pastille par essai : pleine tant qu'il reste des essais, vide ensuite
|
||||
echo '<div class="attempts-dots">';
|
||||
<span class="mail-content">
|
||||
<span class="mail-subject">
|
||||
<?php echo htmlspecialchars($mail['subject'], ENT_QUOTES, 'UTF-8'); ?>
|
||||
</span>
|
||||
<span class="mail-preview">
|
||||
<?php echo htmlspecialchars($mail['preview'], ENT_QUOTES, 'UTF-8'); ?>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
echo '<span class="' . ($i < $attemptsLeft ? 'dot-filled' : 'dot-empty') . '"></span>';
|
||||
}
|
||||
<span class="mail-time">
|
||||
<?php echo htmlspecialchars($mail['time'], ENT_QUOTES, 'UTF-8'); ?>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
<input
|
||||
type="hidden"
|
||||
name="captcha_token"
|
||||
value="<?php echo htmlspecialchars($round['token'], ENT_QUOTES, 'UTF-8'); ?>"
|
||||
>
|
||||
|
||||
// Plus d'essais disponibles : on propose une nouvelle série de mails
|
||||
if ($locked) {
|
||||
echo '<a class="captcha-reset" href="./captcha.php?reload=1">Recharger une nouvelle série</a>';
|
||||
}
|
||||
<!-- Champ piège (honeypot) : invisible et exclu du tabulateur pour un humain,
|
||||
mais souvent rempli automatiquement par les robots qui remplissent tout -->
|
||||
<div class="captcha-honeypot" aria-hidden="true">
|
||||
<label for="website">Laisse ce champ vide</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
echo '</div>';
|
||||
<div class="captcha-footer">
|
||||
<p class="captcha-note">
|
||||
Tu as plusieurs essais. Les faux mails utilisent souvent l’urgence, les cadeaux improbables, les menaces ou les demandes de mot de passe.
|
||||
</p>
|
||||
|
||||
// Message d'erreur du dernier essai raté
|
||||
if ($error !== '') {
|
||||
echo '<div class="captcha-error">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</div>';
|
||||
}
|
||||
|
||||
echo <<<'HTML'
|
||||
<div class="mailbox">
|
||||
<div class="mailbox-toolbar">
|
||||
<div class="mailbox-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="mailbox-label">Test anti-robot : cochez les mails qui ressemblent à une arnaque</div>
|
||||
</div>
|
||||
|
||||
<div class="mail-list">
|
||||
HTML;
|
||||
|
||||
// Un bloc "mail" cliquable par message de la série tirée au sort
|
||||
foreach ($round['emails'] as $index => $mail) {
|
||||
$desactive = $locked ? 'disabled' : '';
|
||||
$classeOption = $locked ? 'mail-option mail-option-disabled' : 'mail-option ';
|
||||
|
||||
echo '<label class="' . $classeOption . '" style="--mail-index: ' . (int) $index . '">';
|
||||
echo '<input type="checkbox" name="selected_mails[]" value="'
|
||||
. htmlspecialchars($mail['id'], ENT_QUOTES, 'UTF-8') . '" ' . $desactive . '>';
|
||||
echo '<span class="mail-row">';
|
||||
echo '<span class="mail-check"><i class="fa-solid fa-check"></i></span>';
|
||||
echo '<span class="mail-avatar">' . htmlspecialchars(firstLetterSafe($mail['sender']), ENT_QUOTES, 'UTF-8') . '</span>';
|
||||
echo '<span class="mail-sender">' . htmlspecialchars($mail['sender'], ENT_QUOTES, 'UTF-8') . '</span>';
|
||||
echo '<span class="mail-content">';
|
||||
echo '<span class="mail-subject">' . htmlspecialchars($mail['subject'], ENT_QUOTES, 'UTF-8') . '</span>';
|
||||
echo '<span class="mail-preview">' . htmlspecialchars($mail['preview'], ENT_QUOTES, 'UTF-8') . '</span>';
|
||||
echo '</span>';
|
||||
echo '<span class="mail-time">' . htmlspecialchars($mail['time'], ENT_QUOTES, 'UTF-8') . '</span>';
|
||||
echo '</span>';
|
||||
echo '</label>';
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
|
||||
// Jeton de la série en cours : permet de vérifier les réponses côté serveur
|
||||
echo '<input type="hidden" name="captcha_token" value="'
|
||||
. htmlspecialchars($round['token'], ENT_QUOTES, 'UTF-8') . '">';
|
||||
|
||||
// Champ piège (honeypot) : invisible et exclu du tabulateur pour un humain,
|
||||
// mais souvent rempli automatiquement par les robots qui remplissent tout
|
||||
echo <<<'HTML'
|
||||
<div class="captcha-honeypot" aria-hidden="true">
|
||||
<label for="website">Laisse ce champ vide</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="captcha-footer">
|
||||
<p class="captcha-note">
|
||||
Tu as plusieurs essais. Les faux mails utilisent souvent l’urgence, les cadeaux improbables, les menaces ou les demandes de mot de passe.
|
||||
</p>
|
||||
|
||||
<div class="captcha-actions">
|
||||
HTML;
|
||||
|
||||
// Bouton de validation, remplacé par un lien de relance quand les essais sont épuisés
|
||||
if ($locked) {
|
||||
echo '<a class="captcha-secondary-button" href="./captcha.php?reload=1">Nouvelle série</a>';
|
||||
} else {
|
||||
echo '<button type="submit" class="captcha-submit">Valider ma sélection</button>';
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
echo '</form>';
|
||||
}
|
||||
?>
|
||||
<div class="captcha-actions">
|
||||
<?php if ($locked): ?>
|
||||
<a class="captcha-secondary-button" href="./captcha.php?reload=1">Nouvelle série</a>
|
||||
<?php else: ?>
|
||||
<button type="submit" class="captcha-submit">Valider ma sélection</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -114,26 +114,6 @@ CREATE TABLE IF NOT EXISTS logo_banners (
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- Compteur de tentatives ratées sur le code PIN du "Mode présentation" (voir
|
||||
-- pages/presentation.php), une ligne par IP visiteuse. ip_hash : jamais l'IP en clair,
|
||||
-- juste son hash SHA-256, suffisant pour reconnaître le même visiteur sans stocker une
|
||||
-- donnée personnelle. locked_until : posé une fois le nombre max de tentatives atteint,
|
||||
-- au-delà duquel le visiteur doit se connecter en administrateur plutôt que ressaisir le code
|
||||
CREATE TABLE IF NOT EXISTS presentation_attempts (
|
||||
ip_hash CHAR(64) NOT NULL,
|
||||
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
locked_until DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (ip_hash)
|
||||
);
|
||||
|
||||
-- Contenu markdown du mode présentation. Pas de valeur par défaut pour presentation_pin_hash
|
||||
-- ici volontairement : tant qu'aucun code n'est configuré depuis le panel admin, personne
|
||||
-- ne peut entrer (voir verifierPinPresentation dans functions/presentationAuth.php)
|
||||
INSERT INTO settings (name, value) VALUES
|
||||
('presentation_markdown', '# Mode présentation\n\nContenu à venir, modifiable depuis le panel admin.')
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value);
|
||||
|
||||
-- PDF de projects/experiences/schools : tables associées plutôt qu'une colonne sur la table
|
||||
-- parente, pour que les pages qui LISTENT ces éléments n'aient jamais à charger le contenu
|
||||
-- binaire des PDF en mémoire (seul le nom de fichier y est utilisé)
|
||||
|
||||
Binary file not shown.
+7
-14
@@ -17,16 +17,13 @@ if (isset($_GET['success']) && $_GET['success'] === '1') {
|
||||
?>
|
||||
</main>
|
||||
|
||||
<?php
|
||||
// Notification toast : disparaît toute seule, clic pour fermer.
|
||||
// Elle n'est affichée que si une des conditions plus haut a rempli $toast.
|
||||
if ($toast !== null) {
|
||||
echo '<div class="toast" onclick="this.remove()" role="status">';
|
||||
echo '<i class="fa-solid fa-circle-check"></i>';
|
||||
echo '<span>' . $toast . '</span>';
|
||||
echo '</div>';
|
||||
}
|
||||
?>
|
||||
<?php if ($toast !== null): ?>
|
||||
<!-- Notification toast : disparaît toute seule, clic pour fermer -->
|
||||
<div class="toast" onclick="this.remove()" role="status">
|
||||
<i class="fa-solid fa-circle-check"></i>
|
||||
<span><?php echo $toast; ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-content">
|
||||
@@ -39,10 +36,6 @@ if ($toast !== null) {
|
||||
<a href="https://www.instagram.com/ours_one_" target="_blank" rel="noopener noreferrer">Instagram</a>
|
||||
<a href="https://git.eldorianet.work/esteban" target="_blank" rel="noopener noreferrer">Git</a>
|
||||
<span class="discord-handle" title="@ours1" onclick="alert('Discord : @ours1')">Discord</span>
|
||||
<a href="../pages/presentation.php">Mode présentation</a>
|
||||
<!-- Le tableau de synthèse E4 est affiché dans le site (voir pages/synthese.php),
|
||||
avec un bouton plein écran et un bouton de téléchargement au-dessus de l'aperçu -->
|
||||
<a href="../pages/synthese.php">Tableau de synthèse Oral</a>
|
||||
<a href="../pages/cgu.php">CGU</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-13
@@ -14,14 +14,11 @@ require_once('../scripts/requireCaptcha.php');
|
||||
// Ajout des logs sur toutes les pages
|
||||
require_once('../functions/sendLogs.php');
|
||||
|
||||
// Versionnement des fichiers statiques (casse le cache navigateur à chaque modif du CSS)
|
||||
require_once(__DIR__ . '/../functions/assets.php');
|
||||
|
||||
// Log de la visite de la page sur Discord (anti-spam : 1 log max par page / minute / visiteur)
|
||||
sendPageLog();
|
||||
|
||||
// Titre de la page : chaque page peut définir $pageTitle avant d'inclure le header
|
||||
$pageTitle = isset($pageTitle) ? $pageTitle . ' - Esteban MOUSSU' : 'Esteban MOUSSU - Portfolio';
|
||||
$pageTitle = isset($pageTitle) ? $pageTitle . ' — Esteban MOUSSU' : 'Esteban MOUSSU — Portfolio';
|
||||
|
||||
// Description : chaque page peut définir $pageDescription avant d'inclure le header
|
||||
// (sinon description générique, utilisée pour le SEO et les aperçus de partage)
|
||||
@@ -52,17 +49,15 @@ $currentPage = basename($_SERVER['PHP_SELF']);
|
||||
<meta property="og:title" content="<?php echo htmlspecialchars($pageTitle); ?>">
|
||||
<meta property="og:description" content="<?php echo htmlspecialchars($pageDescription); ?>">
|
||||
<meta property="og:url" content="<?php echo htmlspecialchars($pageUrl); ?>">
|
||||
<meta property="og:image" content="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.png">
|
||||
<meta property="og:image" content="https://estebanadmin.fr/memoji.png">
|
||||
<meta property="og:locale" content="fr_FR">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="<?php echo htmlspecialchars($pageTitle); ?>">
|
||||
<meta name="twitter:description" content="<?php echo htmlspecialchars($pageDescription); ?>">
|
||||
<meta name="twitter:image" content="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.png">
|
||||
<meta name="twitter:image" content="https://estebanadmin.fr/memoji.png">
|
||||
|
||||
<!-- Favicon du site, hébergé sur le CDN plutôt que dans le dépôt : une seule
|
||||
adresse à changer pour mettre le logo à jour partout, et le fichier n'alourdit
|
||||
plus le projet -->
|
||||
<link rel="icon" type="image/png" href="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.png">
|
||||
<!-- Favicon du site -->
|
||||
<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>
|
||||
@@ -78,9 +73,8 @@ $currentPage = basename($_SERVER['PHP_SELF']);
|
||||
<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">
|
||||
|
||||
<!-- CSS principal du site. urlAsset ajoute "?v=<date de modif>" pour que le navigateur
|
||||
recharge la feuille de style dès qu'elle change (voir functions/assets.php) -->
|
||||
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('styles/primary_css.css')); ?>">
|
||||
<!-- Ajout du CSS Primaire (fait par IA) -->
|
||||
<link rel="stylesheet" href="../styles/primary_css.css">
|
||||
<!-- Ajout de Font Awesome pour les icones de home.php-->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||
<!-- Devicon : équivalent gratuit de Font Awesome dédié aux logos d'outils/langages/frameworks,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
// Fonction qui construit l'URL d'un fichier statique (CSS, JS, image) en lui ajoutant un
|
||||
// numéro de version basé sur la date de dernière modification du fichier sur le disque.
|
||||
//
|
||||
// Pourquoi : le navigateur garde les fichiers CSS en cache parfois plusieurs jours, donc un
|
||||
// visiteur déjà venu continuait de voir l'ancien style après une mise en ligne (d'où la
|
||||
// notice "videz votre cache" affichée sur la page d'accueil). Avec "?v=1756329123" dans
|
||||
// l'URL, l'adresse change dès que le fichier change : le navigateur la considère comme une
|
||||
// nouvelle ressource et la retélécharge, sans qu'il ait à faire quoi que ce soit. Tant que
|
||||
// le fichier ne bouge pas, l'URL reste identique et le cache continue de servir (pas de
|
||||
// téléchargement inutile à chaque visite).
|
||||
//
|
||||
// filemtime plutôt que le hash du dernier commit : ça ne dépend pas de la présence de git
|
||||
// sur le serveur, et ça suit aussi les fichiers modifiés directement en production.
|
||||
//
|
||||
// $chemin : chemin du fichier depuis la racine du site (ex : 'styles/primary_css.css')
|
||||
// $prefixe : préfixe à mettre dans le HTML selon l'emplacement de la page qui l'appelle
|
||||
// ('../' pour les pages de /pages, './' pour les fichiers à la racine)
|
||||
function urlAsset($chemin, $prefixe = '../') {
|
||||
$fichier = __DIR__ . '/../' . $chemin;
|
||||
|
||||
// @ : si le fichier est introuvable on renvoie l'URL sans version plutôt que de
|
||||
// provoquer un warning PHP au milieu du <head>
|
||||
$version = @filemtime($fichier);
|
||||
|
||||
if ($version === false) {
|
||||
return $prefixe . $chemin;
|
||||
}
|
||||
|
||||
return $prefixe . $chemin . '?v=' . $version;
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
<?php
|
||||
|
||||
// Fonctions liées au verrouillage anti-bruteforce du code PIN du "Mode présentation"
|
||||
// (voir pages/presentation.php). Le compteur de tentatives est stocké côté serveur, par
|
||||
// adresse IP (hashée, jamais en clair), pour ne pas pouvoir être remis à zéro en vidant
|
||||
// juste les cookies ou en ouvrant une fenêtre de navigation privée.
|
||||
|
||||
require_once(__DIR__ . '/databaseConnection.php');
|
||||
|
||||
// Nombre de chiffres attendu pour le code PIN
|
||||
define('PRESENTATION_PIN_LENGTH', 9);
|
||||
|
||||
// Durée du blocage une fois le nombre maximum de tentatives atteint (en heures)
|
||||
define('PRESENTATION_LOCK_HOURS', 24);
|
||||
|
||||
// Hash SHA-256 de l'IP du visiteur : sert de clé dans la table presentation_attempts.
|
||||
// On ne stocke jamais l'IP en clair, un hash suffit pour reconnaître le même visiteur
|
||||
// d'une requête à l'autre sans conserver une donnée personnelle en clair.
|
||||
function hashIpPresentation() {
|
||||
return hash('sha256', $_SERVER['REMOTE_ADDR'] ?? 'inconnue');
|
||||
}
|
||||
|
||||
// Vérifie le code PIN saisi contre le hash enregistré dans les réglages du site.
|
||||
// Renvoie false si aucun code n'a encore été configuré (accès bloqué par défaut tant
|
||||
// que l'admin n'a pas défini de code depuis le panel).
|
||||
function verifierPinPresentation($pinSaisi) {
|
||||
require_once(__DIR__ . '/listSettings.php');
|
||||
$hash = recupererSetting('presentation_pin_hash');
|
||||
|
||||
if (empty($hash) || strlen($pinSaisi) !== PRESENTATION_PIN_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return password_verify($pinSaisi, $hash);
|
||||
}
|
||||
|
||||
// Récupère l'état de blocage actuel pour une IP (hashée) donnée.
|
||||
// Renvoie ['attempts' => int, 'locked_until' => string|null] ou null si cette IP n'a
|
||||
// encore jamais raté de tentative.
|
||||
function recupererTentativePresentation($ipHash) {
|
||||
global $dbh;
|
||||
|
||||
try {
|
||||
$stmt = $dbh->prepare('SELECT attempts, locked_until FROM presentation_attempts WHERE ip_hash = :ip');
|
||||
$stmt->bindValue(':ip', $ipHash, PDO::PARAM_STR);
|
||||
$stmt->execute();
|
||||
$ligne = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $ligne ?: null;
|
||||
} catch (PDOException $e) {
|
||||
// Tant que la table n'existe pas encore (migration SQL pas encore appliquée),
|
||||
// on considère qu'il n'y a pas de blocage plutôt que de casser la page
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Vrai si cette IP est actuellement bloquée (trop de tentatives ratées récemment)
|
||||
function presentationEstVerrouillee($ipHash, $maxTentatives) {
|
||||
$etat = recupererTentativePresentation($ipHash);
|
||||
return $etat
|
||||
&& (int) $etat['attempts'] >= $maxTentatives
|
||||
&& !empty($etat['locked_until'])
|
||||
&& strtotime($etat['locked_until']) > time();
|
||||
}
|
||||
|
||||
// Enregistre une tentative ratée : incrémente le compteur de cette IP, et pose un
|
||||
// blocage de PRESENTATION_LOCK_HOURS heures si le maximum est atteint.
|
||||
// Renvoie le nouvel état, au même format que recupererTentativePresentation().
|
||||
function enregistrerEchecPresentation($ipHash, $maxTentatives) {
|
||||
global $dbh;
|
||||
|
||||
$etatActuel = recupererTentativePresentation($ipHash);
|
||||
$tentatives = $etatActuel ? (int) $etatActuel['attempts'] + 1 : 1;
|
||||
$verrouJusqua = $tentatives >= $maxTentatives
|
||||
? date('Y-m-d H:i:s', time() + PRESENTATION_LOCK_HOURS * 3600)
|
||||
: null;
|
||||
|
||||
try {
|
||||
$stmt = $dbh->prepare(
|
||||
'INSERT INTO presentation_attempts (ip_hash, attempts, locked_until) VALUES (:ip, :tentatives, :verrou)
|
||||
ON DUPLICATE KEY UPDATE attempts = VALUES(attempts), locked_until = VALUES(locked_until)'
|
||||
);
|
||||
$stmt->execute([
|
||||
':ip' => $ipHash,
|
||||
':tentatives' => $tentatives,
|
||||
':verrou' => $verrouJusqua
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
// Si la table n'existe pas encore, on ne bloque pas le visiteur pour autant
|
||||
}
|
||||
|
||||
return ['attempts' => $tentatives, 'locked_until' => $verrouJusqua];
|
||||
}
|
||||
|
||||
// Efface le compteur de tentatives d'une IP : code correct, connexion admin, ou
|
||||
// déblocage manuel depuis le panel admin
|
||||
function reinitialiserTentativesPresentation($ipHash) {
|
||||
global $dbh;
|
||||
|
||||
try {
|
||||
$stmt = $dbh->prepare('DELETE FROM presentation_attempts WHERE ip_hash = :ip');
|
||||
$stmt->bindValue(':ip', $ipHash, PDO::PARAM_STR);
|
||||
$stmt->execute();
|
||||
} catch (PDOException $e) {
|
||||
// Rien à faire si la table n'existe pas encore
|
||||
}
|
||||
}
|
||||
|
||||
// Liste des IP actuellement bloquées, pour le panel admin
|
||||
function listerTentativesVerrouillees() {
|
||||
global $dbh;
|
||||
|
||||
try {
|
||||
$stmt = $dbh->query(
|
||||
'SELECT ip_hash, attempts, locked_until FROM presentation_attempts
|
||||
WHERE locked_until IS NOT NULL AND locked_until > NOW()
|
||||
ORDER BY locked_until DESC'
|
||||
);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Affiche, dans le panel admin, la liste des IP actuellement bloquées avec un bouton
|
||||
// de déblocage manuel pour chacune (utile si un visiteur légitime se fait bloquer)
|
||||
function afficherTentativesVerrouilleesAdmin() {
|
||||
$lignes = listerTentativesVerrouillees();
|
||||
|
||||
if (empty($lignes)) {
|
||||
echo '<p class="admin-notice">Aucune IP bloquée actuellement.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<div class="projects-list">';
|
||||
|
||||
foreach ($lignes as $ligne) {
|
||||
// On n'affiche jamais l'IP en clair : seulement une courte empreinte de son hash,
|
||||
// suffisante pour distinguer deux blocages différents sans exposer de donnée perso
|
||||
$empreinte = substr($ligne['ip_hash'], 0, 12);
|
||||
$jusqua = date('d/m/Y à H:i', strtotime($ligne['locked_until']));
|
||||
|
||||
echo '<div class="project-card">';
|
||||
echo '<div class="project-content">';
|
||||
echo '<h2>IP bloquée</h2>';
|
||||
echo '<p><strong>Empreinte :</strong> ' . htmlspecialchars($empreinte) . '…</p>';
|
||||
echo '<p><strong>Tentatives ratées :</strong> ' . (int) $ligne['attempts'] . '</p>';
|
||||
echo '<p><strong>Bloquée jusqu\'au :</strong> ' . htmlspecialchars($jusqua) . '</p>';
|
||||
echo '<div class="admin-actions">';
|
||||
echo '<a href="../scripts/unlockPresentationAttempt.php?ip=' . urlencode($ligne['ip_hash'])
|
||||
. '&csrf_token=' . urlencode(csrfToken()) . '" class="btn-submit btn-delete"'
|
||||
. ' onclick="return confirm(\'Débloquer cette IP ?\');">Débloquer</a>';
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+45
-181
@@ -14,7 +14,6 @@ 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
|
||||
@@ -63,11 +62,6 @@ $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
|
||||
?>
|
||||
|
||||
@@ -85,7 +79,6 @@ $presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
|
||||
<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>
|
||||
@@ -108,13 +101,9 @@ $presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
|
||||
<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
|
||||
// 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'] . '">';
|
||||
}
|
||||
?>
|
||||
<?php if ($projetAModifier): ?>
|
||||
<input type="hidden" name="id" value="<?php echo (int) $projetAModifier['id']; ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<label>Titre *</label>
|
||||
<input type="text" name="title" required value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['title']) : ''; ?>">
|
||||
@@ -135,22 +124,16 @@ $presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
|
||||
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon projet Description complète en **markdown**..."><?php echo $projetAModifier ? htmlspecialchars($projetAModifier['markdown'] ?? '') : ''; ?></textarea>
|
||||
|
||||
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
|
||||
<?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>';
|
||||
}
|
||||
?>
|
||||
<?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; ?>
|
||||
<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>
|
||||
@@ -183,13 +166,9 @@ $presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
|
||||
<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
|
||||
// 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'] . '">';
|
||||
}
|
||||
?>
|
||||
<?php if ($xpAModifier): ?>
|
||||
<input type="hidden" name="id" value="<?php echo (int) $xpAModifier['id']; ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<label>Titre *</label>
|
||||
<input type="text" name="title" required value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['title']) : ''; ?>">
|
||||
@@ -216,22 +195,16 @@ $presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
|
||||
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon expérience Description complète en **markdown**..."><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['markdown'] ?? '') : ''; ?></textarea>
|
||||
|
||||
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
|
||||
<?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>';
|
||||
}
|
||||
?>
|
||||
<?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; ?>
|
||||
<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>
|
||||
@@ -264,13 +237,9 @@ $presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
|
||||
<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
|
||||
// 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'] . '">';
|
||||
}
|
||||
?>
|
||||
<?php if ($ecoleAModifier): ?>
|
||||
<input type="hidden" name="id" value="<?php echo (int) $ecoleAModifier['id']; ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<label>Nom de l'école *</label>
|
||||
<input type="text" name="title" required value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['title']) : ''; ?>">
|
||||
@@ -294,22 +263,16 @@ $presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
|
||||
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon école Description complète en **markdown**..."><?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['markdown'] ?? '') : ''; ?></textarea>
|
||||
|
||||
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
|
||||
<?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>';
|
||||
}
|
||||
?>
|
||||
<?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; ?>
|
||||
<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>
|
||||
@@ -342,13 +305,9 @@ $presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
|
||||
<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
|
||||
// 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'] . '">';
|
||||
}
|
||||
?>
|
||||
<?php if ($skillAModifier): ?>
|
||||
<input type="hidden" name="id" value="<?php echo (int) $skillAModifier['id']; ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<label>Titre de la catégorie *</label>
|
||||
<input type="text" name="title" required value="<?php echo $skillAModifier ? htmlspecialchars($skillAModifier['title']) : ''; ?>">
|
||||
@@ -374,67 +333,6 @@ $presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
|
||||
</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  -->
|
||||
<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 Texte de présentation en **markdown**... "><?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>
|
||||
@@ -452,13 +350,9 @@ $presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
|
||||
<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
|
||||
// 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'] . '">';
|
||||
}
|
||||
?>
|
||||
<?php if ($logoAModifier): ?>
|
||||
<input type="hidden" name="id" value="<?php echo (int) $logoAModifier['id']; ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Seul le bandeau "Ils m'ont fait confiance" est géré ici -->
|
||||
<input type="hidden" name="category" value="partner">
|
||||
@@ -710,36 +604,6 @@ $presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Bouton "Insérer une image" du mode présentation : ajoute la syntaxe markdown d'une
|
||||
image () à 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 = '';
|
||||
|
||||
// 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'); ?>
|
||||
+7
-12
@@ -24,18 +24,13 @@ $contactAddressEnabled = (recupererSetting('contact_address_enabled') ?? '1') ==
|
||||
<div class="contact-info">
|
||||
<p class="contact-title">Contact</p>
|
||||
<p class="contact-name">Esteban MOUSSU</p>
|
||||
<?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>';
|
||||
}
|
||||
?>
|
||||
<?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; ?>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'envoi -->
|
||||
|
||||
+49
-77
@@ -87,78 +87,58 @@ if (!empty($element['tags'])) {
|
||||
|
||||
<article class="details-card">
|
||||
|
||||
<?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>';
|
||||
}
|
||||
?>
|
||||
<?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; ?>
|
||||
|
||||
<div class="details-content">
|
||||
|
||||
<span class="details-type"><?php echo $infos['label']; ?></span>
|
||||
<h1><?php echo $titre; ?></h1>
|
||||
|
||||
<?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">';
|
||||
<!-- 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; ?>
|
||||
|
||||
if (!empty($xptype)) {
|
||||
echo '<span><i class="fa-solid fa-tag"></i> ' . $xptype . '</span>';
|
||||
}
|
||||
<?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($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>';
|
||||
}
|
||||
?>
|
||||
<?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; ?>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -166,16 +146,10 @@ if (!empty($element['tags'])) {
|
||||
|
||||
</div>
|
||||
|
||||
<?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'
|
||||
<?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>
|
||||
<script>
|
||||
(function () {
|
||||
var source = document.getElementById('markdown-source');
|
||||
@@ -185,9 +159,7 @@ if ($hasMarkdown) {
|
||||
cible.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
|
||||
})();
|
||||
</script>
|
||||
JS;
|
||||
}
|
||||
?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// Ajout du footer
|
||||
|
||||
+29
-47
@@ -57,39 +57,27 @@ $statsAffichables = array_filter([
|
||||
'technologies' => ['valeur' => $statsHome['technologies'], 'label' => 'Technologies maîtrisées'],
|
||||
], function ($stat) { return $stat['valeur'] > 0; });
|
||||
?>
|
||||
<?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($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; ?>
|
||||
|
||||
// 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>';
|
||||
}
|
||||
?>
|
||||
<?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; ?>
|
||||
|
||||
<!-- Section d'exploration rapide -->
|
||||
<section class="quick-links-section" id="explorer">
|
||||
@@ -135,21 +123,15 @@ if (!empty(listeIconesCompetences())) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?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 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
|
||||
// Ajout du footer
|
||||
|
||||
+1
-11
@@ -15,13 +15,9 @@ 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">
|
||||
|
||||
@@ -30,12 +26,6 @@ $redirect = ($_GET['redirect'] ?? '') === 'presentation' ? 'presentation' : '';
|
||||
|
||||
<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">
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
<?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">
|
||||
<!-- Favicon servie par le CDN (voir elements/header.php) -->
|
||||
<link rel="icon" type="image/png" href="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.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 : 
|
||||
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');
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
// Page du tableau de synthèse (épreuve E4 du BTS SIO), affiché directement dans le site.
|
||||
// Le PDF est un fichier statique du dépôt (dossier documents/), pas un PDF stocké en base
|
||||
// comme ceux des projets/expériences : il n'y en a qu'un seul et il ne change quasiment
|
||||
// jamais, une table dédiée n'apporterait rien ici.
|
||||
|
||||
// Titre de l'onglet
|
||||
$pageTitle = 'Tableau de synthèse';
|
||||
|
||||
// Description pour le SEO et les aperçus de partage
|
||||
$pageDescription = 'Tableau de synthèse des réalisations professionnelles d\'Esteban MOUSSU, épreuve E4 du BTS SIO option SISR.';
|
||||
|
||||
// On ajoute ici le header
|
||||
require_once('./../elements/header.php');
|
||||
|
||||
// Chemin du PDF, écrit une seule fois : il sert à la fois à l'aperçu et au bouton
|
||||
// d'ouverture en plein écran juste en dessous
|
||||
$cheminPdf = '../documents/tableau-de-synthese-e4.pdf';
|
||||
?>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Tableau de synthèse</h1>
|
||||
<p>Réalisations professionnelles présentées à l'épreuve E4 du BTS SIO, option SISR.</p>
|
||||
</div>
|
||||
|
||||
<div class="synthese-page">
|
||||
|
||||
<!-- Barre au-dessus de l'aperçu : rappel du document et sorties possibles.
|
||||
Le tableau est dense (en-têtes de colonnes en texte vertical), donc on met en
|
||||
avant l'ouverture plein écran, qui donne le zoom de la visionneuse du navigateur -->
|
||||
<div class="pdf-viewer-toolbar">
|
||||
<span><i class="fa-solid fa-file-pdf"></i> Tableau de synthèse E4 (PDF, 1 page)</span>
|
||||
<span class="synthese-actions">
|
||||
<!-- Une seule action mise en avant : l'ouverture plein écran, celle qui rend
|
||||
vraiment le tableau lisible. Le téléchargement reste en bouton discret,
|
||||
même style que sur les fiches projets (voir pages/details.php) -->
|
||||
<a class="btn btn-primary pdf-download-btn" href="<?php echo $cheminPdf; ?>" target="_blank" rel="noopener noreferrer">
|
||||
<i class="fa-solid fa-up-right-from-square"></i> Plein écran
|
||||
</a>
|
||||
<a class="btn btn-ghost pdf-download-btn" href="<?php echo $cheminPdf; ?>" download>
|
||||
<i class="fa-solid fa-download"></i> Télécharger
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Aperçu intégré. L'attribut title sert aux lecteurs d'écran, et le contenu de
|
||||
secours s'affiche si le navigateur ne sait pas afficher un PDF dans une iframe
|
||||
(c'est le cas de plusieurs navigateurs mobiles) -->
|
||||
<!-- #view=FitH ajuste le document à la largeur, pagemode=none masque le panneau de
|
||||
vignettes qui mange la moitié de la place sur un écran étroit (respecté par
|
||||
Firefox, ignoré sans dommage par les autres) -->
|
||||
<iframe class="pdf-viewer synthese-viewer" src="<?php echo $cheminPdf; ?>#view=FitH&pagemode=none"
|
||||
title="Tableau de synthèse des réalisations professionnelles, épreuve E4 du BTS SIO">
|
||||
<p class="synthese-repli">
|
||||
Ton navigateur ne peut pas afficher le PDF directement.
|
||||
<a href="<?php echo $cheminPdf; ?>" target="_blank" rel="noopener noreferrer">Ouvre-le dans un nouvel onglet</a>.
|
||||
</p>
|
||||
</iframe>
|
||||
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Ajout du footer
|
||||
require_once('../elements/footer.php');
|
||||
?>
|
||||
@@ -59,11 +59,8 @@ try {
|
||||
// Log Discord : connexion réussie
|
||||
sendLog("Connexion réussie", $user['username']);
|
||||
|
||||
// Redirection après connexion : vers presentation.php si on vient de s'y faire bloquer
|
||||
// (voir pages/login.php, seule valeur acceptée pour ne pas ouvrir une redirection
|
||||
// arbitraire), vers home.php sinon
|
||||
//Redirection vers home.php puisque que l'utilisateur est forcément connecté
|
||||
// (?login=1 déclenche la notification "Connexion réussie" sur la page)
|
||||
$pageRetour = ($_POST['redirect'] ?? '') === 'presentation' ? 'presentation.php' : 'home.php';
|
||||
header('Location: ./../pages/' . $pageRetour . '?login=1');
|
||||
header('Location: ./../pages/home.php?login=1');
|
||||
exit;
|
||||
?>
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
// On démarre la session
|
||||
require_once(__DIR__ . '/../functions/security.php');
|
||||
// On ajoute la DB
|
||||
require_once('../functions/databaseConnection.php');
|
||||
// On ajoute les logs Discord
|
||||
require_once('../functions/sendLogs.php');
|
||||
// reinitialiserTentativesPresentation
|
||||
require_once('../functions/presentationAuth.php');
|
||||
|
||||
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
|
||||
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
||||
header('Location: ../pages/home.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
|
||||
requireCsrfToken();
|
||||
|
||||
$ipHash = $_GET['ip'] ?? '';
|
||||
|
||||
if ($ipHash !== '') {
|
||||
reinitialiserTentativesPresentation($ipHash);
|
||||
sendLog('Déblocage manuel du mode présentation pour une IP');
|
||||
}
|
||||
|
||||
// On renvoie l'utilisateur sur admin.php, directement sur la section Mode présentation
|
||||
header('Location: ../pages/admin.php#admin-presentation');
|
||||
exit;
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
// On démarre la session
|
||||
require_once(__DIR__ . '/../functions/security.php');
|
||||
// On ajoute la DB
|
||||
require_once('../functions/databaseConnection.php');
|
||||
// On ajoute les logs Discord
|
||||
require_once('../functions/sendLogs.php');
|
||||
// PRESENTATION_PIN_LENGTH
|
||||
require_once('../functions/presentationAuth.php');
|
||||
|
||||
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
|
||||
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
||||
header('Location: ../pages/home.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
|
||||
requireCsrfToken();
|
||||
|
||||
// La requête doit être un POST sinon on l'a refuse
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: ../pages/admin.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$pin = trim($_POST['pin'] ?? '');
|
||||
|
||||
// Le code doit faire exactement 9 chiffres, rien d'autre : sinon on ignore la demande
|
||||
// plutôt que d'enregistrer un code invalide (on ne veut jamais bloquer l'accès par erreur)
|
||||
if (!preg_match('/^\d{' . PRESENTATION_PIN_LENGTH . '}$/', $pin)) {
|
||||
header('Location: ../pages/admin.php#admin-presentation');
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// On ne stocke jamais le code en clair, seulement son hash (comme un mot de passe)
|
||||
$stmt = $dbh->prepare('INSERT INTO settings (name, value) VALUES (:name, :value) ON DUPLICATE KEY UPDATE value = VALUES(value)');
|
||||
$stmt->execute([
|
||||
':name' => 'presentation_pin_hash',
|
||||
':value' => password_hash($pin, PASSWORD_DEFAULT)
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
die("Erreur lors du changement du code PIN : " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Log Discord
|
||||
sendLog('Changement du code PIN du mode présentation');
|
||||
|
||||
// On renvoie l'utilisateur sur admin.php, directement sur la section Mode présentation
|
||||
header('Location: ../pages/admin.php#admin-presentation');
|
||||
exit;
|
||||
@@ -39,12 +39,6 @@ if ($groupeReglages === 'captcha') {
|
||||
'contact_address_line2' => trim($_POST['contact_address_line2'] ?? ''),
|
||||
'contact_address_enabled' => isset($_POST['contact_address_enabled']) ? '1' : '0'
|
||||
];
|
||||
} elseif ($groupeReglages === 'presentation') {
|
||||
// Le code PIN se change à part (voir scripts/updatePresentationPin.php) : il a
|
||||
// besoin d'être haché, contrairement aux autres réglages stockés tels quels ici
|
||||
$reglages = [
|
||||
'presentation_markdown' => trim($_POST['presentation_markdown'] ?? '')
|
||||
];
|
||||
} else {
|
||||
$reglages = [
|
||||
'site_notice_title' => trim($_POST['notice_title'] ?? ''),
|
||||
@@ -71,8 +65,7 @@ try { // On essaie d'executer une requête qui est préparée pour chaque régla
|
||||
$libellesGroupes = [
|
||||
'captcha' => 'Modification du réglage du captcha',
|
||||
'contact' => 'Modification des coordonnées de contact',
|
||||
'notice' => "Modification de la notification de la page d'accueil",
|
||||
'presentation' => 'Modification du contenu du mode présentation'
|
||||
'notice' => "Modification de la notification de la page d'accueil"
|
||||
];
|
||||
sendLog($libellesGroupes[$groupeReglages] ?? "Modification des réglages du site");
|
||||
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
/* ---- MODE PRÉSENTATION (page autonome, plein écran) ----
|
||||
Ce fichier ne s'occupe QUE de la lecture immersive une fois le code déverrouillé.
|
||||
L'écran de saisie du code (avant déverrouillage) vit dans le site normal et est
|
||||
stylé dans primary_css.css, comme le reste des pages (voir pages/presentation.php). */
|
||||
|
||||
/* Barre du haut : pas de nav, juste le rappel du mode + sortie. Même traitement
|
||||
visuel que .site-header (position sticky + flou) pour rester cohérent avec le site */
|
||||
.presentation-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px clamp(16px, 4vw, 48px);
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.presentation-bar-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.presentation-bar-tag i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.presentation-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.presentation-exit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
height: 42px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.presentation-exit:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent-border);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Barre de progression de lecture : ne s'anime que si le navigateur sait suivre le
|
||||
scroll en CSS pur (pas de JS de scroll ici, volontairement, pour rester fluide).
|
||||
Sans ce support, la barre reste simplement invisible : aucune régression visuelle. */
|
||||
.presentation-progress {
|
||||
position: sticky;
|
||||
top: 70px;
|
||||
z-index: 99;
|
||||
height: 3px;
|
||||
background: var(--accent);
|
||||
transform-origin: left;
|
||||
transform: scaleX(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
.presentation-progress {
|
||||
opacity: 1;
|
||||
animation: presentation-progress-scroll linear;
|
||||
animation-timeline: scroll(root);
|
||||
}
|
||||
|
||||
@keyframes presentation-progress-scroll {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Colonne de lecture : centrée, largeur confortable, grand respire au-dessus/en dessous */
|
||||
.presentation-reader {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(48px, 8vw, 96px) clamp(20px, 5vw, 24px) 120px;
|
||||
}
|
||||
|
||||
.presentation-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
/* Le contenu markdown reprend .markdown-content (voir primary_css.css) mais en plus
|
||||
grand : c'est le seul contenu de la page, il peut respirer davantage qu'inséré au
|
||||
milieu d'une fiche projet */
|
||||
.presentation-markdown {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
font-size: 18px;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.presentation-markdown h1 { font-size: clamp(30px, 5vw, 42px); }
|
||||
.presentation-markdown h2 { font-size: clamp(24px, 3.5vw, 30px); }
|
||||
.presentation-markdown h3 { font-size: clamp(20px, 3vw, 24px); }
|
||||
|
||||
.presentation-markdown img {
|
||||
width: 100%;
|
||||
margin: 28px 0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.presentation-bar-tag span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.presentation-reader {
|
||||
padding-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- MODE DIAPORAMA ----
|
||||
Activé dès que le contenu du panel admin contient au moins une ligne "---" : le
|
||||
texte est alors coupé en plusieurs diapositives (voir pages/presentation.php),
|
||||
affichées une par une avec une navigation en bas d'écran plutôt qu'en défilement. */
|
||||
|
||||
.presentation-deck {
|
||||
position: relative;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(48px, 8vw, 96px) clamp(20px, 5vw, 24px) 140px;
|
||||
min-height: calc(100dvh - 70px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Toute la diapositive est cliquable pour avancer/reculer (voir le JS) : le curseur
|
||||
le signale, sauf sur les vrais liens/boutons qui gardent leur propre curseur */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slide {
|
||||
display: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slide.is-active {
|
||||
display: block;
|
||||
animation: slide-enter 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes slide-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.slide.is-active {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* La navigation reprend le même traitement "pilule flottante" que le bouton de sortie */
|
||||
.slide-nav {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 18px;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.slide-nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-nav-btn:hover:not(:disabled) {
|
||||
color: var(--accent-light);
|
||||
border-color: var(--accent-border);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.slide-nav-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slide-progress {
|
||||
width: 90px;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-progress-bar {
|
||||
height: 100%;
|
||||
/* Valeur de repli si le JS n'a pas encore tourné : une diapositive sur le total
|
||||
affiché par le compteur juste à côté (voir le style inline posé en JS ensuite) */
|
||||
width: 20%;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-counter {
|
||||
min-width: 46px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.slide-nav {
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.slide-progress {
|
||||
width: 54px;
|
||||
}
|
||||
}
|
||||
@@ -2496,291 +2496,3 @@ main {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- MODE PRÉSENTATION : écran de saisie du code / écran "trop de tentatives" ----
|
||||
Ces deux écrans vivent dans le chrome normal du site (header + footer), contrairement
|
||||
à la lecture immersive une fois déverrouillé (voir styles/presentation.css) */
|
||||
|
||||
.presentation-lock-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 65vh;
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.presentation-lock-card {
|
||||
background: linear-gradient(180deg, var(--surface) 0%, var(--bg-soft) 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 44px 40px;
|
||||
width: 460px;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.presentation-lock-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent-border);
|
||||
color: var(--accent-light);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.presentation-lock-badge--danger {
|
||||
background: rgba(229, 72, 77, 0.12);
|
||||
border-color: rgba(229, 72, 77, 0.35);
|
||||
color: #ff8a8d;
|
||||
}
|
||||
|
||||
[data-theme="light"] .presentation-lock-badge--danger {
|
||||
color: var(--danger-dark);
|
||||
}
|
||||
|
||||
.presentation-lock-card h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.presentation-lock-card p {
|
||||
margin: 0 0 6px;
|
||||
color: var(--text-soft);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.presentation-lock-card p:last-of-type {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.presentation-lock-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.presentation-lock-error {
|
||||
margin: 16px 0 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(229, 72, 77, 0.12);
|
||||
border: 1px solid rgba(229, 72, 77, 0.3);
|
||||
color: #ff8a8d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-theme="light"] .presentation-lock-error {
|
||||
color: var(--danger-dark);
|
||||
}
|
||||
|
||||
/* Les 9 cases du code PIN */
|
||||
.presentation-pin-form {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.pin-boxes {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.pin-box {
|
||||
width: 38px;
|
||||
height: 48px;
|
||||
text-align: center;
|
||||
background-color: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.pin-box:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Bouton "maintenir pour afficher" sous les cases du code : discret, il ne doit pas
|
||||
attirer l'oeil autant que le bouton Valider */
|
||||
.pin-reveal {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 auto 20px;
|
||||
padding: 7px 14px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
/* Le maintien affiche le code : on empêche la sélection du texte du bouton pendant
|
||||
l'appui prolongé, sinon le navigateur surligne le libellé */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.pin-reveal:hover {
|
||||
color: var(--text-soft);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Pendant que le bouton est maintenu : accent orange pour montrer que le code est visible */
|
||||
.pin-reveal.is-revealing {
|
||||
color: var(--accent-light);
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.pin-reveal:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.presentation-pin-submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.presentation-pin-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Pastilles "tentatives restantes", même principe que sur le captcha */
|
||||
.pin-attempts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pin-attempts-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pin-attempts-dots span {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pin-attempts-dots .dot-filled {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.pin-attempts-dots .dot-empty {
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pin-box {
|
||||
width: 30px;
|
||||
height: 42px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.pin-boxes {
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Petit bouton "Insérer une image" au-dessus du champ markdown du mode présentation
|
||||
(panel admin) : insère la syntaxe markdown au curseur, sans avoir à s'en souvenir */
|
||||
.markdown-toolbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.markdown-insert-image {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-soft);
|
||||
font-family: var(--font);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.markdown-insert-image:hover {
|
||||
color: var(--accent-light);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
/* ---- PAGE TABLEAU DE SYNTHÈSE ----
|
||||
Le PDF est le contenu principal de la page : on lui donne plus de largeur et plus de
|
||||
hauteur que l'aperçu inséré dans une fiche projet, parce que le tableau est dense et
|
||||
qu'il devient vite illisible sur une petite surface */
|
||||
|
||||
.synthese-page {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto clamp(48px, 7vw, 72px);
|
||||
padding: 0 clamp(14px, 4vw, 40px);
|
||||
}
|
||||
|
||||
/* On réutilise .pdf-viewer-toolbar (voir plus haut) mais sans le trait de séparation
|
||||
hérité de .pdf-viewer-wrap, qui n'a pas lieu d'être en haut de page */
|
||||
.synthese-page .pdf-viewer-toolbar {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.synthese-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.synthese-viewer {
|
||||
height: 82vh;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.synthese-repli {
|
||||
color: var(--text-soft);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Sur mobile l'aperçu intégré d'un PDF est souvent inutilisable (plusieurs navigateurs
|
||||
affichent juste une zone vide) : on le garde plus court, les deux boutons au-dessus
|
||||
restant le vrai chemin d'accès au document */
|
||||
@media (max-width: 700px) {
|
||||
.synthese-viewer {
|
||||
height: 60vh;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.synthese-page .pdf-viewer-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user