Files
2026-08-28 00:04:48 +02:00

195 lines
7.4 KiB
PHP

<?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
// (le PDF est stocké dans une table associée depuis la migration Juillet 2026 (6) : on la
// joint juste pour le nom de fichier, jamais pour son contenu binaire)
$types = [
'projet' => ['table' => 'projects', 'pdf' => 'project_pdfs', 'pdf_colonne' => 'project_id', 'retour' => './projects.php', 'label' => 'Projet'],
'experience' => ['table' => 'experiences', 'pdf' => 'experience_pdfs', 'pdf_colonne' => 'experience_id', 'retour' => './xp.php', 'label' => 'Expérience'],
'ecole' => ['table' => 'schools', 'pdf' => 'school_pdfs', 'pdf_colonne' => 'school_id', '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 = 'e.id, e.title, e.status, e.tags, e.description, e.image_url, e.markdown, e.link_url, p.pdf_filename';
$colonnesParType = [
'projet' => $colonnesCommunes,
'experience' => $colonnesCommunes . ', e.xptype, e.duration',
'ecole' => $colonnesCommunes . ', e.duration',
];
try {
$stmt = $dbh->prepare(
'SELECT ' . $colonnesParType[$type] . '
FROM `' . $infos['table'] . '` e
LEFT JOIN `' . $infos['pdf'] . '` p ON p.`' . $infos['pdf_colonne'] . '` = e.id
WHERE e.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
// Bandeau d'image, seulement si une image est renseignée pour cet élément
if (!empty($image)) {
echo '<div class="details-banner project-banner">';
echo '<img src="' . $image . '" alt="Image : ' . $titre . '">';
// Pastille d'état (En cours, Terminé...) posée par-dessus l'image
if (!empty($status)) {
echo '<div class="project-state">' . $status . '</div>';
}
echo '</div>';
}
?>
<div class="details-content">
<span class="details-type"><?php echo $infos['label']; ?></span>
<h1><?php echo $titre; ?></h1>
<?php
// Métadonnées : type d'expérience et durée, affichés seulement si renseignés
if (!empty($xptype) || !empty($duration)) {
echo '<p class="details-meta">';
if (!empty($xptype)) {
echo '<span><i class="fa-solid fa-tag"></i> ' . $xptype . '</span>';
}
if (!empty($duration)) {
echo '<span><i class="fa-solid fa-clock"></i> ' . $duration . '</span>';
}
echo '</p>';
}
// Étiquettes (technologies, mots-clés), une par tag séparé par une virgule en BDD
if (!empty($tags)) {
echo '<div class="project-tags">';
foreach ($tags as $tag) {
echo '<span class="tag">' . htmlspecialchars($tag) . '</span>';
}
echo '</div>';
}
?>
<?php
// Deux contenus possibles, jamais les deux en même temps : soit un texte en
// markdown, soit un PDF. Le markdown est prioritaire quand les deux existent.
if ($hasMarkdown) {
// Le markdown brut est placé ici (échappé), puis rendu en HTML par le script plus bas
echo '<div id="markdown-source" hidden>' . htmlspecialchars($element['markdown']) . '</div>';
echo '<div class="markdown-content" id="markdown-target">';
echo '<p>Chargement du contenu...</p>';
echo '</div>';
} elseif ($hasPdf) {
// Mini lecteur PDF : le fichier est servi par viewPdf.php depuis la base de données
$lienPdf = '../scripts/viewPdf.php?type=' . $type . '&id=' . (int) $id;
echo '<div class="pdf-viewer-wrap">';
echo '<div class="pdf-viewer-toolbar">';
echo '<span><i class="fa-solid fa-file-pdf"></i> ' . htmlspecialchars($element['pdf_filename']) . '</span>';
echo '<a class="btn btn-ghost pdf-download-btn" href="' . $lienPdf . '&download=1" download>';
echo '<i class="fa-solid fa-download"></i> Télécharger';
echo '</a>';
echo '</div>';
echo '<iframe class="pdf-viewer" src="' . $lienPdf . '" title="Aperçu PDF : ' . $titre . '"></iframe>';
echo '</div>';
}
?>
</div>
</article>
</div>
<?php
// Ces deux librairies ne sont chargées que sur les pages qui ont réellement du markdown :
// marked.js convertit le markdown en HTML, DOMPurify nettoie le HTML généré (sécurité XSS)
if ($hasMarkdown) {
echo '<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>';
echo '<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>';
// <<<'JS' ... JS; = "nowdoc" : un bloc de texte sur plusieurs lignes, recopié tel quel.
// Pratique ici pour ne pas avoir à échapper les guillemets du JavaScript.
echo <<<'JS'
<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>
JS;
}
?>
<?php
// Ajout du footer
require_once('../elements/footer.php'); ?>