This commit is contained in:
Esteban
2026-07-02 15:13:54 +02:00
parent a019dcb00d
commit 07bae3eff3
38 changed files with 2039 additions and 226 deletions
+10
View File
@@ -1,6 +1,9 @@
<?php
session_start();
// Ajout des logs Discord (chemin via __DIR__ car captcha.php est à la racine du site)
require_once(__DIR__ . '/functions/sendLogs.php');
// Recharger une nouvelle série si demandé
if (isset($_GET['reload']) && $_GET['reload'] === '1') {
unset($_SESSION['captcha_round']);
@@ -311,13 +314,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($round['attempts_left'] ?? 0) <= 0) {
$locked = true;
$error = 'Perdu. Tu nas plus dessais. Recharge une nouvelle série.';
// Log Discord : visiteur bloqué au captcha (possible robot)
sendLog("Captcha raté : plus aucun essai restant (possible robot)");
} else {
$error = 'Ce nest pas le bon mail. Regarde mieux le message le plus agressif ou trop urgent.';
// Log Discord : mauvaise réponse au captcha
sendLog("Captcha raté : mauvaise réponse (" . $round['attempts_left'] . " essai(s) restant(s))");
}
} else {
$_SESSION['captcha_valid'] = true;
unset($_SESSION['captcha_round']);
$success = true;
// Log Discord : captcha validé, le visiteur accède au site
sendLog("Captcha réussi : nouveau visiteur sur le site");
}
}
@@ -329,6 +338,7 @@ $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>
<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">
+36
View File
@@ -106,3 +106,39 @@ CREATE TABLE IF NOT EXISTS schools (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- markdown : contenu de la page détaillée (LONGTEXT = ~4 Go, taille pratiquement illimitée)
-- link_url : lien externe utilisé à la place du markdown si celui-ci est vide
-- Les deux sont facultatifs : si les deux sont vides, la carte n'est pas cliquable
ALTER TABLE projects
ADD COLUMN markdown LONGTEXT NULL,
ADD COLUMN link_url VARCHAR(255) NULL;
ALTER TABLE experiences
ADD COLUMN markdown LONGTEXT NULL,
ADD COLUMN link_url VARCHAR(255) NULL;
ALTER TABLE schools
ADD COLUMN markdown LONGTEXT NULL,
ADD COLUMN link_url VARCHAR(255) NULL;
-- ============================================================
-- Juillet 2026 (3) : PDF en drag & drop pour les projets,
-- expériences et écoles, lu depuis un mini lecteur intégré
-- ============================================================
-- pdf_data : contenu binaire du PDF (LONGBLOB = ~4 Go, largement suffisant)
-- pdf_filename : nom original du fichier (affiché dans le panel admin et le lecteur)
-- Priorité d'affichage sur la page de détails : markdown > pdf > lien externe
ALTER TABLE projects
ADD COLUMN pdf_data LONGBLOB NULL,
ADD COLUMN pdf_filename VARCHAR(255) NULL;
ALTER TABLE experiences
ADD COLUMN pdf_data LONGBLOB NULL,
ADD COLUMN pdf_filename VARCHAR(255) NULL;
ALTER TABLE schools
ADD COLUMN pdf_data LONGBLOB NULL,
ADD COLUMN pdf_filename VARCHAR(255) NULL;
+21
View File
@@ -1,9 +1,30 @@
<?php
// Ce Footer sera inseré sur des pages grâce a la fonction require_once('./../elements/footer.php')
// Rappel des différentes pages : Acceuil, Projets, Contact
// Notifications "toast" : affichées selon le paramètre présent dans l'URL
// (?success=1 après un message de contact, ?login=1 après connexion, ?logout=1 après déconnexion, ?import=1 après un import)
$toast = null;
if (isset($_GET['success']) && $_GET['success'] === '1') {
$toast = 'Votre message a bien été envoyé !';
} elseif (isset($_GET['login']) && $_GET['login'] === '1') {
$toast = 'Connexion réussie' . (isset($_SESSION['username']) ? ', bonjour ' . htmlspecialchars($_SESSION['username']) . ' !' : ' !');
} elseif (isset($_GET['logout']) && $_GET['logout'] === '1') {
$toast = 'Vous avez bien été déconnecté.';
} elseif (isset($_GET['import']) && $_GET['import'] === '1') {
$toast = 'Les données ont bien été importées.';
}
?>
</main>
<?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">
<p class="footer-copyright">
+6
View File
@@ -14,6 +14,9 @@ require_once('../scripts/requireCaptcha.php');
// Ajout des logs sur toutes les pages
require_once('../functions/sendLogs.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';
@@ -28,6 +31,9 @@ $currentPage = basename($_SERVER['PHP_SELF']);
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($pageTitle); ?></title>
<!-- 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>
(function () {
+22 -3
View File
@@ -11,7 +11,9 @@ function listeProjets() {
// Essayer
try {
// Requête SQL qui récupère titre, tags, description, image_url
$stmt = $dbh->prepare('SELECT id, title, status, tags, description, image_url FROM projects ORDER BY id DESC');
// SELECT * : les colonnes markdown / link_url sont optionnelles (la page fonctionne
// même si la migration SQL n'a pas encore été exécutée)
$stmt = $dbh->prepare('SELECT * FROM projects ORDER BY id DESC');
// Execution de la requête SQL
$stmt->execute();
// Retourne toutes les données collectés dans un ARRAY
@@ -27,7 +29,7 @@ function recupererProjetParId($id) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT id, title, status, tags, description, image_url FROM projects WHERE id = :id');
$stmt = $dbh->prepare('SELECT * FROM projects WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch(PDO::FETCH_ASSOC);
@@ -50,10 +52,14 @@ function afficherProjets() {
// Pour chaque projet dans le ARRAY
foreach ($projets as $projet) {
// On récupère le titre, la description et l'image et le tableau avec les tags
$id = (int) $projet['id'];
$titre = htmlspecialchars($projet['title']);
$description = nl2br(htmlspecialchars($projet['description']));
$image = htmlspecialchars($projet['image_url']);
$status = htmlspecialchars($projet['status'] ?? '');
$markdown = trim($projet['markdown'] ?? '');
$lien = trim($projet['link_url'] ?? '');
$pdf = trim($projet['pdf_filename'] ?? '');
$tags = [];
// S'il y a des tags alors on les sépares grâce à la virgule que l'user à ajouté dans le champs (tags1,tags2,etcc...)
@@ -90,6 +96,19 @@ function afficherProjets() {
// Ajout de la description et fin des classes
echo '<p>' . $description . '</p>';
// Indicateur de page détaillée : markdown > PDF > lien externe > rien
// Le lien rend toute la carte cliquable (voir CSS .card-link)
if ($markdown !== '') {
echo '<a class="card-link" href="./details.php?type=projet&id=' . $id . '"><i class="fa-solid fa-file-lines"></i> Voir la page détaillée</a>';
} elseif ($pdf !== '') {
echo '<a class="card-link" href="./details.php?type=projet&id=' . $id . '"><i class="fa-solid fa-file-pdf"></i> Voir le PDF</a>';
} elseif ($lien !== '') {
echo '<a class="card-link" href="' . $lien . '" target="_blank" rel="noopener noreferrer"><i class="fa-solid fa-arrow-up-right-from-square"></i> Voir le lien externe</a>';
} else {
echo '<span class="card-link card-link-none"><i class="fa-solid fa-eye-slash"></i> Pas de page détaillée</span>';
}
echo '</div>';
echo '</article>';
@@ -144,7 +163,7 @@ function afficherProjetsAdmin() {
// Boutons qui vont permettrent la modification / Suppresion
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_project=' . $id . '">Modifier</a>';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_project=' . $id . '#admin-projets">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeProject.php?id=' . $id . '" onclick="return confirm(\'Voulez-vous vraiment supprimer ce projet ?\');">Supprimer</a>';
echo '</div>';
+21 -3
View File
@@ -10,7 +10,9 @@ function listeEcoles() {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT id, title, status, tags, description, duration, image_url FROM schools ORDER BY id DESC');
// SELECT * : les colonnes markdown / link_url sont optionnelles (la page fonctionne
// même si la migration SQL n'a pas encore été exécutée)
$stmt = $dbh->prepare('SELECT * FROM schools ORDER BY id DESC');
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
@@ -23,7 +25,7 @@ function recupererEcoleParId($id) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT id, title, status, tags, description, duration, image_url FROM schools WHERE id = :id');
$stmt = $dbh->prepare('SELECT * FROM schools WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch(PDO::FETCH_ASSOC);
@@ -40,6 +42,9 @@ function afficherEcoleCard($ecole, $admin = false) {
$duration = htmlspecialchars($ecole['duration']);
$image = htmlspecialchars($ecole['image_url']);
$status = htmlspecialchars($ecole['status'] ?? '');
$markdown = trim($ecole['markdown'] ?? '');
$lien = trim($ecole['link_url'] ?? '');
$pdf = trim($ecole['pdf_filename'] ?? '');
$tags = [];
// S'il y a des tags alors on les sépares grâce à la virgule (tags1,tags2,etc...)
@@ -76,10 +81,23 @@ function afficherEcoleCard($ecole, $admin = false) {
echo '<p>' . $description . '</p>';
// Indicateur de page détaillée (uniquement sur la page publique) : markdown > PDF > lien externe > rien
if (!$admin) {
if ($markdown !== '') {
echo '<a class="card-link" href="./details.php?type=ecole&id=' . $id . '"><i class="fa-solid fa-file-lines"></i> Voir la page détaillée</a>';
} elseif ($pdf !== '') {
echo '<a class="card-link" href="./details.php?type=ecole&id=' . $id . '"><i class="fa-solid fa-file-pdf"></i> Voir le PDF</a>';
} elseif ($lien !== '') {
echo '<a class="card-link" href="' . $lien . '" target="_blank" rel="noopener noreferrer"><i class="fa-solid fa-arrow-up-right-from-square"></i> Voir le lien externe</a>';
} else {
echo '<span class="card-link card-link-none"><i class="fa-solid fa-eye-slash"></i> Pas de page détaillée</span>';
}
}
// Boutons de modification / suppression pour le panel admin
if ($admin) {
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_school=' . $id . '">Modifier</a>';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_school=' . $id . '#admin-ecoles">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeSchool.php?id=' . $id . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette école ?\');">Supprimer</a>';
echo '</div>';
}
+1 -1
View File
@@ -61,7 +61,7 @@ function afficherSkillCard($skill, $admin = false) {
// Boutons de modification / suppression pour le panel admin
if ($admin) {
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_skill=' . $id . '">Modifier</a>';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_skill=' . $id . '#admin-competences">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeSkill.php?id=' . $id . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette catégorie de compétences ?\');">Supprimer</a>';
echo '</div>';
}
+22 -3
View File
@@ -14,7 +14,9 @@ function listeXP() {
// Essayer
try {
// Requête SQL qui récupère titre, type, tags, description, durée, image_url
$stmt = $dbh->prepare('SELECT id, title, status, xptype, tags, description, duration, image_url FROM experiences ORDER BY id DESC');
// SELECT * : les colonnes markdown / link_url sont optionnelles (la page fonctionne
// même si la migration SQL n'a pas encore été exécutée)
$stmt = $dbh->prepare('SELECT * FROM experiences ORDER BY id DESC');
// Execution de la requête SQL
$stmt->execute();
// Retourne toutes les données collectés dans un ARRAY
@@ -31,7 +33,7 @@ function recupererXPParId($id) {
try {
// Préparation de la requête préparée
$stmt = $dbh->prepare('SELECT id, title, status, xptype, tags, description, duration, image_url FROM experiences WHERE id = :id');
$stmt = $dbh->prepare('SELECT * FROM experiences WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
@@ -58,12 +60,16 @@ function afficherXP() {
// Pour chaque experience dans le ARRAY
foreach ($experiences as $experience) {
// On récupère le titre, le type, la description, la durée et l'image et le tableau avec les tags
$id = (int) $experience['id'];
$titre = htmlspecialchars($experience['title']);
$xptype = htmlspecialchars($experience['xptype']);
$description = nl2br(htmlspecialchars($experience['description']));
$duration = htmlspecialchars($experience['duration']);
$image = htmlspecialchars($experience['image_url']);
$status = htmlspecialchars($experience['status'] ?? '');
$markdown = trim($experience['markdown'] ?? '');
$lien = trim($experience['link_url'] ?? '');
$pdf = trim($experience['pdf_filename'] ?? '');
$tags = [];
@@ -109,6 +115,19 @@ function afficherXP() {
// Ajout de la description et fin des classes
echo '<p>' . $description . '</p>';
// Indicateur de page détaillée : markdown > PDF > lien externe > rien
// Le lien rend toute la carte cliquable (voir CSS .card-link)
if ($markdown !== '') {
echo '<a class="card-link" href="./details.php?type=experience&id=' . $id . '"><i class="fa-solid fa-file-lines"></i> Voir la page détaillée</a>';
} elseif ($pdf !== '') {
echo '<a class="card-link" href="./details.php?type=experience&id=' . $id . '"><i class="fa-solid fa-file-pdf"></i> Voir le PDF</a>';
} elseif ($lien !== '') {
echo '<a class="card-link" href="' . $lien . '" target="_blank" rel="noopener noreferrer"><i class="fa-solid fa-arrow-up-right-from-square"></i> Voir le lien externe</a>';
} else {
echo '<span class="card-link card-link-none"><i class="fa-solid fa-eye-slash"></i> Pas de page détaillée</span>';
}
echo '</div>';
@@ -175,7 +194,7 @@ function afficherXPAdmin() {
// Boutons qui vont permettrent la modification / Suppresion
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_xp=' . $id . '">Modifier</a>';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_xp=' . $id . '#admin-experiences">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeXP.php?id=' . $id . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette expérience ?\');">Supprimer</a>';
echo '</div>';
+68
View File
@@ -0,0 +1,68 @@
<?php
// Fonction utilitaire de validation/lecture d'un PDF envoyé par formulaire (drag & drop ou clic)
// Utilisée par les scripts d'ajout/modification de projets, expériences et écoles
// Taille max acceptée par l'application : à ajuster avec upload_max_filesize / post_max_size
// (php.ini) et max_allowed_packet (MySQL) si besoin d'accepter des PDF plus lourds
define('PDF_MAX_SIZE', 15 * 1024 * 1024); // 15 Mo
// Détecte le cas où le fichier envoyé dépasse post_max_size (php.ini)
// Dans ce cas PHP vide $_POST et $_FILES AVANT même d'exécuter le script : sans cette
// détection, le formulaire échoue silencieusement (aucun champ, aucune erreur visible)
function postDepasseLimite() {
$contentLength = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
return $_SERVER['REQUEST_METHOD'] === 'POST' && $contentLength > 0 && empty($_POST) && empty($_FILES);
}
// Message d'erreur explicite à afficher quand postDepasseLimite() est vrai
function messageDepassementPost() {
return "Le fichier envoyé est trop volumineux pour le serveur (limite actuelle : "
. ini_get('post_max_size') . "). Réduis la taille du PDF, ou augmente "
. "post_max_size et upload_max_filesize côté serveur.";
}
// Vérifie et lit un fichier PDF envoyé en upload ($_FILES)
// Retourne :
// - null si aucun fichier n'a été envoyé (ce n'est pas une erreur)
// - une chaîne de texte si le fichier est invalide (message d'erreur à afficher)
// - ['filename' => ..., 'data' => ...] si le PDF est valide
function lireUploadPdf($champ = 'pdf') {
if (!isset($_FILES[$champ]) || $_FILES[$champ]['error'] === UPLOAD_ERR_NO_FILE) {
return null;
}
if ($_FILES[$champ]['error'] !== UPLOAD_ERR_OK) {
return "Erreur lors de l'envoi du PDF (code " . $_FILES[$champ]['error'] . ").";
}
if ($_FILES[$champ]['size'] > PDF_MAX_SIZE) {
return "Le PDF dépasse la taille maximale autorisée (15 Mo).";
}
// Vérification de l'extension
$extension = strtolower(pathinfo($_FILES[$champ]['name'], PATHINFO_EXTENSION));
if ($extension !== 'pdf') {
return "Le fichier doit être un PDF (extension .pdf).";
}
// Vérification du type réel du fichier (pas seulement l'extension, pour éviter
// qu'un fichier malveillant renommé en .pdf soit accepté)
// (pas de finfo_close : déprécié depuis PHP 8.5, la ressource se libère toute seule)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES[$champ]['tmp_name']);
if ($mime !== 'application/pdf') {
return "Le fichier envoyé n'est pas un PDF valide.";
}
$contenu = file_get_contents($_FILES[$champ]['tmp_name']);
if ($contenu === false) {
return "Impossible de lire le fichier envoyé.";
}
return [
'filename' => basename($_FILES[$champ]['name']),
'data' => $contenu
];
}
+47 -2
View File
@@ -1,6 +1,8 @@
<?php
// On cache les webhooks discord => Correction de faille de sécurité
require_once('../../idDiscord.php');
// __DIR__ = chemin absolu du dossier functions : le require fonctionne ainsi quel que soit
// le dossier depuis lequel le script est appelé (pages/, scripts/, racine...)
require_once(__DIR__ . '/../../idDiscord.php');
// EXEMPLE de a quoi ressemble idDiscord :
// define('ID_DISCORD1', 'https://discord.com/api/webhooks/URL_WEBHOOK1');
// define('ID_DISCORD2', 'https://discord.com/api/webhooks/URL_WEBHOOK2');
@@ -8,7 +10,18 @@ require_once('../../idDiscord.php');
// Fonction qui va ENVOYER des logs sur le webhook discord
function sendLog($action, $user = "") {
// Si le webhook n'est pas configuré on ne bloque pas le site
if (!defined('ID_DISCORD1') || ID_DISCORD1 == '') {
return false;
}
$webhook = ID_DISCORD1; // URL du webhook Discord
// Si l'utilisateur n'est pas précisé on prend celui de la session (connecté)
if ($user == "" && isset($_SESSION['username'])) {
$user = $_SESSION['username'];
}
$contenuLog = "**Nouvelle log du portfolio**\n\n";
$contenuLog .= "**Action :** " . $action . "\n";
if ($user != "") {
@@ -16,6 +29,8 @@ function sendLog($action, $user="") {
} else {
$contenuLog .= "**Utilisateur :** non défini" . "\n";
}
$contenuLog .= "**IP :** " . ($_SERVER['REMOTE_ADDR'] ?? 'inconnue') . "\n";
$contenuLog .= "**Date :** " . date('d/m/Y H:i:s');
$data = [
"content" => $contenuLog
@@ -28,6 +43,36 @@ function sendLog($action, $user="") {
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $dataJson);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Timeouts courts : si Discord est lent ou down, le site ne doit pas être ralenti
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 800);
curl_setopt($ch, CURLOPT_TIMEOUT_MS, 1500);
// CORRECTION DU BUG : curl_exec() n'était jamais appelé, la requête n'était
// donc jamais envoyée à Discord. C'est pour ça que les logs n'ont jamais marché.
// (pas de curl_close : déprécié depuis PHP 8.5, la ressource se libère toute seule)
curl_exec($ch);
$codeHttp = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// Discord renvoie 204 quand le message est bien envoyé
return $codeHttp == 204;
}
//
// Log de visite de page : appelée par le header sur toutes les pages
// Anti-spam : Discord limite à ~30 messages/minute par webhook, donc une même page
// n'est loguée qu'une fois par minute et par visiteur (stocké dans sa session)
function sendPageLog() {
$page = basename($_SERVER['PHP_SELF'] ?? 'inconnue');
if (!isset($_SESSION['pages_log'])) {
$_SESSION['pages_log'] = [];
}
$maintenant = time();
if (isset($_SESSION['pages_log'][$page]) && ($maintenant - $_SESSION['pages_log'][$page]) < 60) {
return; // Page déjà loguée il y a moins d'une minute pour ce visiteur
}
$_SESSION['pages_log'][$page] = $maintenant;
sendLog("Visite de la page " . $page);
}
+1 -2
View File
@@ -1,8 +1,7 @@
<?php
// Ce projet est entièrement développé par Esteban MOUSSU
// 2026 - Projet Hébergé par l'entreprise TerraTech Hebergement en collaboration avec l'association Eldoria Network
// Le CSS est **probablement** généré par intelligence artificielle ou récupéré d'autre projet, cependant le code PHP / HTML / JS est globalement fait par un humain
// Le CSS est généré par intelligence artificielle ou récupéré d'autre projet.
// Note 1 : Pour lancer un serveur local PHP Depuis VsCode : 'php -v' pour vérifier que php est bien installé et 'php -S localhost:8000' pour lancer le serveur
// Note 2 : Je m'aide désormais de l'autocompletion de vsCode pour accélerer le développement avec Copilot (GitHub) ou Devin mais le reste du code est entièrement développé par moi même mais copilot
// reste utile pour la génération de commentaires, de code répétitif ou de code basique (exemple : les formulaires de contact, les requêtes SQL basiques etc...)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+258 -73
View File
@@ -50,18 +50,37 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
// Page du panneau d'administration
?>
<div class="admin-forms">
<div class="admin-page">
<!-- Formulaire de création de projet -->
<div class="project-container">
<div class="project-form-card">
<div class="project-header">
<h2><?php echo $projetAModifier ? 'Modifier le projet' : 'Nouveau projet'; ?></h2>
<!-- Titre du panneau -->
<div class="page-header">
<h1>Panneau d'administration</h1>
<p>Gère le contenu du site : projets, expériences, écoles, compétences, notification et utilisateurs.</p>
</div>
<!-- Sous-navigation : accès rapide à chaque catégorie -->
<nav class="admin-nav">
<a href="#admin-projets"><i class="fa-solid fa-folder-open"></i> Projets</a>
<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-notification"><i class="fa-solid fa-bell"></i> Notification</a>
<a href="#admin-utilisateurs"><i class="fa-solid fa-users"></i> Utilisateurs</a>
<a href="#admin-donnees"><i class="fa-solid fa-database"></i> Données</a>
</nav>
<!-- ========== SECTION PROJETS ========== -->
<section id="admin-projets" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-folder-open"></i> Projets</h2>
<!-- Formulaire (replié par défaut, ouvert automatiquement en mode modification) -->
<details class="admin-block" <?php echo $projetAModifier ? 'open' : ''; ?>>
<summary><?php echo $projetAModifier ? 'Modifier le projet : ' . htmlspecialchars($projetAModifier['title']) : '+ Nouveau projet'; ?></summary>
<div class="project-form-card">
<!-- enctype multipart/form-data : nécessaire pour l'upload du PDF -->
<!-- en envoie les données à addProject.php -->
<form method="POST" action="<?php echo $projetAModifier ? '../scripts/updateProject.php' : '../scripts/addProject.php'; ?>">
<form method="POST" enctype="multipart/form-data" action="<?php echo $projetAModifier ? '../scripts/updateProject.php' : '../scripts/addProject.php'; ?>">
<?php if ($projetAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $projetAModifier['id']; ?>">
@@ -70,6 +89,9 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<label>Titre *</label>
<input type="text" name="title" required value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['title']) : ''; ?>">
<label>Status (optionnel, affiché sur l'image : En cours, Terminé...)</label>
<input type="text" name="status" placeholder="En cours..." value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['status'] ?? '') : ''; ?>">
<label>Tags (virgules)</label>
<input type="text" name="tags" value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['tags']) : ''; ?>">
@@ -79,22 +101,50 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<label>Lien de l'image *</label>
<input type="url" name="imageurl" placeholder="https://..." required value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['image_url']) : ''; ?>">
<label>Page détaillée en markdown (optionnel, prioritaire sur le PDF et le lien externe)</label>
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon projet&#10;&#10;Description complète en **markdown**..."><?php echo $projetAModifier ? htmlspecialchars($projetAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($projetAModifier && !empty($projetAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($projetAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=projet&id=<?php echo (int) $projetAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<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>
<span class="pdf-dropzone-filename"></span>
<input type="file" name="pdf" id="pdf-project" accept="application/pdf,.pdf" hidden>
</div>
<label>Lien externe (optionnel, utilisé si pas de markdown ni de PDF)</label>
<input type="url" name="linkurl" placeholder="https://..." value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['link_url'] ?? '') : ''; ?>">
<input type="submit" name="submitproject" value="<?php echo $projetAModifier ? 'Modifier le projet' : '+ Ajouter le projet'; ?>" class="btn-submit">
</form>
</div>
</div>
</details>
<!-- Liste des projets avec modification / suppression -->
<?php afficherProjetsAdmin(); ?>
</section>
<!-- ========== SECTION EXPÉRIENCES ========== -->
<section id="admin-experiences" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-briefcase"></i> Expériences</h2>
<details class="admin-block" <?php echo $xpAModifier ? 'open' : ''; ?>>
<summary><?php echo $xpAModifier ? 'Modifier l\'expérience : ' . htmlspecialchars($xpAModifier['title']) : '+ Nouvelle expérience'; ?></summary>
<!-- Formulaire de création d'expérience -->
<div class="project-container">
<div class="project-form-card">
<div class="project-header">
<h2><?php echo $xpAModifier ? 'Modifier l\'experience' : 'Nouvelle expérience'; ?></h2>
</div>
<!-- en envoie les données à addXP.php -->
<form method="POST" action="<?php echo $xpAModifier ? '../scripts/updateXP.php' : '../scripts/addXP.php'; ?>">
<form method="POST" enctype="multipart/form-data" action="<?php echo $xpAModifier ? '../scripts/updateXP.php' : '../scripts/addXP.php'; ?>">
<?php if ($xpAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $xpAModifier['id']; ?>">
@@ -103,8 +153,11 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<label>Titre *</label>
<input type="text" name="title" required value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['title']) : ''; ?>">
<label>Status (optionnel, affiché sur l'image : En cours, Terminé...)</label>
<input type="text" name="status" placeholder="En cours..." value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['status'] ?? '') : ''; ?>">
<label>Type *</label>
<textarea name="xptype" required><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['xptype']) : ''; ?></textarea>
<input type="text" name="xptype" required value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['xptype']) : ''; ?>">
<label>Tags (virgules)</label>
<input type="text" name="tags" value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['tags']) : ''; ?>">
@@ -113,27 +166,55 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<textarea name="description" required><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['description']) : ''; ?></textarea>
<label>Durée *</label>
<textarea name="duration" required><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['duration']) : ''; ?></textarea>
<input type="text" name="duration" required value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['duration']) : ''; ?>">
<label>Lien de l'image *</label>
<input type="url" name="imageurl" placeholder="https://..." required value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['image_url']) : ''; ?>">
<label>Page détaillée en markdown (optionnel, prioritaire sur le PDF et le lien externe)</label>
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon expérience&#10;&#10;Description complète en **markdown**..."><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($xpAModifier && !empty($xpAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($xpAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=experience&id=<?php echo (int) $xpAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<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>
<span class="pdf-dropzone-filename"></span>
<input type="file" name="pdf" id="pdf-xp" accept="application/pdf,.pdf" hidden>
</div>
<label>Lien externe (optionnel, utilisé si pas de markdown ni de PDF)</label>
<input type="url" name="linkurl" placeholder="https://..." value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['link_url'] ?? '') : ''; ?>">
<input type="submit" name="submitxp" value="<?php echo $xpAModifier ? 'Modifier l\'experience' : '+ Ajouter l\'experience'; ?>" class="btn-submit">
</form>
</div>
</div>
</details>
<!-- Liste des expériences avec modification / suppression -->
<?php afficherXPAdmin(); ?>
</section>
<!-- ========== SECTION ÉCOLES ========== -->
<section id="admin-ecoles" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-graduation-cap"></i> Écoles</h2>
<details class="admin-block" <?php echo $ecoleAModifier ? 'open' : ''; ?>>
<summary><?php echo $ecoleAModifier ? 'Modifier l\'école : ' . htmlspecialchars($ecoleAModifier['title']) : '+ Nouvelle école'; ?></summary>
<!-- Formulaire de création d'école / formation -->
<div class="project-container">
<div class="project-form-card">
<div class="project-header">
<h2><?php echo $ecoleAModifier ? 'Modifier l\'école' : 'Nouvelle école'; ?></h2>
</div>
<!-- on envoie les données à addSchool.php -->
<form method="POST" action="<?php echo $ecoleAModifier ? '../scripts/updateSchool.php' : '../scripts/addSchool.php'; ?>">
<form method="POST" enctype="multipart/form-data" action="<?php echo $ecoleAModifier ? '../scripts/updateSchool.php' : '../scripts/addSchool.php'; ?>">
<?php if ($ecoleAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $ecoleAModifier['id']; ?>">
@@ -142,6 +223,9 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<label>Nom de l'école *</label>
<input type="text" name="title" required value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['title']) : ''; ?>">
<label>Status (optionnel, affiché sur l'image : En cours, Diplômé...)</label>
<input type="text" name="status" placeholder="En cours..." value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['status'] ?? '') : ''; ?>">
<label>Tags (virgules)</label>
<input type="text" name="tags" value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['tags']) : ''; ?>">
@@ -154,20 +238,48 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<label>Lien de l'image *</label>
<input type="url" name="imageurl" placeholder="https://..." required value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['image_url']) : ''; ?>">
<label>Page détaillée en markdown (optionnel, prioritaire sur le PDF et le lien externe)</label>
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon école&#10;&#10;Description complète en **markdown**..."><?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($ecoleAModifier && !empty($ecoleAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($ecoleAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=ecole&id=<?php echo (int) $ecoleAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<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>
<span class="pdf-dropzone-filename"></span>
<input type="file" name="pdf" id="pdf-school" accept="application/pdf,.pdf" hidden>
</div>
<label>Lien externe (optionnel, utilisé si pas de markdown ni de PDF)</label>
<input type="url" name="linkurl" placeholder="https://..." value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['link_url'] ?? '') : ''; ?>">
<input type="submit" name="submitschool" value="<?php echo $ecoleAModifier ? 'Modifier l\'école' : '+ Ajouter l\'école'; ?>" class="btn-submit">
</form>
</div>
</div>
</details>
<!-- Liste des écoles avec modification / suppression -->
<?php afficherEcolesAdmin(); ?>
</section>
<!-- ========== SECTION COMPÉTENCES ========== -->
<section id="admin-competences" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-code"></i> Compétences</h2>
<details class="admin-block" <?php echo $skillAModifier ? 'open' : ''; ?>>
<summary><?php echo $skillAModifier ? 'Modifier la catégorie : ' . htmlspecialchars($skillAModifier['title']) : '+ Nouvelle catégorie de compétences'; ?></summary>
<!-- Formulaire de création de catégorie de compétences -->
<div class="project-container">
<div class="project-form-card">
<div class="project-header">
<h2><?php echo $skillAModifier ? 'Modifier la compétence' : 'Nouvelle compétence'; ?></h2>
</div>
<!-- on envoie les données à addSkill.php -->
<form method="POST" action="<?php echo $skillAModifier ? '../scripts/updateSkill.php' : '../scripts/addSkill.php'; ?>">
@@ -191,16 +303,20 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
</form>
</div>
</details>
<!-- Liste des compétences avec modification / suppression -->
<div class="skills-section skills-section-admin">
<?php afficherSkillsAdmin(); ?>
</div>
</section>
<!-- ========== SECTION NOTIFICATION ========== -->
<section id="admin-notification" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-bell"></i> Notification de la page d'accueil</h2>
<!-- Formulaire de la notification de la page d'accueil -->
<div class="project-container">
<div class="project-form-card">
<div class="project-header">
<h2>Notification de la page d'accueil</h2>
</div>
<!-- on envoie les données à updateSettings.php -->
<form method="POST" action="../scripts/updateSettings.php">
@@ -221,34 +337,16 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
</form>
</div>
</div>
</div>
<?php
// Liste des projets / xps => en gros on pourra supprimer, et modifier à la vollée sans aller dans la bdd
?>
<h2 class="projects-title">Gérer les projets</h2>
<?php afficherProjetsAdmin(); ?>
<h2 class="projects-title">Gérer les experiences</h2>
<?php afficherXPAdmin(); ?>
<h2 class="projects-title">Gérer les écoles</h2>
<?php afficherEcolesAdmin(); ?>
<h2 class="projects-title">Gérer les compétences</h2>
<section class="skills-section skills-section-admin">
<?php afficherSkillsAdmin(); ?>
</section>
<?php
// Création de compte
?>
<!-- ========== SECTION UTILISATEURS ========== -->
<section id="admin-utilisateurs" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-users"></i> Utilisateurs</h2>
<details class="admin-block">
<summary>+ Nouveau compte</summary>
<!-- Formulaire de création de compte -->
<div class="project-container">
<div class="project-form-card">
<div class="project-header">
<h2>Nouveau compte</h2>
</div>
<!-- en envoie les données addUser.php -->
<form method="POST" action="../scripts/addUser.php">
<label>Nom du compte</label>
@@ -256,7 +354,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<!-- role admin ou user -->
<label>Rôle (admin/user)</label>
<textarea name="role" required></textarea>
<input type="text" name="role" placeholder="user" required>
<!-- mot de passe -->
<label>Mot de passe</label>
@@ -266,14 +364,101 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<input type="submit" name="submituser" value="Ajouter l'utilisateur" class="btn-submit">
</form>
</div>
</div>
<!-- Liste des utilisateurs -->
<h2 class="projects-title">Gérer les utilisateurs</h2>
</details>
<!-- Affichage de la liste des utilisateurs -->
<?php
afficherUsersAdmin();
<?php afficherUsersAdmin(); ?>
</section>
<!-- ========== SECTION DONNÉES (EXPORT / IMPORT) ========== -->
<section id="admin-donnees" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-database"></i> Données du site</h2>
<div class="project-container">
<div class="project-form-card">
<!-- Export : télécharge toutes les données en JSON -->
<h3 class="admin-data-title"><i class="fa-solid fa-download"></i> Exporter</h3>
<p class="admin-data-text">
Télécharge une sauvegarde JSON de toutes les données du site
(projets, expériences, écoles, compétences, notification, utilisateurs).
</p>
<a class="btn-submit admin-export-btn" href="../scripts/exportData.php">
<i class="fa-solid fa-download"></i> Exporter les données (JSON)
</a>
<hr class="admin-data-separator">
<!-- Import : écrase les données actuelles avec celles du fichier -->
<h3 class="admin-data-title"><i class="fa-solid fa-upload"></i> Importer</h3>
<p class="admin-data-text admin-data-warning">
⚠️ L'import <strong>écrase définitivement</strong> les données actuelles des tables
présentes dans le fichier. Fais un export avant, au cas où !
</p>
<form method="POST" action="../scripts/importData.php" enctype="multipart/form-data"
onsubmit="return confirm('ATTENTION : les données actuelles du site vont être écrasées par celles du fichier. Continuer ?');">
<input type="file" name="jsonfile" accept=".json,application/json" required>
<input type="submit" value="Importer les données" class="btn-submit btn-delete">
</form>
</div>
</div>
</section>
</div>
<!-- Gestion des zones de drag & drop PDF (projets, expériences, écoles) -->
<script>
(function () {
document.querySelectorAll('.pdf-dropzone').forEach(function (zone) {
var input = document.getElementById(zone.dataset.input);
var filenameEl = zone.querySelector('.pdf-dropzone-filename');
function afficherFichier(file) {
if (!file) return;
if (file.type !== 'application/pdf') {
filenameEl.textContent = 'Ce fichier n\'est pas un PDF.';
zone.classList.remove('has-file');
return;
}
filenameEl.textContent = file.name;
zone.classList.add('has-file');
}
// Clic sur la zone = ouvre le sélecteur de fichier
zone.addEventListener('click', function () {
input.click();
});
input.addEventListener('change', function () {
afficherFichier(input.files[0]);
});
['dragenter', 'dragover'].forEach(function (evt) {
zone.addEventListener(evt, function (e) {
e.preventDefault();
zone.classList.add('dragging');
});
});
['dragleave', 'drop'].forEach(function (evt) {
zone.addEventListener(evt, function (e) {
e.preventDefault();
zone.classList.remove('dragging');
});
});
zone.addEventListener('drop', function (e) {
var file = e.dataTransfer.files[0];
if (file) {
input.files = e.dataTransfer.files;
afficherFichier(file);
}
});
});
})();
</script>
<?php
// On ajoute ici le footer
require_once('../elements/footer.php'); ?>
+159
View File
@@ -0,0 +1,159 @@
<?php
// Page de détails d'un projet / d'une expérience / d'une école
// Le contenu markdown (stocké en BDD, éditable depuis le panel admin) est rendu
// côté navigateur avec marked.js puis nettoyé avec DOMPurify (sécurité XSS)
// Connexion BDD (avant le header car on doit pouvoir rediriger si l'élément n'existe pas)
require_once('../functions/databaseConnection.php');
// Types autorisés : liste blanche pour empêcher toute injection de nom de table
$types = [
'projet' => ['table' => 'projects', 'retour' => './projects.php', 'label' => 'Projet'],
'experience' => ['table' => 'experiences', 'retour' => './xp.php', 'label' => 'Expérience'],
'ecole' => ['table' => 'schools', 'retour' => './xp.php', 'label' => 'École'],
];
$type = $_GET['type'] ?? '';
$id = $_GET['id'] ?? '';
// Type inconnu ou id invalide : retour à l'accueil
if (!isset($types[$type]) || !is_numeric($id)) {
header('Location: ./home.php');
exit;
}
$infos = $types[$type];
// Récupération de l'élément demandé
// (pdf_data n'est volontairement pas sélectionné : le PDF est servi à part par viewPdf.php
// dans une <iframe>, inutile de charger son contenu binaire en mémoire ici)
$colonnesCommunes = 'id, title, status, tags, description, image_url, markdown, link_url, pdf_filename';
$colonnesParType = [
'projet' => $colonnesCommunes,
'experience' => $colonnesCommunes . ', xptype, duration',
'ecole' => $colonnesCommunes . ', duration',
];
try {
$stmt = $dbh->prepare('SELECT ' . $colonnesParType[$type] . ' FROM `' . $infos['table'] . '` WHERE id = :id');
$stmt->bindValue(':id', (int) $id, PDO::PARAM_INT);
$stmt->execute();
$element = $stmt->fetch(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
$element = false;
}
$hasMarkdown = $element && trim($element['markdown'] ?? '') !== '';
$hasPdf = $element && trim($element['pdf_filename'] ?? '') !== '';
// Élément inexistant, ou sans markdown ni PDF : retour à la liste
if (!$element || (!$hasMarkdown && !$hasPdf)) {
header('Location: ' . $infos['retour']);
exit;
}
// Titre de l'onglet
$pageTitle = $element['title'];
// On ajoute le header
require_once('../elements/header.php');
// Préparation des données pour l'affichage
$titre = htmlspecialchars($element['title']);
$image = htmlspecialchars($element['image_url'] ?? '');
$status = htmlspecialchars($element['status'] ?? '');
$duration = htmlspecialchars($element['duration'] ?? '');
$xptype = htmlspecialchars($element['xptype'] ?? '');
$tags = [];
if (!empty($element['tags'])) {
$tags = array_filter(array_map('trim', explode(',', $element['tags'])));
}
?>
<div class="details-page">
<!-- Bouton retour vers la liste -->
<a class="btn btn-ghost details-back" href="<?php echo $infos['retour']; ?>">
<i class="fa-solid fa-arrow-left"></i> Retour
</a>
<article class="details-card">
<?php if (!empty($image)): ?>
<div class="details-banner project-banner">
<img src="<?php echo $image; ?>" alt="Image : <?php echo $titre; ?>">
<?php if (!empty($status)): ?>
<div class="project-state"><?php echo $status; ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="details-content">
<span class="details-type"><?php echo $infos['label']; ?></span>
<h1><?php echo $titre; ?></h1>
<!-- Métadonnées : type / durée si renseignés -->
<?php if (!empty($xptype) || !empty($duration)): ?>
<p class="details-meta">
<?php if (!empty($xptype)): ?><span><i class="fa-solid fa-tag"></i> <?php echo $xptype; ?></span><?php endif; ?>
<?php if (!empty($duration)): ?><span><i class="fa-solid fa-clock"></i> <?php echo $duration; ?></span><?php endif; ?>
</p>
<?php endif; ?>
<?php 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; ?>
<?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>
</article>
</div>
<?php if ($hasMarkdown): ?>
<!-- marked.js : convertit le markdown en HTML / DOMPurify : nettoie le HTML généré (sécurité) -->
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script>
(function () {
var source = document.getElementById('markdown-source');
var cible = document.getElementById('markdown-target');
// textContent décode automatiquement les entités HTML => on récupère le markdown d'origine
var markdown = source.textContent;
cible.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
})();
</script>
<?php endif; ?>
<?php
// Ajout du footer
require_once('../elements/footer.php'); ?>
+7 -2
View File
@@ -28,14 +28,19 @@ afficherNotice();
<a href="../pages/projects.php" class="btn btn-primary"><i class="fa-solid fa-folder-open"></i> Voir mes projets</a>
<a href="../pages/contact.php" class="btn btn-ghost"><i class="fa-solid fa-paper-plane"></i> Me contacter</a>
</div>
<!-- Flèche animée qui invite à défiler vers le bas -->
<a href="#explorer" class="scroll-indicator" aria-label="Défiler vers le bas">
<i class="fa-solid fa-chevron-down"></i>
</a>
</section>
<!-- Section d'exploration rapide -->
<section class="quick-links-section">
<section class="quick-links-section" id="explorer">
<div class="quick-links-header">
<!-- Titre et description de la section -->
<h2>Explorer</h2>
<p>Accède rapidement aux sections principales du portfolio.</p>
<p>Accède rapidement aux sections principales de mon portfolio.</p>
</div>
<!-- Grille de cartes de redirection vers les différentes page -->
<div class="quick-links-grid">
+43 -2
View File
@@ -16,7 +16,7 @@ if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin') {
// Affichages des xp pro
?>
<div class="page-header">
<div class="page-header" id="experiences">
<h1>Expériences professionnelles</h1>
<p>Mes stages et missions réalisées en entreprise.</p>
</div>
@@ -34,7 +34,7 @@ require_once('../functions/listSchool.php');
if (!empty(listeEcoles())) {
?>
<div class="page-header">
<div class="page-header" id="ecoles">
<h2>Mes écoles</h2>
<p>Mon parcours scolaire et mes formations.</p>
</div>
@@ -42,6 +42,47 @@ if (!empty(listeEcoles())) {
<?php
// Appel de ma fonction dans Functions/listSchool.php
afficherEcoles();
?>
<!-- Bouton flottant : icône chapeau de diplômé = descendre vers les écoles,
icône sac de travail = remonter vers les expériences -->
<button class="section-jump" id="section-jump" aria-label="Basculer entre expériences et écoles">
<i class="fa-solid fa-graduation-cap"></i>
<i class="fa-solid fa-briefcase"></i>
</button>
<script>
(function () {
var btn = document.getElementById('section-jump');
var ecoles = document.getElementById('ecoles');
if (!btn || !ecoles) return;
// Vrai quand la moitié de l'écran a dépassé le début de la section écoles
function estDansEcoles() {
return window.scrollY + window.innerHeight / 2 >= ecoles.offsetTop;
}
// Change l'icône du bouton selon la section où on se trouve
function majIcone() {
btn.classList.toggle('mode-ecoles', estDansEcoles());
}
btn.addEventListener('click', function () {
if (estDansEcoles()) {
// On est dans les écoles : remonter aux expériences
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
// On est dans les expériences : descendre aux écoles
ecoles.scrollIntoView({ behavior: 'smooth' });
}
});
window.addEventListener('scroll', majIcone);
majIcone();
})();
</script>
<?php
}
// Ajout du footer
+35 -7
View File
@@ -1,6 +1,8 @@
<?php
session_start();
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Pour faire cette fonction l'utilisateur doit être administrateur
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -8,28 +10,54 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite pour éviter un échec silencieux
if (postDepasseLimite()) {
die(messageDepassementPost());
}
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Récupération des données dans la requete POST
$title = $_POST['title'] ?? '';
$status = trim($_POST['status'] ?? '');
$tags = $_POST['tags'] ?? '';
$description = $_POST['description'] ?? '';
$image_url = $_POST['imageurl'] ?? '';
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
// Le fichier envoyé n'est pas valide : on affiche l'erreur et on s'arrête
echo $pdfResultat;
exit;
}
$pdfData = is_array($pdfResultat) ? $pdfResultat['data'] : null;
$pdfFilename = is_array($pdfResultat) ? $pdfResultat['filename'] : null;
// On vérifie que les champs obligatoires ne sont pas vide
if (!empty($title) && !empty($description) && !empty($image_url)) {
try {
// On prépare la requête SQL pour insérer le nouveau projet
$stmt = $dbh->prepare('INSERT INTO projects (title, tags, description, image_url) VALUES (:title, :tags, :description, :image_url)');
$stmt = $dbh->prepare('INSERT INTO projects (title, status, tags, description, image_url, markdown, link_url, pdf_data, pdf_filename) VALUES (:title, :status, :tags, :description, :image_url, :markdown, :link_url, :pdf_data, :pdf_filename)');
// On execute la requete préparée
$stmt->execute([
':title' => $title,
':tags' => $tags,
':description' => $description,
':image_url' => $image_url
]);
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $image_url, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$stmt->execute();
// Log Discord
sendLog("Ajout du projet : " . $title);
// Redirection vers projects.php ds que la requete est executée
header('Location: ../pages/projects.php');
+35 -8
View File
@@ -1,6 +1,8 @@
<?php
session_start();
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Pour faire cette fonction l'utilisateur doit être administrateur
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -8,30 +10,55 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite pour éviter un échec silencieux
if (postDepasseLimite()) {
die(messageDepassementPost());
}
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Récupération des données dans la requete POST
$title = trim($_POST['title'] ?? '');
$status = trim($_POST['status'] ?? '');
$tags = trim($_POST['tags'] ?? '');
$description = trim($_POST['description'] ?? '');
$duration = trim($_POST['duration'] ?? '');
$image_url = trim($_POST['imageurl'] ?? '');
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
echo $pdfResultat;
exit;
}
$pdfData = is_array($pdfResultat) ? $pdfResultat['data'] : null;
$pdfFilename = is_array($pdfResultat) ? $pdfResultat['filename'] : null;
// On vérifie que les champs obligatoires ne sont pas vide
if (!empty($title) && !empty($description) && !empty($duration) && !empty($image_url)) {
try {
// On prépare la requête SQL pour insérer la nouvelle école
$stmt = $dbh->prepare('INSERT INTO schools (title, tags, description, duration, image_url) VALUES (:title, :tags, :description, :duration, :image_url)');
$stmt = $dbh->prepare('INSERT INTO schools (title, status, tags, description, duration, image_url, markdown, link_url, pdf_data, pdf_filename) VALUES (:title, :status, :tags, :description, :duration, :image_url, :markdown, :link_url, :pdf_data, :pdf_filename)');
// On execute la requete préparée
$stmt->execute([
':title' => $title,
':tags' => $tags,
':description' => $description,
':duration' => $duration,
':image_url' => $image_url
]);
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':duration', $duration, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $image_url, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$stmt->execute();
// Log Discord
sendLog("Ajout de l'école : " . $title);
// Redirection vers xp.php dès que la requete est executée
header('Location: ../pages/xp.php');
+4
View File
@@ -1,6 +1,7 @@
<?php
session_start();
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
// Pour faire cette fonction l'utilisateur doit être administrateur
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -31,6 +32,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
':position' => $position
]);
// Log Discord
sendLog("Ajout de la catégorie de compétences : " . $title);
// Redirection vers skills.php dès que la requete est executée
header('Location: ../pages/skills.php');
exit;
+5
View File
@@ -3,6 +3,8 @@
session_start();
// Ajout de connexion à la BDD
require_once("../functions/databaseConnection.php");
// Ajout des logs Discord
require_once("../functions/sendLogs.php");
// Vérification que l'utilisateur est bien admin
if (!isset($_SESSION["role"]) || $_SESSION["role"] != "admin") {
@@ -58,6 +60,9 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
":created_at" => $dateCreation
]);
// Log Discord
sendLog("Création du compte : " . $user . " (rôle : " . $role . ")");
// Redirection vers la page admin après succès
header("Location: ../pages/admin.php");
exit();
+36 -9
View File
@@ -1,6 +1,8 @@
<?php
session_start();
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Pour faire cette fonction l'utilisateur doit être administrateur
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -8,32 +10,57 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite pour éviter un échec silencieux
if (postDepasseLimite()) {
die(messageDepassementPost());
}
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Récupération des données dans la requete POST
$title = $_POST['title'] ?? '';
$status = trim($_POST['status'] ?? '');
$xptype = $_POST['xptype'] ?? '';
$tags = $_POST['tags'] ?? '';
$description = $_POST['description'] ?? '';
$duration = $_POST['duration'] ?? '';
$image_url = $_POST['imageurl'] ?? '';
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
echo $pdfResultat;
exit;
}
$pdfData = is_array($pdfResultat) ? $pdfResultat['data'] : null;
$pdfFilename = is_array($pdfResultat) ? $pdfResultat['filename'] : null;
// On vérifie que les champs obligatoires ne sont pas vide
if (!empty($title) && !empty($description) && !empty($image_url)) {
try {
// On prépare la requête SQL pour insérer la nouvelle experience
$stmt = $dbh->prepare('INSERT INTO experiences (title, xptype, tags, description, duration, image_url) VALUES (:title, :xptype, :tags, :description, :duration, :image_url)');
$stmt = $dbh->prepare('INSERT INTO experiences (title, status, xptype, tags, description, duration, image_url, markdown, link_url, pdf_data, pdf_filename) VALUES (:title, :status, :xptype, :tags, :description, :duration, :image_url, :markdown, :link_url, :pdf_data, :pdf_filename)');
// On execute la requete préparée
$stmt->execute([
':title' => $title,
':xptype' => $xptype,
':tags' => $tags,
':description' => $description,
':duration' => $duration,
':image_url' => $image_url
]);
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':xptype', $xptype, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':duration', $duration, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $image_url, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$stmt->execute();
// Log Discord
sendLog("Ajout de l'expérience : " . $title);
// Redirection vers xp.php ds que la requete est executée
header('Location: ../pages/xp.php');
+10 -1
View File
@@ -8,6 +8,9 @@ session_start();
// Ajout de connexion a la bdd
require_once('../functions/databaseConnection.php');
// Ajout des logs Discord
require_once('../functions/sendLogs.php');
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ./../pages/login.php');
@@ -29,6 +32,8 @@ try {
// On regarde si le mdp correspond à celui en bdd
if (!$user || !password_verify($password, $user['password'])) {
// Log Discord : tentative de connexion échouée (utile pour repérer le bruteforce)
sendLog("Tentative de connexion échouée avec l'identifiant : " . $username);
header('Location: ./../pages/login.php');
exit;
}
@@ -46,7 +51,11 @@ try {
error_log('Le serveur WEB ne peut pas modifier la date de dernière connexion : ' . $e->getMessage());
}
// Log Discord : connexion réussie
sendLog("Connexion réussie", $user['username']);
//Redirection vers home.php puisque que l'utilisateur est forcément connecté
header('Location: ./../pages/home.php');
// (?login=1 déclenche la notification "Connexion réussie" sur la page)
header('Location: ./../pages/home.php?login=1');
exit;
?>
+8 -1
View File
@@ -5,6 +5,12 @@
// Démarrage de session
session_start();
// Ajout des logs Discord
require_once('../functions/sendLogs.php');
// Log Discord AVANT la destruction de session (sinon on perd le nom d'utilisateur)
sendLog("Déconnexion");
// On sauvegarde l'état du captcha avant destruction
$captchaAlreadyValid = isset($_SESSION['captcha_valid']) && $_SESSION['captcha_valid'] === true;
@@ -20,5 +26,6 @@ if ($captchaAlreadyValid) {
}
// Redirection de l'utilisateur vers l'accueil
header('Location: ../index.php');
// (?logout=1 déclenche la notification "Vous avez bien été déconnecté" sur la page)
header('Location: ../pages/home.php?logout=1');
exit;
+77
View File
@@ -0,0 +1,77 @@
<?php
// Export de toutes les données du site en JSON (comme un dbdump mais en JSON)
// Le fichier téléchargé peut ensuite être ré-importé via importData.php
// On démarre la session
session_start();
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.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;
}
// Liste blanche des tables et colonnes exportées (empêche toute injection via noms de colonnes)
$tables = [
'settings' => ['name', 'value'],
'skills' => ['id', 'title', 'icon', 'items', 'position', 'created_at'],
'projects' => ['id', 'title', 'status', 'tags', 'description', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'experiences' => ['id', 'title', 'status', 'xptype', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'schools' => ['id', 'title', 'status', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'users' => ['id', 'username', 'password', 'role', 'created_at', 'last_login'],
];
// Colonnes binaires : le JSON ne peut pas contenir de binaire brut, on les encode en base64
// (+33% de taille mais nécessaire pour que le PDF survive à un export/import complet)
$colonnesBinaires = [
'projects' => ['pdf_data'],
'experiences' => ['pdf_data'],
'schools' => ['pdf_data'],
];
// Métadonnées de l'export (pratique pour s'y retrouver dans les sauvegardes)
$export = [
'_meta' => [
'site' => 'Portfolio Esteban MOUSSU',
'date_export' => date('Y-m-d H:i:s'),
'version' => 1
]
];
// Pour chaque table on récupère toutes les lignes
foreach ($tables as $table => $colonnes) {
try {
// SELECT * puis filtrage par liste blanche : fonctionne même si certaines
// colonnes optionnelles (markdown, link_url, pdf_data...) n'existent pas encore
$stmt = $dbh->query('SELECT * FROM `' . $table . '`');
$lignes = $stmt->fetchAll(PDO::FETCH_ASSOC);
$binaires = $colonnesBinaires[$table] ?? [];
// On ne garde que les colonnes de la liste blanche, et on encode les colonnes binaires
$export[$table] = array_map(function ($ligne) use ($colonnes, $binaires) {
$ligne = array_intersect_key($ligne, array_flip($colonnes));
foreach ($binaires as $colonne) {
if (isset($ligne[$colonne]) && $ligne[$colonne] !== null) {
$ligne[$colonne] = base64_encode($ligne[$colonne]);
}
}
return $ligne;
}, $lignes);
} catch (PDOException $e) {
// Si une table n'existe pas encore on l'exporte vide plutôt que de bloquer l'export
$export[$table] = [];
}
}
// Log Discord
sendLog("Export des données du site en JSON");
// Envoi du fichier JSON en téléchargement
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="portfolio-backup-' . date('Y-m-d-His') . '.json"');
echo json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
exit;
+117
View File
@@ -0,0 +1,117 @@
<?php
// Import d'une sauvegarde JSON (créée par exportData.php)
// ATTENTION : les données actuelles des tables présentes dans le fichier sont ÉCRASÉES
// On démarre la session
session_start();
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.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;
}
// La requête doit être un POST avec un fichier sinon on refuse
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['jsonfile']) || $_FILES['jsonfile']['error'] !== UPLOAD_ERR_OK) {
header('Location: ../pages/admin.php');
exit;
}
// Lecture et décodage du fichier JSON
$contenu = file_get_contents($_FILES['jsonfile']['tmp_name']);
$donnees = json_decode($contenu, true);
if (!is_array($donnees)) {
die("Erreur : le fichier n'est pas un JSON valide.");
}
// Même liste blanche que l'export : seules ces tables / colonnes peuvent être importées
$tables = [
'settings' => ['name', 'value'],
'skills' => ['id', 'title', 'icon', 'items', 'position', 'created_at'],
'projects' => ['id', 'title', 'status', 'tags', 'description', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'experiences' => ['id', 'title', 'status', 'xptype', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'schools' => ['id', 'title', 'status', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'users' => ['id', 'username', 'password', 'role', 'created_at', 'last_login'],
];
// Colonnes binaires : encodées en base64 par exportData.php, à redécoder ici
$colonnesBinaires = [
'projects' => ['pdf_data'],
'experiences' => ['pdf_data'],
'schools' => ['pdf_data'],
];
try {
// Transaction : si une erreur survient, TOUT est annulé (la base reste intacte)
$dbh->beginTransaction();
foreach ($tables as $table => $colonnesAutorisees) {
// On ne touche qu'aux tables présentes dans le fichier
if (!isset($donnees[$table]) || !is_array($donnees[$table])) {
continue;
}
// Sécurité : on refuse de vider la table users si la sauvegarde n'en contient aucun
// (sinon l'admin se retrouverait enfermé dehors sans aucun compte)
if ($table === 'users' && count($donnees[$table]) === 0) {
continue;
}
// On écrase les données existantes de la table
$dbh->exec('DELETE FROM `' . $table . '`');
// Colonnes binaires (PDF) de cette table, à redécoder du base64
$binaires = $colonnesBinaires[$table] ?? [];
// Puis on insère chaque ligne de la sauvegarde
foreach ($donnees[$table] as $ligne) {
if (!is_array($ligne)) {
continue;
}
// On ne garde que les colonnes autorisées ET présentes dans la ligne
$colonnes = array_values(array_intersect($colonnesAutorisees, array_keys($ligne)));
if (empty($colonnes)) {
continue;
}
$placeholders = array_map(function ($c) { return ':' . $c; }, $colonnes);
$stmt = $dbh->prepare(
'INSERT INTO `' . $table . '` (`' . implode('`, `', $colonnes) . '`) VALUES (' . implode(', ', $placeholders) . ')'
);
foreach ($colonnes as $colonne) {
$valeur = $ligne[$colonne];
// Les colonnes binaires (pdf_data) sont ré-encodées en base64 par l'export : on les redécode
if (in_array($colonne, $binaires, true) && $valeur !== null) {
$stmt->bindValue(':' . $colonne, base64_decode($valeur), PDO::PARAM_LOB);
} else {
$stmt->bindValue(':' . $colonne, $valeur);
}
}
$stmt->execute();
}
}
// Tout s'est bien passé : on valide définitivement
$dbh->commit();
} catch (Exception $e) {
// En cas d'erreur on annule tout : la base revient à son état d'avant l'import
$dbh->rollBack();
die("Erreur pendant l'import, aucune donnée n'a été modifiée : " . $e->getMessage());
}
// Log Discord
sendLog("Import d'une sauvegarde JSON : données du site écrasées");
// On renvoie l'utilisateur sur admin.php (?import=1 déclenche la notification de succès)
header('Location: ../pages/admin.php?import=1');
exit;
+5
View File
@@ -3,6 +3,8 @@
session_start();
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -26,6 +28,9 @@ try { // On essaie d'executer une requête qui est préparée
} catch (PDOException $e) {
die("Erreur lors de la suppression du projet :" . $e->getMessage());
}
// Log Discord
sendLog("Suppression du projet id " . $id);
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+6
View File
@@ -3,6 +3,8 @@
session_start();
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -26,6 +28,10 @@ try { // On essaie d'executer une requête qui est préparée
} catch (PDOException $e) {
die("Erreur lors de la suppression de l'école :" . $e->getMessage());
}
// Log Discord
sendLog("Suppression de l'école id " . $id);
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+6
View File
@@ -3,6 +3,8 @@
session_start();
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -26,6 +28,10 @@ try { // On essaie d'executer une requête qui est préparée
} catch (PDOException $e) {
die("Erreur lors de la suppression de la compétence :" . $e->getMessage());
}
// Log Discord
sendLog("Suppression de la catégorie de compétences id " . $id);
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+5
View File
@@ -3,6 +3,8 @@
session_start();
// Ajout de connexion à la BDD
require_once("../functions/databaseConnection.php");
// Ajout des logs Discord
require_once("../functions/sendLogs.php");
// Vérification que l'utilisateur est bien admin
if (!isset($_SESSION["role"]) || $_SESSION["role"] != "admin") {
@@ -26,6 +28,9 @@ if ($id == "") {
":id" => $id
]);
// Log Discord
sendLog("Suppression du compte id " . $id);
// Redirection vers la page admin après suppression
header("Location: ../pages/admin.php");
exit();
+6
View File
@@ -3,6 +3,8 @@
session_start();
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -26,6 +28,10 @@ try { // On essaie d'executer une requête qui est préparée
} catch (PDOException $e) {
die("Erreur lors de la suppression de l experience :" . $e->getMessage());
}
// Log Discord
sendLog("Suppression de l'expérience id " . $id);
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+7 -2
View File
@@ -2,6 +2,10 @@
// Envoi de message de contact via WebHook discord => Correction de faille de sécurité en cachant le webhook dans un fichier à part
require_once('../../idDiscord.php');
// Ajout des logs Discord
session_start();
require_once('../functions/sendLogs.php');
// Envoi de formulaire de contact via WebHook discord
$webhook = ID_DISCORD2; // URL du webhook Discord
// EXEMPLE de a quoi ressemble idDiscord :
@@ -55,12 +59,13 @@ if ($subject == "" || $name == "" || $mail == "" || $message == "") {
$resultat = curl_exec($ch);
$codeHttp = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// (pas de curl_close : déprécié depuis PHP 8.5, la ressource se libère toute seule)
if ($codeHttp == 204) { // Si ça renvoi 204 alors le message a été correctement envoyé
curl_close($ch); // Fermer la ressource curl lancée
// Log Discord : message de contact envoyé
sendLog("Nouveau message de contact envoyé par : " . $name);
header("Location: ../pages/contact.php?success=1");
exit();
} else { // Sinon c'est que ça n'a pas été envoyé (discord down / webhook expiré / etcc...)
curl_close($ch); // Fermer la ressource curl lancée
echo "Le message n'a pas pu être envoyé sur Discord.";
}
}
+52 -4
View File
@@ -3,6 +3,9 @@
session_start();
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -10,6 +13,12 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite (sinon le script redirige silencieusement vers admin.php sans explication)
if (postDepasseLimite()) {
die(messageDepassementPost());
}
if ( // Si les champs sont vides on renvoie vers admin.php
!isset($_POST['id']) || !is_numeric($_POST['id']) ||
empty($_POST['title']) ||
@@ -23,25 +32,64 @@ if ( // Si les champs sont vides on renvoie vers admin.php
// On récupère les données pour les mettres en variables locales
$id = (int) $_POST['id'];
$title = trim($_POST['title']);
$status = trim($_POST['status'] ?? '');
$tags = trim($_POST['tags'] ?? '');
$description = trim($_POST['description']);
$imageurl = trim($_POST['imageurl']);
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
// Le fichier envoyé n'est pas valide : on affiche l'erreur et on s'arrête
echo $pdfResultat;
exit;
}
// Le PDF n'est mis à jour que si un nouveau fichier est envoyé OU si la case
// "Supprimer ce PDF" est cochée. Sinon on garde le PDF actuel intact.
$updatePdf = false;
$pdfData = null;
$pdfFilename = null;
if (is_array($pdfResultat)) {
$updatePdf = true;
$pdfData = $pdfResultat['data'];
$pdfFilename = $pdfResultat['filename'];
} elseif (isset($_POST['remove_pdf'])) {
$updatePdf = true;
}
try { // On essaie d'executer une requête qui est préparée
$stmt = $dbh->prepare('
$sql = '
UPDATE projects
SET title = :title, tags = :tags, description = :description, image_url = :image_url
WHERE id = :id
');
SET title = :title, status = :status, tags = :tags, description = :description, image_url = :image_url, markdown = :markdown, link_url = :link_url';
if ($updatePdf) {
$sql .= ', pdf_data = :pdf_data, pdf_filename = :pdf_filename';
}
$sql .= ' WHERE id = :id';
$stmt = $dbh->prepare($sql);
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
if ($updatePdf) {
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
}
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
} catch (PDOException $e) {
die("Erreur lors de la modification du projet :" . $e->getMessage());
}
// Log Discord
sendLog("Modification du projet : " . $title . " (id " . $id . ")");
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+51 -4
View File
@@ -3,6 +3,9 @@
session_start();
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -10,6 +13,12 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite (sinon le script redirige silencieusement vers admin.php sans explication)
if (postDepasseLimite()) {
die(messageDepassementPost());
}
if ( // Si les champs sont vides on renvoie vers admin.php
!isset($_POST['id']) || !is_numeric($_POST['id']) ||
empty($_POST['title']) ||
@@ -24,27 +33,65 @@ if ( // Si les champs sont vides on renvoie vers admin.php
// On récupère les données pour les mettres en variables locales
$id = (int) $_POST['id'];
$title = trim($_POST['title']);
$status = trim($_POST['status'] ?? '');
$tags = trim($_POST['tags'] ?? '');
$description = trim($_POST['description']);
$duration = trim($_POST['duration']);
$imageurl = trim($_POST['imageurl']);
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
echo $pdfResultat;
exit;
}
// Le PDF n'est mis à jour que si un nouveau fichier est envoyé OU si la case
// "Supprimer ce PDF" est cochée. Sinon on garde le PDF actuel intact.
$updatePdf = false;
$pdfData = null;
$pdfFilename = null;
if (is_array($pdfResultat)) {
$updatePdf = true;
$pdfData = $pdfResultat['data'];
$pdfFilename = $pdfResultat['filename'];
} elseif (isset($_POST['remove_pdf'])) {
$updatePdf = true;
}
try { // On essaie d'executer une requête qui est préparée
$stmt = $dbh->prepare('
$sql = '
UPDATE schools
SET title = :title, tags = :tags, description = :description, duration = :duration, image_url = :image_url
WHERE id = :id
');
SET title = :title, status = :status, tags = :tags, description = :description, duration = :duration, image_url = :image_url, markdown = :markdown, link_url = :link_url';
if ($updatePdf) {
$sql .= ', pdf_data = :pdf_data, pdf_filename = :pdf_filename';
}
$sql .= ' WHERE id = :id';
$stmt = $dbh->prepare($sql);
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':duration', $duration, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
if ($updatePdf) {
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
}
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
} catch (PDOException $e) {
die("Erreur lors de la modification de l'école :" . $e->getMessage());
}
// Log Discord
sendLog("Modification de l'école : " . $title . " (id " . $id . ")");
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+5
View File
@@ -3,6 +3,8 @@
session_start();
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -38,6 +40,9 @@ try { // On essaie d'executer une requête qui est préparée pour chaque régla
die("Erreur lors de la modification de la notification :" . $e->getMessage());
}
// Log Discord
sendLog("Modification de la notification de la page d'accueil");
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+6
View File
@@ -3,6 +3,8 @@
session_start();
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -41,6 +43,10 @@ try { // On essaie d'executer une requête qui est préparée
} catch (PDOException $e) {
die("Erreur lors de la modification de la compétence :" . $e->getMessage());
}
// Log Discord
sendLog("Modification de la catégorie de compétences : " . $title . " (id " . $id . ")");
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+51 -4
View File
@@ -3,6 +3,9 @@
session_start();
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -10,6 +13,12 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite (sinon le script redirige silencieusement vers admin.php sans explication)
if (postDepasseLimite()) {
die(messageDepassementPost());
}
// Si le formulaire à un champ vide
if (
!isset($_POST['id']) || !is_numeric($_POST['id']) ||
@@ -26,29 +35,67 @@ if (
//On récupère les données pour les mettres en variables locales
$id = (int) $_POST['id'];
$title = trim($_POST['title']);
$status = trim($_POST['status'] ?? '');
$xptype = trim($_POST['xptype']);
$tags = trim($_POST['tags'] ?? '');
$description = trim($_POST['description']);
$duration = trim($_POST['duration']);
$imageurl = trim($_POST['imageurl']);
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
echo $pdfResultat;
exit;
}
// Le PDF n'est mis à jour que si un nouveau fichier est envoyé OU si la case
// "Supprimer ce PDF" est cochée. Sinon on garde le PDF actuel intact.
$updatePdf = false;
$pdfData = null;
$pdfFilename = null;
if (is_array($pdfResultat)) {
$updatePdf = true;
$pdfData = $pdfResultat['data'];
$pdfFilename = $pdfResultat['filename'];
} elseif (isset($_POST['remove_pdf'])) {
$updatePdf = true;
}
try { // On essaie d'executer une requête qui est préparée
$stmt = $dbh->prepare('
$sql = '
UPDATE experiences
SET title = :title, xptype = :xptype, tags = :tags, description = :description, duration = :duration, image_url = :image_url
WHERE id = :id
');
SET title = :title, status = :status, xptype = :xptype, tags = :tags, description = :description, duration = :duration, image_url = :image_url, markdown = :markdown, link_url = :link_url';
if ($updatePdf) {
$sql .= ', pdf_data = :pdf_data, pdf_filename = :pdf_filename';
}
$sql .= ' WHERE id = :id';
$stmt = $dbh->prepare($sql);
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':xptype', $xptype, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':duration', $duration, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
if ($updatePdf) {
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
}
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
} catch (PDOException $e) {
die("Erreur lors de la modification de l experience :" . $e->getMessage());
}
// Log Discord
sendLog("Modification de l'expérience : " . $title . " (id " . $id . ")");
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+48
View File
@@ -0,0 +1,48 @@
<?php
// Sert le PDF stocké en base pour un projet / une expérience / une école
// Utilisé en <iframe> par la page de détails (mini lecteur) et pour le téléchargement direct
require_once('../functions/databaseConnection.php');
// Liste blanche des types autorisés : empêche toute injection de nom de table
$tables = [
'projet' => 'projects',
'experience' => 'experiences',
'ecole' => 'schools',
];
$type = $_GET['type'] ?? '';
$id = $_GET['id'] ?? '';
if (!isset($tables[$type]) || !is_numeric($id)) {
http_response_code(404);
exit;
}
try {
$stmt = $dbh->prepare('SELECT pdf_data, pdf_filename FROM `' . $tables[$type] . '` WHERE id = :id');
$stmt->bindValue(':id', (int) $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
http_response_code(404);
exit;
}
if (!$row || $row['pdf_data'] === null) {
http_response_code(404);
exit;
}
$filename = !empty($row['pdf_filename']) ? $row['pdf_filename'] : 'document.pdf';
// ?download=1 force le téléchargement, sinon le PDF s'affiche dans le mini lecteur (iframe)
$disposition = (isset($_GET['download']) && $_GET['download'] === '1') ? 'attachment' : 'inline';
header('Content-Type: application/pdf');
header('Content-Disposition: ' . $disposition . '; filename="' . basename($filename) . '"');
header('Content-Length: ' . strlen($row['pdf_data']));
header('Cache-Control: private, max-age=3600');
echo $row['pdf_data'];
exit;
+648 -1
View File
@@ -357,10 +357,58 @@ img {
.hero {
max-width: 1200px;
margin: 0 auto;
padding: clamp(56px, 10vw, 120px) clamp(20px, 4vw, 40px) clamp(30px, 5vw, 60px);
/* Le hero occupe presque tout l'écran pour un rendu plus aéré */
min-height: calc(100vh - 220px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
padding: clamp(70px, 12vw, 140px) clamp(20px, 4vw, 40px) clamp(90px, 12vw, 130px);
text-align: center;
}
/* Flèche animée qui invite à défiler vers le bas */
.scroll-indicator {
position: absolute;
bottom: 26px;
left: 50%;
margin-left: -23px;
width: 46px;
height: 46px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--accent-light);
background: var(--accent-soft);
border: 1px solid var(--accent-border);
font-size: 17px;
opacity: 0;
animation: scrollFadeIn 0.6s ease 0.9s forwards, scrollBounce 2s ease-in-out 1.5s infinite;
transition: background-color 0.2s ease, color 0.2s ease;
}
.scroll-indicator:hover {
background: var(--accent);
color: white;
}
@keyframes scrollFadeIn {
to {
opacity: 1;
}
}
@keyframes scrollBounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(9px);
}
}
.hero-badge {
display: inline-flex;
align-items: center;
@@ -1415,6 +1463,596 @@ img {
display: none;
}
/* ==== CARTES CLIQUABLES (page markdown / lien externe) ==== */
/* Indicateur en bas de carte ; le ::after rend TOUTE la carte cliquable quand c'est un lien */
.projects-list .project-card {
position: relative;
}
.card-link {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: auto;
padding-top: 16px;
font-size: 14px;
font-weight: 600;
text-decoration: none;
color: var(--accent-light);
}
a.card-link::after {
content: '';
position: absolute;
inset: 0;
}
.card-link-none {
color: var(--text-muted);
font-weight: 500;
}
/* ==== PAGE DE DÉTAILS (markdown) ==== */
.details-page {
max-width: 920px;
margin: clamp(24px, 4vw, 40px) auto clamp(48px, 7vw, 72px);
padding: 0 clamp(14px, 4vw, 24px);
}
.details-back {
margin-bottom: 20px;
}
.details-card {
background: linear-gradient(180deg, var(--surface) 0%, var(--bg-soft) 100%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow);
}
.details-banner img {
width: 100%;
height: clamp(200px, 32vw, 320px);
object-fit: cover;
display: block;
}
.details-content {
padding: clamp(22px, 4vw, 40px);
}
.details-type {
display: inline-block;
padding: 6px 14px;
margin-bottom: 14px;
border-radius: 999px;
background: var(--accent-soft);
border: 1px solid var(--accent-border);
color: var(--accent-light);
font-size: 13px;
font-weight: 600;
}
.details-content h1 {
margin: 0 0 12px;
font-size: clamp(26px, 4vw, 38px);
font-weight: 800;
letter-spacing: -0.02em;
}
.details-meta {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin: 0 0 16px;
color: var(--text-muted);
font-size: 15px;
}
.details-meta i {
color: var(--accent-light);
margin-right: 6px;
}
/* Contenu markdown rendu (via marked.js) */
.markdown-content {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border);
color: var(--text-soft);
font-size: 16px;
line-height: 1.8;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.01em;
margin: 28px 0 12px;
line-height: 1.3;
}
.markdown-content h1 { font-size: 28px; }
.markdown-content h2 { font-size: 23px; }
.markdown-content h3 { font-size: 19px; }
.markdown-content h4 { font-size: 17px; }
.markdown-content p {
margin: 0 0 14px;
}
.markdown-content a {
color: var(--accent-light);
font-weight: 600;
}
.markdown-content ul,
.markdown-content ol {
margin: 0 0 14px;
padding-left: 26px;
}
.markdown-content li {
margin-bottom: 6px;
}
.markdown-content img {
max-width: 100%;
border-radius: var(--radius-sm);
margin: 10px 0;
}
.markdown-content code {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 7px;
font-size: 14px;
font-family: 'SF Mono', Menlo, Consolas, monospace;
color: var(--accent-light);
}
.markdown-content pre {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 16px 18px;
overflow-x: auto;
margin: 0 0 16px;
}
.markdown-content pre code {
background: none;
border: none;
padding: 0;
color: var(--text-soft);
}
.markdown-content blockquote {
margin: 0 0 16px;
padding: 10px 18px;
border-left: 3px solid var(--accent);
background: var(--accent-soft);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
color: var(--text-soft);
}
.markdown-content hr {
border: none;
height: 1px;
background: var(--border);
margin: 24px 0;
}
.markdown-content table {
border-collapse: collapse;
margin: 0 0 16px;
width: 100%;
}
.markdown-content th,
.markdown-content td {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
}
.markdown-content th {
background: var(--surface-2);
color: var(--text);
}
/* ==== MINI LECTEUR PDF (page de détails) ==== */
.pdf-viewer-wrap {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border);
}
.pdf-viewer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
color: var(--text-soft);
font-size: 14.5px;
font-weight: 600;
}
.pdf-viewer-toolbar i {
color: var(--accent-light);
margin-right: 8px;
}
.pdf-download-btn {
padding: 8px 16px;
font-size: 13.5px;
text-decoration: none;
}
.pdf-viewer {
width: 100%;
height: 75vh;
min-height: 420px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #525659;
}
@media (max-width: 700px) {
.pdf-viewer {
height: 60vh;
}
}
/* ==== PANEL ADMIN (sous-catégories) ==== */
.admin-page {
max-width: 1280px;
margin: 0 auto;
padding-bottom: 40px;
}
/* Sous-navigation par catégorie */
.admin-nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 1200px;
margin: 20px auto 8px;
padding: 0 clamp(16px, 4vw, 40px);
}
.admin-nav a {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text-soft);
text-decoration: none;
font-size: 14px;
font-weight: 600;
transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
}
.admin-nav a:hover {
color: var(--accent-light);
border-color: var(--accent-border);
transform: translateY(-1px);
}
.admin-nav a i {
color: var(--accent-light);
}
/* Sections du panel */
.admin-section {
max-width: 1280px;
margin: 0 auto;
padding: 26px 0 6px;
/* L'ancre s'arrête sous le header sticky */
scroll-margin-top: 84px;
}
.admin-section-title {
display: flex;
align-items: center;
gap: 12px;
max-width: 1200px;
margin: 0 auto 16px;
padding: 0 clamp(16px, 4vw, 40px);
font-size: clamp(21px, 3vw, 26px);
font-weight: 800;
letter-spacing: -0.01em;
}
.admin-section-title i {
color: var(--accent-light);
font-size: 0.85em;
}
/* Bloc repliable contenant chaque formulaire */
.admin-block {
max-width: 1200px;
margin: 0 auto 20px;
padding: 0 clamp(16px, 4vw, 40px);
}
.admin-block summary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 11px 20px;
border-radius: var(--radius-sm);
background: var(--surface-2);
border: 1px solid var(--border-strong);
color: var(--text);
font-size: 15px;
font-weight: 600;
cursor: pointer;
user-select: none;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.admin-block summary:hover {
border-color: var(--accent-border);
}
.admin-block[open] summary {
border-color: var(--accent-border);
color: var(--accent-light);
margin-bottom: 14px;
}
.admin-block .project-form-card {
width: 100%;
}
/* Grand champ markdown dans les formulaires admin */
.project-form-card .markdown-textarea {
min-height: 220px;
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 13.5px;
}
/* ==== DRAG & DROP PDF (panel admin) ==== */
/* Bandeau indiquant le PDF déjà attaché, avec option de suppression */
.pdf-current {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
padding: 12px 16px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-soft);
font-size: 14px;
}
.pdf-current i {
color: var(--accent-light);
font-size: 18px;
}
.pdf-current a {
color: var(--accent-light);
font-weight: 600;
text-decoration: none;
}
.pdf-current a:hover {
text-decoration: underline;
}
.pdf-remove-row {
margin: 0 0 0 auto;
}
/* Zone de dépôt du fichier PDF */
.pdf-dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px 20px;
margin-bottom: 16px;
text-align: center;
background: var(--bg-soft);
border: 2px dashed var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.15s ease;
}
.pdf-dropzone i {
font-size: 26px;
color: var(--accent-light);
}
.pdf-dropzone p {
margin: 0;
font-size: 14px;
}
.pdf-dropzone-browse {
color: var(--accent-light);
font-weight: 600;
text-decoration: underline;
}
.pdf-dropzone-filename {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
/* Survol pendant qu'un fichier est glissé au-dessus de la zone */
.pdf-dropzone.dragging {
border-color: var(--accent);
background: var(--accent-soft);
transform: scale(1.01);
}
/* Un fichier valide a été sélectionné */
.pdf-dropzone.has-file {
border-style: solid;
border-color: var(--accent);
}
/* Section Données (export / import) */
.admin-data-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
}
.admin-data-title i {
color: var(--accent-light);
margin-right: 8px;
}
.admin-data-text {
margin: 0 0 16px;
color: var(--text-muted);
font-size: 14.5px;
line-height: 1.6;
}
.admin-data-warning {
color: #ffb8bb;
}
[data-theme="light"] .admin-data-warning {
color: #b03538;
}
.admin-export-btn {
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
.admin-data-separator {
border: none;
height: 1px;
background: var(--border);
margin: 24px 0;
}
.project-form-card input[type="file"] {
display: block;
width: 100%;
margin-bottom: 16px;
padding: 12px;
background: var(--surface-2);
border: 1px dashed var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text-soft);
font-family: var(--font);
font-size: 14px;
}
/* ==== BOUTON FLOTTANT EXPÉRIENCES <-> ÉCOLES ==== */
.section-jump {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
width: 54px;
height: 54px;
border-radius: 50%;
border: none;
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
color: white;
font-size: 20px;
cursor: pointer;
box-shadow: 0 10px 26px rgba(255, 122, 26, 0.4);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.section-jump:hover {
transform: translateY(-3px);
box-shadow: 0 14px 32px rgba(255, 122, 26, 0.5);
}
/* Par défaut (dans les expériences) : chapeau de diplômé = aller aux écoles */
.section-jump .fa-briefcase {
display: none;
}
/* Dans la section écoles : sac de travail = remonter aux expériences */
.section-jump.mode-ecoles .fa-graduation-cap {
display: none;
}
.section-jump.mode-ecoles .fa-briefcase {
display: inline;
}
/* ==== NOTIFICATIONS TOAST ==== */
.toast {
position: fixed;
bottom: 26px;
left: 50%;
transform: translateX(-50%);
z-index: 300;
display: flex;
align-items: center;
gap: 12px;
max-width: calc(100vw - 40px);
padding: 15px 22px;
background: var(--surface-2);
border: 1px solid var(--accent-border);
border-radius: 14px;
color: var(--text);
font-size: 15px;
font-weight: 500;
box-shadow: var(--shadow);
cursor: pointer;
animation: toastIn 0.4s ease, toastOut 0.5s ease 4.5s forwards;
}
.toast i {
color: var(--success);
font-size: 19px;
flex-shrink: 0;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(24px);
}
}
@keyframes toastOut {
to {
opacity: 0;
transform: translateX(-50%) translateY(12px);
visibility: hidden;
}
}
/* ==== ANIMATIONS & INTERACTIONS ==== */
/* Transition entre les pages : fondu de l'ancienne page vers la nouvelle
@@ -1557,4 +2195,13 @@ main {
.footer-links .discord-handle:hover {
transform: none;
}
.scroll-indicator {
animation: none;
opacity: 1;
}
.toast {
animation: none;
}
}