feat: déplacer 'À lire aussi' après les réactions dans la colonne principale

This commit is contained in:
Cedric Abonnel
2026-05-13 01:32:03 +02:00
parent 0a44ab9da2
commit 78d6c656be
7 changed files with 355 additions and 21 deletions
+6
View File
@@ -36,6 +36,12 @@ RewriteRule ^admin/role/([a-z0-9_-]+)/?$ /index.php?action=admin_role_edit&role_
RewriteRule ^admin/([a-z0-9-]+)/?$ /index.php?action=admin&tab=$1 [L,QSA]
RewriteRule ^admin/?$ /index.php?action=admin [L,QSA]
# Réactions et commentaires
RewriteRule ^react/?$ /index.php?action=react [L,QSA]
RewriteRule ^comment/?$ /index.php?action=comment [L,QSA]
RewriteRule ^comment-moderate/?$ /index.php?action=comment_moderate [L,QSA]
RewriteRule ^verify-comment/([0-9]{6})/?$ /index.php?action=verify_comment&code=$1 [L,QSA]
# Pages de gestion
RewriteRule ^categories/?$ /index.php?action=categories [L,QSA]
RewriteRule ^profile/?$ /index.php?action=profile [L,QSA]
+30
View File
@@ -681,6 +681,36 @@ textarea.form-control {
border-color: var(--vl-accent);
}
.also-read-title {
font-size: .7rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--vl-muted);
margin-bottom: .75rem;
}
.also-read-grid {
display: flex;
flex-wrap: wrap;
gap: .75rem;
}
.also-read-grid .related-card {
flex: 1 1 200px;
max-width: 280px;
background: var(--vl-surface);
border-radius: var(--vl-radius);
padding: .65rem;
box-shadow: var(--vl-shadow-sm);
transition: box-shadow .15s, transform .15s;
}
.also-read-grid .related-card:hover {
box-shadow: var(--vl-shadow-md);
transform: translateY(-1px);
}
.related-card {
display: flex;
gap: 0.75rem;
+212 -1
View File
@@ -22,7 +22,7 @@ $action = $_GET['action'] ?? 'list';
$uuid = $_GET['uuid'] ?? '';
$slug = $_GET['slug'] ?? '';
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links'];
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate'];
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
unset($_noindexActions);
@@ -635,6 +635,38 @@ switch ($action) {
$backlinks = $articles->getBacklinks($article['slug'] ?? '', $article['uuid']);
// Réactions et commentaires
require_once BASE_PATH . '/src/ReactionManager.php';
require_once BASE_PATH . '/src/CommentManager.php';
$reactionStats = array_fill_keys(ReactionManager::TYPES, 0);
$visitorReactions = [];
$comments = [];
$commentFlash = isset($_GET['commented']);
$commentVerified = isset($_GET['verified']);
$commentError = null;
if ($pdo) {
$reactionMgr = new ReactionManager($pdo);
$commentMgr = new CommentManager($pdo);
// Cookie visiteur (fingerprint anti-doublon)
if (empty($_COOKIE['vl_vid'])) {
$vid = bin2hex(random_bytes(16));
setcookie('vl_vid', $vid, [
'expires' => time() + 365 * 86400,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
} else {
$vid = $_COOKIE['vl_vid'];
}
$reactionStats = $reactionMgr->statsForArticle($article['uuid']);
$visitorReactions = $reactionMgr->visitorReactions($article['uuid'], $vid);
$comments = $commentMgr->forArticle($article['uuid']);
}
include BASE_PATH . '/templates/post_view.php';
break;
@@ -1616,6 +1648,161 @@ switch ($action) {
header('Location: /edit/' . rawurlencode($uuid));
exit;
case 'react':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$reactUuid = trim($_POST['uuid'] ?? '');
$reactType = trim($_POST['type'] ?? '');
$isAjax = ($_POST['_ajax'] ?? '') === '1';
// Cookie visiteur
if (empty($_COOKIE['vl_vid'])) {
$vid = bin2hex(random_bytes(16));
setcookie('vl_vid', $vid, [
'expires' => time() + 365 * 86400,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
} else {
$vid = $_COOKIE['vl_vid'];
}
$pdo = dbPdo();
if ($pdo && $reactUuid !== '') {
require_once BASE_PATH . '/src/ReactionManager.php';
$rm = new ReactionManager($pdo);
$added = $rm->toggle($reactUuid, $reactType, $vid);
$count = $rm->statsForArticle($reactUuid)[$reactType] ?? 0;
if ($isAjax) {
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'active' => $added, 'count' => $count]);
exit;
}
}
$reactBack = $_POST['_back'] ?? '/';
header('Location: ' . $reactBack);
exit;
case 'comment':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
// Honeypot
if (($_POST['website'] ?? '') !== '') {
header('Location: /');
exit;
}
// CSRF
$csrfOk = isset($_POST['_token'], $_SESSION['comment_csrf'])
&& hash_equals($_SESSION['comment_csrf'], $_POST['_token']);
unset($_SESSION['comment_csrf']);
if (!$csrfOk) {
header('Location: /');
exit;
}
$cmtUuid = trim($_POST['uuid'] ?? '');
$cmtName = trim($_POST['author_name'] ?? '');
$cmtEmail = trim($_POST['author_email'] ?? '');
$cmtContent = trim($_POST['content'] ?? '');
$cmtArticle = $cmtUuid !== '' ? $articles->getByUuid($cmtUuid) : null;
$cmtBack = $cmtArticle ? '/post/' . rawurlencode($cmtArticle['slug'] ?? $cmtUuid) : '/';
$pdo = dbPdo();
if (!$pdo || !$cmtArticle || $cmtName === '' || !filter_var($cmtEmail, FILTER_VALIDATE_EMAIL) || $cmtContent === '') {
header('Location: ' . $cmtBack . '#comment-form-card');
exit;
}
if (mb_strlen($cmtContent) > 2000) {
header('Location: ' . $cmtBack . '#comment-form-card');
exit;
}
require_once BASE_PATH . '/src/CommentManager.php';
require_once BASE_PATH . '/src/mailer.php';
$cm = new CommentManager($pdo);
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$code = $cm->submit($cmtUuid, $cmtName, $cmtEmail, $cmtContent, $ip, $ua);
$verifyUrl = rtrim(APP_URL, '/') . '/verify-comment/' . $code;
$articleUrl = rtrim(APP_URL, '/') . $cmtBack;
$subject = '[' . siteTitle() . '] Confirmez votre commentaire';
$html = '<!DOCTYPE html><html><body style="font-family:sans-serif;max-width:560px;margin:0 auto">'
. '<p>Bonjour ' . htmlspecialchars($cmtName) . ',</p>'
. '<p>Cliquez sur le bouton ci-dessous pour confirmer votre commentaire sur <em>' . htmlspecialchars($cmtArticle['title']) . '</em> :</p>'
. '<p><a href="' . htmlspecialchars($verifyUrl) . '" style="display:inline-block;padding:10px 20px;background:#0d6efd;color:#fff;text-decoration:none;border-radius:4px">Confirmer mon commentaire</a></p>'
. '<p style="color:#888;font-size:.875em">Ou copiez ce lien dans votre navigateur :<br>' . htmlspecialchars($verifyUrl) . '</p>'
. '<p style="color:#888;font-size:.875em">Ce lien expire dans 24 heures. Si vous n\'êtes pas à l\'origine de ce message, ignorez-le.</p>'
. '</body></html>';
try {
envoyer_mail_smtp($cmtEmail, $subject, $html);
} catch (\RuntimeException) {
// Taux limité ou erreur SMTP : on continue sans planter le visiteur
}
header('Location: ' . $cmtBack . '?commented=1#comments');
exit;
case 'verify_comment':
$vcCode = trim($_GET['code'] ?? '');
$pdo = dbPdo();
if ($pdo && preg_match('/^[0-9]{6}$/', $vcCode)) {
require_once BASE_PATH . '/src/CommentManager.php';
$cm = new CommentManager($pdo);
$vcUuid = $cm->verify($vcCode);
if ($vcUuid !== null) {
$vcArticle = $articles->getByUuid($vcUuid);
$vcSlug = $vcArticle ? ($vcArticle['slug'] ?? $vcUuid) : $vcUuid;
header('Location: /post/' . rawurlencode($vcSlug) . '?verified=1#comments');
exit;
}
}
// Code invalide ou expiré
http_response_code(404);
ob_start();
?>
<div class="container py-5 text-center">
<h1 class="h2 mb-3">Lien invalide ou expiré</h1>
<p class="text-muted mb-4">Ce lien de confirmation n'est plus valide (expiré après 24 h) ou a déjà été utilisé.</p>
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
</div>
<?php
$content = ob_get_clean();
$title = 'Lien invalide — ' . siteTitle();
$metaRobots = 'noindex, nofollow';
include BASE_PATH . '/templates/layout.php';
break;
case 'comment_moderate':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$modId = (int)($_POST['id'] ?? 0);
$modPub = (int)($_POST['pub'] ?? 1);
$pdo = dbPdo();
if ($pdo && $modId > 0) {
require_once BASE_PATH . '/src/CommentManager.php';
(new CommentManager($pdo))->setPublished($modId, $modPub === 1);
}
header('Location: /admin/comments');
exit;
case 'rate':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -1780,6 +1967,30 @@ switch ($action) {
}
}
if ($tab === 'comments') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$pdo = dbPdo();
if ($pdo) {
require_once BASE_PATH . '/src/CommentManager.php';
$adminData['comments'] = (new CommentManager($pdo))->allForAdmin();
// Enrichit avec le slug de chaque article pour les liens
$adminData['articleSlugs'] = [];
foreach ($adminData['comments'] as $cmtRow) {
$uuid = $cmtRow['article_uuid'];
if (!isset($adminData['articleSlugs'][$uuid])) {
$a = $articles->getByUuid($uuid);
$adminData['articleSlugs'][$uuid] = $a ? ($a['slug'] ?? null) : null;
}
}
} else {
$adminData['comments'] = [];
$adminData['articleSlugs'] = [];
}
}
include BASE_PATH . '/templates/admin.php';
break;
+77
View File
@@ -48,6 +48,10 @@ function adminStatusBadge(array $a, int $now): string
<a class="nav-link <?= $tab === 'site' ? 'active' : '' ?>"
href="/admin/site">Site</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $tab === 'comments' ? 'active' : '' ?>"
href="/admin/comments">Commentaires</a>
</li>
<?php endif; ?>
</ul>
@@ -461,6 +465,79 @@ function adminStatusBadge(array $a, int $now): string
<?php endif; ?>
<!-- ─────────────────────────── COMMENTAIRES ──────────────────────── -->
<?php if ($tab === 'comments' && isAdmin()): ?>
<h5 class="mb-3">Commentaires</h5>
<?php if (empty($adminData['comments'])): ?>
<p class="text-muted">Aucun commentaire pour l'instant.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead>
<tr>
<th>Article</th>
<th>Auteur</th>
<th>Commentaire</th>
<th>Date</th>
<th>État</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($adminData['comments'] as $c): ?>
<?php
$cSlug = $adminData['articleSlugs'][$c['article_uuid']] ?? null;
$cDate = date('d/m/Y H:i', strtotime((string)$c['created_at']));
$cVerif = $c['verified'] ? '<span class="badge bg-success">Vérifié</span>' : '<span class="badge bg-warning text-dark">En attente</span>';
$cPub = $c['published'] ? '<span class="badge bg-primary">Publié</span>' : '<span class="badge bg-secondary">Masqué</span>';
?>
<tr>
<td class="small">
<?php if ($cSlug): ?>
<a href="/post/<?= rawurlencode($cSlug) ?>#comments"><?= htmlspecialchars($cSlug) ?></a>
<?php else: ?>
<span class="text-muted"><?= htmlspecialchars(substr($c['article_uuid'], 0, 8)) ?>…</span>
<?php endif; ?>
</td>
<td class="small">
<div><?= htmlspecialchars($c['author_name']) ?></div>
<div class="text-muted"><?= htmlspecialchars($c['author_email']) ?></div>
</td>
<td class="small" style="max-width:30ch">
<span title="<?= htmlspecialchars($c['content']) ?>">
<?= htmlspecialchars(mb_strimwidth($c['content'], 0, 80, '…')) ?>
</span>
<?php if (!empty($c['verification_code'])): ?>
<br><span class="text-muted">Code : <?= htmlspecialchars($c['verification_code']) ?></span>
<?php endif; ?>
</td>
<td class="text-muted small text-nowrap"><?= htmlspecialchars($cDate) ?></td>
<td><?= $cVerif ?> <?= $cPub ?></td>
<td>
<?php if ($c['verified'] && $c['published']): ?>
<form method="post" action="/comment-moderate" class="d-inline">
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input type="hidden" name="pub" value="0">
<button type="submit" class="btn btn-sm btn-outline-danger">Masquer</button>
</form>
<?php elseif ($c['verified'] && !$c['published']): ?>
<form method="post" action="/comment-moderate" class="d-inline">
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input type="hidden" name="pub" value="1">
<button type="submit" class="btn btn-sm btn-outline-success">Publier</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = 'Administration — ' . siteTitle();
+25
View File
@@ -44,6 +44,31 @@ $_SESSION['comment_csrf'] = $_csrfToken;
</div>
</div>
<?php if (!empty($alsoReadArticles ?? [])): ?>
<!-- ── À lire aussi ──────────────────────────────────────────────── -->
<div class="also-read mb-4">
<h6 class="also-read-title">À lire aussi</h6>
<div class="also-read-grid">
<?php foreach ($alsoReadArticles as $_also):
$_alsoCover = $_also['cover'] ?? '';
$_alsoCat = trim($_also['category'] ?? '');
$_alsoGradient = coverGradient($_alsoCat !== '' ? $_alsoCat : $_also['uuid'], $allCats ?? []);
$_alsoDate = date('d/m/Y', strtotime((string)($_also['published_at'] ?? $_also['created_at'] ?? '')));
?>
<a href="/post/<?= rawurlencode($_also['slug'] ?? '') ?>" class="related-card">
<div class="related-card-thumb" style="<?= $_alsoCover !== ''
? 'background-image:url(/file?uuid=' . rawurlencode($_also['uuid']) . '&name=' . rawurlencode($_alsoCover) . ');background-size:cover;background-position:center'
: 'background:' . htmlspecialchars($_alsoGradient) ?>"></div>
<div class="related-card-body">
<div class="related-card-title"><?= htmlspecialchars($_also['title']) ?></div>
<div class="related-card-date"><?= $_alsoDate ?></div>
</div>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- ── Commentaires ───────────────────────────────────────────────── -->
<div id="comments" class="mb-4">
+3
View File
@@ -131,6 +131,9 @@ $_layoutCurrentCat = trim($_GET['cat'] ?? '');
<!-- JS -->
<script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
<?php if (isset($reactionStats)): ?>
<script src="/assets/js/reactions.js"></script>
<?php endif; ?>
</body>
</html>
+2 -20
View File
@@ -170,6 +170,8 @@ $hasSources = (!empty($externalLinks) || !empty($files))
</p>
<?php endif; ?>
<?php include __DIR__ . '/comments_section.php'; ?>
</div><!-- /col principale -->
<div class="post-sidebar-col order-3">
@@ -286,26 +288,6 @@ $hasSources = (!empty($externalLinks) || !empty($files))
</div>
<?php endif; ?>
<?php if (!empty($alsoReadArticles ?? [])): ?>
<h6 class="related-sidebar-title">À lire aussi</h6>
<?php foreach ($alsoReadArticles as $_also):
$_alsoCover = $_also['cover'] ?? '';
$_alsoCat = trim($_also['category'] ?? '');
$_alsoGradient = coverGradient($_alsoCat !== '' ? $_alsoCat : $_also['uuid'], $allCats ?? []);
$_alsoDate = date('d/m/Y', strtotime((string)($_also['published_at'] ?? $_also['created_at'] ?? '')));
?>
<a href="/post/<?= rawurlencode($_also['slug'] ?? '') ?>" class="related-card">
<div class="related-card-thumb" style="<?= $_alsoCover !== ''
? 'background-image:url(/file?uuid=' . rawurlencode($_also['uuid']) . '&name=' . rawurlencode($_alsoCover) . ');background-size:cover;background-position:center'
: 'background:' . htmlspecialchars($_alsoGradient) ?>">
</div>
<div class="related-card-body">
<div class="related-card-title"><?= htmlspecialchars($_also['title']) ?></div>
<div class="related-card-date"><?= $_alsoDate ?></div>
</div>
</a>
<?php endforeach; ?>
<?php endif; ?>
</aside>
</div>