feat: boutons haut/bas de page dans la sidebar article

This commit is contained in:
Cedric Abonnel
2026-05-13 01:18:24 +02:00
parent 11dce4510b
commit 0a44ab9da2
8 changed files with 415 additions and 18 deletions
+28
View File
@@ -0,0 +1,28 @@
-- Réactions visiteurs (cookie anti-doublon)
CREATE TABLE article_reactions (
id SERIAL PRIMARY KEY,
article_uuid TEXT NOT NULL,
reaction_type TEXT NOT NULL CHECK (reaction_type IN ('useful', 'important', 'interesting')),
visitor_hash TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (article_uuid, reaction_type, visitor_hash)
);
CREATE INDEX ON article_reactions (article_uuid);
-- Commentaires avec vérification par email
CREATE TABLE comments (
id SERIAL PRIMARY KEY,
article_uuid TEXT NOT NULL,
author_name TEXT NOT NULL,
author_email TEXT NOT NULL,
content TEXT NOT NULL CHECK (LENGTH(content) <= 2000),
verification_code TEXT,
verified BOOLEAN NOT NULL DEFAULT FALSE,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX ON comments (article_uuid, verified, published);
CREATE INDEX ON comments (verification_code)
WHERE verified = FALSE AND verification_code IS NOT NULL;
+25
View File
@@ -656,6 +656,31 @@ textarea.form-control {
font-size: .775rem;
}
.toc-nav {
display: flex;
gap: .5rem;
padding-top: .75rem;
border-top: 1px solid var(--vl-border);
margin-top: .25rem;
}
.toc-nav-btn {
flex: 1;
background: none;
border: 1px solid var(--vl-border);
border-radius: .5rem;
padding: .3rem .4rem;
font-size: .75rem;
color: var(--vl-muted);
cursor: pointer;
transition: color .15s, border-color .15s;
}
.toc-nav-btn:hover {
color: var(--vl-accent);
border-color: var(--vl-accent);
}
.related-card {
display: flex;
gap: 0.75rem;
+41
View File
@@ -0,0 +1,41 @@
// reactions.js — toggle réactions via fetch, fallback formulaire natif
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.reaction-form').forEach(function (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = form.querySelector('.reaction-btn');
var type = form.querySelector('[name="type"]').value;
var uuid = form.querySelector('[name="uuid"]').value;
var badge = form.querySelector('.reaction-count');
var active = btn.classList.contains('btn-primary');
var data = new URLSearchParams();
data.append('uuid', uuid);
data.append('type', type);
data.append('_ajax', '1');
fetch('/react', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: data.toString(),
})
.then(function (r) { return r.json(); })
.then(function (json) {
if (!json.ok) { form.submit(); return; }
var nowActive = json.active;
btn.classList.toggle('btn-primary', nowActive);
btn.classList.toggle('btn-outline-secondary', !nowActive);
if (badge) {
badge.classList.toggle('bg-light', nowActive);
badge.classList.toggle('text-primary', nowActive);
badge.classList.toggle('bg-secondary', !nowActive);
badge.textContent = json.count;
}
})
.catch(function () { form.submit(); });
});
});
});
+15 -1
View File
@@ -1,8 +1,8 @@
(function () {
var headings = document.querySelectorAll('.post-content h2, .post-content h3');
var links = document.querySelectorAll('.toc-list a');
if (!headings.length || !links.length) return;
if (headings.length && links.length) {
var map = {};
links.forEach(function (a) {
map[decodeURIComponent(a.getAttribute('href').slice(1))] = a;
@@ -20,4 +20,18 @@
}, { rootMargin: '-8% 0px -82% 0px', threshold: 0 });
headings.forEach(function (h) { observer.observe(h); });
}
var btnTop = document.getElementById('toc-go-top');
var btnBot = document.getElementById('toc-go-bottom');
if (btnTop) {
btnTop.addEventListener('click', function () {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
if (btnBot) {
btnBot.addEventListener('click', function () {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
});
}
})();
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
class CommentManager
{
public function __construct(private PDO $pdo)
{
}
/** Enregistre un commentaire non vérifié. Retourne le code de vérification à 6 chiffres. */
public function submit(
string $articleUuid,
string $name,
string $email,
string $content,
string $ip,
string $ua
): string {
$code = sprintf('%06d', random_int(100000, 999999));
$this->pdo->prepare(
'INSERT INTO comments
(article_uuid, author_name, author_email, content, verification_code, ip_address, user_agent)
VALUES (:uuid, :name, :email, :content, :code, :ip, :ua)'
)->execute([
':uuid' => $articleUuid,
':name' => $name,
':email' => $email,
':content' => $content,
':code' => $code,
':ip' => $ip,
':ua' => substr($ua, 0, 512),
]);
return $code;
}
/**
* Vérifie un code et publie le commentaire.
* Retourne l'article_uuid si succès, null si code invalide ou expiré.
*/
public function verify(string $code): ?string
{
$st = $this->pdo->prepare(
"UPDATE comments
SET verified = TRUE, published = TRUE, verification_code = NULL
WHERE verification_code = :code
AND verified = FALSE
AND created_at >= NOW() - INTERVAL '24 hours'
RETURNING article_uuid"
);
$st->execute([':code' => $code]);
$row = $st->fetch(PDO::FETCH_ASSOC);
return $row ? (string) $row['article_uuid'] : null;
}
/** @return array<int, array<string, mixed>> */
public function forArticle(string $uuid): array
{
$st = $this->pdo->prepare(
'SELECT id, author_name, content, created_at
FROM comments
WHERE article_uuid = :uuid AND verified = TRUE AND published = TRUE
ORDER BY created_at ASC'
);
$st->execute([':uuid' => $uuid]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
public function setPublished(int $id, bool $published): void
{
$this->pdo->prepare('UPDATE comments SET published = :pub WHERE id = :id')
->execute([':pub' => $published, ':id' => $id]);
}
/** @return array<int, array<string, mixed>> */
public function allForAdmin(): array
{
return $this->pdo->query(
'SELECT id, article_uuid, author_name, author_email, content,
verification_code, verified, published, created_at, ip_address
FROM comments ORDER BY created_at DESC'
)->fetchAll(PDO::FETCH_ASSOC);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
class ReactionManager
{
public const TYPES = ['useful', 'important', 'interesting'];
public function __construct(private PDO $pdo)
{
}
/** Ajoute ou retire une réaction. Retourne true si ajoutée, false si retirée. */
public function toggle(string $uuid, string $type, string $visitorHash): bool
{
if (!in_array($type, self::TYPES, true)) {
return false;
}
$st = $this->pdo->prepare(
'SELECT id FROM article_reactions
WHERE article_uuid = :uuid AND reaction_type = :type AND visitor_hash = :hash'
);
$st->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]);
if ($st->fetchColumn() !== false) {
$this->pdo->prepare(
'DELETE FROM article_reactions
WHERE article_uuid = :uuid AND reaction_type = :type AND visitor_hash = :hash'
)->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]);
return false;
}
$this->pdo->prepare(
'INSERT INTO article_reactions (article_uuid, reaction_type, visitor_hash)
VALUES (:uuid, :type, :hash) ON CONFLICT DO NOTHING'
)->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]);
return true;
}
/** @return array<string, int> */
public function statsForArticle(string $uuid): array
{
$st = $this->pdo->prepare(
'SELECT reaction_type, COUNT(*) AS cnt
FROM article_reactions WHERE article_uuid = :uuid GROUP BY reaction_type'
);
$st->execute([':uuid' => $uuid]);
$stats = array_fill_keys(self::TYPES, 0);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
$stats[$row['reaction_type']] = (int) $row['cnt'];
}
return $stats;
}
/** @return string[] */
public function visitorReactions(string $uuid, string $visitorHash): array
{
$st = $this->pdo->prepare(
'SELECT reaction_type FROM article_reactions
WHERE article_uuid = :uuid AND visitor_hash = :hash'
);
$st->execute([':uuid' => $uuid, ':hash' => $visitorHash]);
return array_column($st->fetchAll(PDO::FETCH_ASSOC), 'reaction_type');
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
// Variables attendues (injectées depuis index.php) :
// $article — tableau article courant (uuid, slug, title)
// $reactionStats — array<string, int>
// $visitorReactions — string[] (types déjà cliqués par ce visiteur)
// $comments — array de commentaires publiés
// $commentFlash — bool|null (commentaire soumis, email envoyé)
// $commentVerified — bool|null (commentaire vérifié et publié)
// $commentError — string|null (message d'erreur)
$_reactionDefs = [
'useful' => ['👍', 'Utile'],
'important' => ['🔥', 'Important'],
'interesting' => ['🤔', 'À creuser'],
];
$_csrfToken = bin2hex(random_bytes(16));
$_SESSION['comment_csrf'] = $_csrfToken;
?>
<!-- ── Réactions ──────────────────────────────────────────────────── -->
<div class="card mb-4" id="reactions">
<div class="card-body">
<h6 class="card-title mb-3 text-muted text-uppercase" style="font-size:.75rem;letter-spacing:.06em">Réactions</h6>
<div class="d-flex gap-2 flex-wrap" id="reaction-buttons">
<?php foreach ($_reactionDefs as $type => [$icon, $label]): ?>
<?php $active = in_array($type, $visitorReactions, true); ?>
<form method="post" action="/react" class="reaction-form d-inline">
<input type="hidden" name="uuid" value="<?= htmlspecialchars($article['uuid']) ?>">
<input type="hidden" name="type" value="<?= htmlspecialchars($type) ?>">
<input type="hidden" name="_back" value="/post/<?= rawurlencode($article['slug'] ?? '') ?>#reactions">
<button type="submit"
class="btn btn-sm <?= $active ? 'btn-primary' : 'btn-outline-secondary' ?> reaction-btn"
data-type="<?= htmlspecialchars($type) ?>"
data-uuid="<?= htmlspecialchars($article['uuid']) ?>">
<span class="reaction-icon"><?= $icon ?></span>
<span class="reaction-label"><?= htmlspecialchars($label) ?></span>
<span class="badge <?= $active ? 'bg-light text-primary' : 'bg-secondary' ?> ms-1 reaction-count"
data-type="<?= htmlspecialchars($type) ?>"><?= (int)($reactionStats[$type] ?? 0) ?></span>
</button>
</form>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- ── Commentaires ───────────────────────────────────────────────── -->
<div id="comments" class="mb-4">
<h5 class="mb-3">
Commentaires
<?php if (!empty($comments)): ?>
<span class="badge bg-secondary ms-1"><?= count($comments) ?></span>
<?php endif; ?>
</h5>
<?php if ($commentFlash ?? false): ?>
<div class="alert alert-info">
Un code de confirmation vous a été envoyé par email.
Cliquez sur le lien reçu pour publier votre commentaire.
</div>
<?php endif; ?>
<?php if ($commentVerified ?? false): ?>
<div class="alert alert-success">Votre commentaire a été publié. Merci !</div>
<?php endif; ?>
<?php if (!empty($commentError ?? null)): ?>
<div class="alert alert-danger"><?= htmlspecialchars($commentError) ?></div>
<?php endif; ?>
<?php foreach ($comments as $c): ?>
<div class="card mb-3 comment-card" id="comment-<?= (int)$c['id'] ?>">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<strong class="comment-author"><?= htmlspecialchars($c['author_name']) ?></strong>
<small class="text-muted">
<?= htmlspecialchars(date('d/m/Y à H\hi', strtotime((string)$c['created_at']))) ?>
</small>
</div>
<div class="comment-content"><?= nl2br(htmlspecialchars((string)$c['content'])) ?></div>
<?php if (function_exists('isAdmin') && isAdmin()): ?>
<div class="mt-2">
<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>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($comments) && !($commentFlash ?? false) && !($commentVerified ?? false)): ?>
<p class="text-muted mb-4">Aucun commentaire pour l'instant. Soyez le premier !</p>
<?php endif; ?>
<!-- Formulaire -->
<div class="card mt-3" id="comment-form-card">
<div class="card-body">
<h6 class="card-title mb-3">Laisser un commentaire</h6>
<form method="post" action="/comment" id="comment-form">
<input type="hidden" name="_token" value="<?= htmlspecialchars($_csrfToken) ?>">
<input type="hidden" name="uuid" value="<?= htmlspecialchars($article['uuid']) ?>">
<!-- honeypot -->
<div class="d-none" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label" for="comment-name">Nom <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="comment-name" name="author_name"
maxlength="100" required placeholder="Votre nom">
</div>
<div class="col-sm-6">
<label class="form-label" for="comment-email">
Email <span class="text-danger">*</span>
<span class="text-muted fw-normal">(non publié)</span>
</label>
<input type="email" class="form-control" id="comment-email" name="author_email"
maxlength="254" required placeholder="votre@email.fr">
</div>
<div class="col-12">
<label class="form-label" for="comment-content">Commentaire <span class="text-danger">*</span></label>
<textarea class="form-control" id="comment-content" name="content"
rows="4" maxlength="2000" required
placeholder="Votre commentaire (2000 caractères max)"></textarea>
</div>
<div class="col-12 d-flex align-items-center gap-3">
<button type="submit" class="btn btn-primary">Envoyer</button>
<small class="text-muted">Un code de vérification sera envoyé à votre adresse email.</small>
</div>
</div>
</form>
</div>
</div>
</div>
+5 -2
View File
@@ -186,6 +186,11 @@ $hasSources = (!empty($externalLinks) || !empty($files))
</ul>
<?php endif; ?>
<div class="toc-nav">
<button class="toc-nav-btn" id="toc-go-top">↑ Haut</button>
<button class="toc-nav-btn" id="toc-go-bottom">↓ Bas</button>
</div>
<?php if (!empty($backlinks ?? [])): ?>
<h6 class="related-sidebar-title">Rétroliens</h6>
<?php foreach ($backlinks as $_bl):
@@ -307,9 +312,7 @@ $hasSources = (!empty($externalLinks) || !empty($files))
</div><!-- /row -->
<?php if (count($_tocItems) >= 3): ?>
<script src="/assets/js/toc.js" defer></script>
<?php endif; ?>
<?php
$content = ob_get_clean();