feat: stockage articles en fichiers Markdown, SSO intégré, URLs propres

This commit is contained in:
Cedric Abonnel
2026-05-08 22:36:04 +02:00
parent aa9c04d154
commit fd3fced0d8
22 changed files with 863 additions and 352 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ ob_start();
<div class="card mb-4">
<div class="card-body">
<p class="mb-0">
Vous pouvez me joindre via le <a href="route.php?action=contact">formulaire de contact</a>.
Vous pouvez me joindre via le <a href="/?action=contact">formulaire de contact</a>.
Je lis tous les messages, même si je ne réponds pas toujours vite.
</p>
</div>
+1 -1
View File
@@ -96,7 +96,7 @@ ob_start();
<div class="card">
<div class="card-body">
<form method="POST" action="route.php?action=contact" novalidate>
<form method="POST" action="/?action=contact" novalidate>
<input type="hidden" name="_token" value="<?= htmlspecialchars($_SESSION['contact_csrf']) ?>">
<!-- Honeypot -->
<div style="display:none" aria-hidden="true">
+18 -6
View File
@@ -30,7 +30,7 @@
<header>
<nav class="navbar navbar-expand-lg navbar-light mb-0" role="navigation" aria-label="Navigation principale">
<div class="container-fluid">
<a class="navbar-brand d-flex flex-column lh-1" href="route.php">
<a class="navbar-brand d-flex flex-column lh-1" href="/">
<span>varlog</span>
<small class="navbar-tagline">journal de Cédrix · informatique, hack &amp; loisirs</small>
</a>
@@ -39,7 +39,19 @@
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a class="nav-link" href="route.php?action=create">Nouveau post</a></li>
<?php if (function_exists('isAdmin') && isAdmin()): ?>
<li class="nav-item"><a class="nav-link" href="/?action=create">Nouveau post</a></li>
<?php endif; ?>
<?php if (function_exists('isLoggedIn') && isLoggedIn()): ?>
<li class="nav-item">
<a class="nav-link" href="/logout.php" title="Déconnexion">
<?= htmlspecialchars(currentUserEmail() ?? '') ?>
<small class="text-muted">(déconnexion)</small>
</a>
</li>
<?php else: ?>
<li class="nav-item"><a class="nav-link" href="/login">Connexion</a></li>
<?php endif; ?>
</ul>
</div>
</div>
@@ -59,10 +71,10 @@
<small>&copy; <?= date('Y') ?> &mdash; <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a></small>
</div>
<nav class="footer-nav" aria-label="Liens du site">
<a href="route.php?action=about">À propos</a>
<a href="route.php?action=contact">Contact</a>
<a href="route.php?action=legal">Mentions légales</a>
<a href="route.php?action=licenses">Licences</a>
<a href="/?action=about">À propos</a>
<a href="/?action=contact">Contact</a>
<a href="/?action=legal">Mentions légales</a>
<a href="/?action=licenses">Licences</a>
</nav>
</div>
</div>
+3 -3
View File
@@ -35,7 +35,7 @@ ob_start();
<div class="card-body">
<p class="mb-1"><strong>Responsable de publication :</strong> Cédric Abonnel</p>
<p class="mb-1"><strong>Qualité :</strong> Particulier — site personnel non commercial</p>
<p class="mb-0"><strong>Contact :</strong> <a href="route.php?action=contact">formulaire de contact</a></p>
<p class="mb-0"><strong>Contact :</strong> <a href="/?action=contact">formulaire de contact</a></p>
</div>
</div>
</section>
@@ -74,7 +74,7 @@ ob_start();
</p>
<p class="mb-0">
Les composants tiers (Bootstrap, PHPMailer, police Inter…) sont soumis à leurs licences respectives,
détaillées sur la <a href="route.php?action=licenses">page des licences</a>.
détaillées sur la <a href="/?action=licenses">page des licences</a>.
</p>
</div>
</div>
@@ -99,7 +99,7 @@ ob_start();
<p class="mb-0">
Conformément au RGPD (règlement UE 2016/679), vous disposez d'un droit d'accès, de rectification
et de suppression des données vous concernant. Pour exercer ces droits :
<a href="route.php?action=contact">formulaire de contact</a>.
<a href="/?action=contact">formulaire de contact</a>.
</p>
</div>
</div>
+85 -28
View File
@@ -1,64 +1,121 @@
<?php
ob_start();
// Valeur par défaut pour le champ datetime-local
$dateValue = $published_at ?? date('Y-m-d\TH:i');
$dateValue = isset($published_at)
? (str_contains($published_at, ' ')
? date('Y-m-d\TH:i', strtotime($published_at))
: $published_at)
: date('Y-m-d\TH:i');
?>
<h1 class="mb-4"><?= $action === 'edit' ? 'Modifier le post' : 'Créer un nouveau post' ?></h1>
<h1 class="mb-4"><?= $action === 'edit' ? 'Modifier l\'article' : 'Nouvel article' ?></h1>
<?php if (!empty($errors)): ?>
<div class="alert alert-danger">
<ul class="mb-0">
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<div class="alert alert-danger">
<ul class="mb-0">
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="POST" action="<?= htmlspecialchars($formAction) ?>" enctype="multipart/form-data">
<div class="mb-3">
<label for="title" class="form-label">Titre</label>
<input type="text" class="form-control" id="title" name="title" required value="<?= htmlspecialchars($title) ?>">
<input type="text" class="form-control" id="title" name="title" required
value="<?= htmlspecialchars($title ?? '') ?>"
oninput="autoSlug(this.value)">
</div>
<div class="mb-3">
<label for="slug" class="form-label">
Slug <small class="text-muted">(URL : /post/<span id="slug-preview"><?= htmlspecialchars($postSlug ?? '') ?></span>)</small>
</label>
<input type="text" class="form-control form-control-sm font-monospace" id="slug" name="slug"
value="<?= htmlspecialchars($postSlug ?? '') ?>"
pattern="[a-z0-9][a-z0-9-]*"
placeholder="généré automatiquement depuis le titre">
</div>
<div class="mb-2">
<small class="text-muted">
Écris en <strong>Markdown</strong> <a href="https://www.markdownguide.org/cheat-sheet/" target="_blank">guide rapide</a>
Écris en <strong>Markdown</strong> — les fichiers uploadés sont référençables dans le contenu :
<code>![alt](nom-du-fichier.jpg)</code>
</small>
</div>
<div class="mb-3">
<label for="content" class="form-label">Contenu</label>
<textarea class="form-control" id="content" name="content" rows="6"><?= htmlspecialchars($content) ?></textarea>
<textarea class="form-control font-monospace" id="content" name="content" rows="12"><?= htmlspecialchars($content ?? '') ?></textarea>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="published_at" class="form-label">Date de publication</label>
<input type="datetime-local" class="form-control" id="published_at" name="published_at" value="<?= $dateValue ?>">
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="published" name="published" <?= ($published ?? false) ? 'checked' : '' ?>>
<label class="form-check-label" for="published">Publié</label>
<div class="row mb-3">
<div class="col-md-6">
<label for="published_at" class="form-label">Date de publication</label>
<input type="datetime-local" class="form-control" id="published_at" name="published_at" value="<?= $dateValue ?>">
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="published" name="published"
<?= ($published ?? false) ? 'checked' : '' ?>>
<label class="form-check-label" for="published">Publié</label>
</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="files" class="form-label">Fichiers</label>
<label for="files" class="form-label">Ajouter des fichiers</label>
<input type="file" class="form-control" id="files" name="files[]" multiple>
<div class="form-text">Images, vidéos, PDF… — intègre-les dans le contenu ou laisse-les en pièces jointes.</div>
</div>
<?php if ($action === 'edit' && !empty($existingFiles)): ?>
<div class="mb-3">
<p class="form-label">Fichiers existants</p>
<ul class="list-unstyled">
<?php foreach ($existingFiles as $f): ?>
<li>
<code><?= htmlspecialchars($f['name']) ?></code>
<small class="text-muted ms-2"><?= htmlspecialchars(number_format($f['size'] / 1024, 1)) ?> Ko</small>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-success">Enregistrer</button>
<a href="route.php" class="btn btn-secondary">Annuler</a>
<a href="/" class="btn btn-secondary">Annuler</a>
</form>
<script>
function slugify(s) {
const map = {'à':'a','â':'a','ä':'a','é':'e','è':'e','ê':'e','ë':'e','î':'i','ï':'i','ô':'o','ö':'o','ù':'u','û':'u','ü':'u','ç':'c','æ':'ae','œ':'oe'};
return s.toLowerCase().replace(/[àâäéèêëîïôöùûüçæœ]/g, c => map[c] || c)
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
function autoSlug(title) {
const slugField = document.getElementById('slug');
const preview = document.getElementById('slug-preview');
// N'écrase le slug que s'il est vide ou s'il correspond à la génération automatique
if (slugField._auto !== false) {
const generated = slugify(title);
slugField.value = generated;
preview.textContent = generated;
}
}
document.getElementById('slug').addEventListener('input', function() {
this._auto = (this.value === '');
document.getElementById('slug-preview').textContent = this.value;
});
// En mode édition le champ est pré-rempli : désactive l'auto-génération
(function() {
const s = document.getElementById('slug');
if (s.value !== '') s._auto = false;
})();
</script>
<?php
$content = ob_get_clean();
$title = $action === 'edit' ? 'Modifier le post' : 'Nouveau post';
$title = $action === 'edit' ? 'Modifier l\'article' : 'Nouvel article';
include __DIR__ . '/layout.php';
+11 -8
View File
@@ -15,32 +15,35 @@ ob_start();
?>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
<?php foreach ($posts as $post): ?>
<?php foreach ($posts as $i => $post): ?>
<?php
$html = $Parsedown->text($post['content']);
$preview = mb_strimwidth(strip_tags($html), 0, 120, '…');
$gradient = $coverGradients[$post['id'] % count($coverGradients)];
$gradient = $coverGradients[$i % count($coverGradients)];
$postUrl = '/post/' . rawurlencode($post['slug']);
?>
<div class="col">
<article class="card h-100">
<div class="card-cover" style="background: <?= $gradient ?>"></div>
<div class="card-body d-flex flex-column">
<h2 class="card-title">
<a href="route.php?action=view&id=<?= $post['id'] ?>">
<a href="<?= htmlspecialchars($postUrl) ?>">
<?= htmlspecialchars($post['title']) ?>
</a>
<?php if (!$post['is_published']): ?>
<?php if (!$post['published']): ?>
<span class="badge bg-warning ms-1">Brouillon</span>
<?php endif; ?>
</h2>
<p class="card-text flex-grow-1"><?= htmlspecialchars($preview) ?></p>
<div class="post-entry-meta mt-auto">
<span><?= date('d/m/Y', strtotime($post['created_at'])) ?></span>
<a href="route.php?action=edit&id=<?= $post['id'] ?>" class="post-entry-edit">modifier</a>
<a href="route.php?action=view&id=<?= $post['id'] ?>" class="post-entry-read">→ lire</a>
<span><?= htmlspecialchars(date('d/m/Y', strtotime((string)($post['created_at'] ?? '')))) ?></span>
<?php if (function_exists('isAdmin') && isAdmin()): ?>
<a href="/?action=edit&uuid=<?= htmlspecialchars($post['uuid']) ?>" class="post-entry-edit">modifier</a>
<?php endif; ?>
<a href="<?= htmlspecialchars($postUrl) ?>" class="post-entry-read">→ lire</a>
</div>
</div>
<a href="route.php?action=view&id=<?= $post['id'] ?>" class="stretched-link"></a>
<a href="<?= htmlspecialchars($postUrl) ?>" class="stretched-link"></a>
</article>
</div>
<?php endforeach; ?>
+53 -46
View File
@@ -5,66 +5,73 @@ $Parsedown = new Parsedown();
ob_start();
?>
<a href="route.php" class="btn btn-secondary mb-3">← Retour</a>
<a href="/" class="btn btn-secondary mb-3">← Retour</a>
<div class="card mb-4">
<div class="card-body">
<h2 class="card-title"><?= htmlspecialchars($post['title']) ?></h2>
<h2 class="card-title"><?= htmlspecialchars($article['title']) ?></h2>
<div class="card-text post-content">
<?= $Parsedown->text($post['content']) ?>
<?= $Parsedown->text($rawContent) ?>
</div>
<p class="text-muted small mt-2">Publié le <?= $post['created_at'] ?></p>
<p class="text-muted small mt-2">
Publié le <?= htmlspecialchars(date('d/m/Y H:i', strtotime((string)($article['published_at'] ?? $article['created_at'] ?? '')))) ?>
</p>
</div>
</div>
<?php
require_once __DIR__ . '/../src/FileManager.php';
$uploadDir = __DIR__ . '/../public/assets/uploads';
$publicDir = 'assets/uploads';
$fileManager = new FileManager($db, $uploadDir);
$files = $fileManager->getFilesForPost($post['id']);
?>
<?php if ($files): ?>
<h5>Fichiers attachés</h5>
<div class="row">
<?php foreach ($files as $file): ?>
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-body">
<?php
$fileUrl = $publicDir . '/' . $file['file_path'];
$type = $file['file_type'];
?>
<?php if ($type === 'image'): ?>
<img src="<?= $fileUrl ?>" class="img-fluid" alt="<?= htmlspecialchars($file['original_name']) ?>">
<?php elseif ($type === 'video'): ?>
<video controls class="w-100">
<source src="<?= $fileUrl ?>" type="video/mp4">
</video>
<?php elseif ($type === 'audio'): ?>
<audio controls class="w-100">
<source src="<?= $fileUrl ?>" type="audio/mpeg">
</audio>
<?php else: ?>
<p><a href="<?= $fileUrl ?>" target="_blank">📎 <?= htmlspecialchars($file['original_name']) ?></a></p>
<?php endif; ?>
</div>
<div class="card-footer text-end">
<small class="text-muted">Ajouté le <?= $file['uploaded_at'] ?></small>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php
// Sépare les fichiers intégrés (référencés dans le contenu) des pièces jointes
$referenced = [];
preg_match_all('/\(\/file\?uuid=[^&]+&name=([^)]+)\)/', $rawContent, $m);
foreach ($m[1] as $encodedName) {
$referenced[rawurldecode($encodedName)] = true;
}
$attachments = array_filter($files, static fn ($f) => !isset($referenced[$f['name']]));
?>
<?php if ($attachments): ?>
<section class="mb-4">
<h5>Pièces jointes</h5>
<div class="row g-3">
<?php foreach ($attachments as $file): ?>
<div class="col-sm-6 col-md-4">
<div class="card">
<?php
$fileUrl = '/file?uuid=' . rawurlencode($article['uuid']) . '&name=' . rawurlencode($file['name']);
?>
<?php if ($file['is_image']): ?>
<img src="<?= htmlspecialchars($fileUrl) ?>" class="card-img-top" alt="<?= htmlspecialchars($file['name']) ?>" style="max-height:200px;object-fit:cover">
<?php elseif ($file['is_video']): ?>
<video controls class="w-100" style="max-height:200px"><source src="<?= htmlspecialchars($fileUrl) ?>"></video>
<?php elseif ($file['is_audio']): ?>
<audio controls class="w-100"><source src="<?= htmlspecialchars($fileUrl) ?>"></audio>
<?php endif; ?>
<div class="card-body p-2">
<a href="<?= htmlspecialchars($fileUrl) ?>" class="card-title small d-block text-truncate" target="_blank">
<?= htmlspecialchars($file['name']) ?>
</a>
<small class="text-muted"><?= htmlspecialchars(number_format($file['size'] / 1024, 1)) ?> Ko</small>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php endif; ?>
<a href="route.php?action=delete&id=<?= $post['id'] ?>" class="btn btn-danger mt-3" onclick="return confirm('Supprimer ce post ?')">Supprimer ce post</a>
<?php if (function_exists('isAdmin') && isAdmin()): ?>
<div class="d-flex gap-2 mt-3">
<a href="/?action=edit&uuid=<?= htmlspecialchars($article['uuid']) ?>" class="btn btn-primary">Modifier</a>
<a href="/?action=delete&uuid=<?= htmlspecialchars($article['uuid']) ?>"
class="btn btn-danger"
onclick="return confirm('Supprimer cet article définitivement ?')">Supprimer</a>
</div>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = htmlspecialchars($post['title']);
$title = htmlspecialchars($article['title']);
include __DIR__ . '/layout.php';