feat: boutons haut/bas de page dans la sidebar article
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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(); });
|
||||
});
|
||||
});
|
||||
});
|
||||
+30
-16
@@ -1,23 +1,37 @@
|
||||
(function () {
|
||||
var headings = document.querySelectorAll('.post-content h2, .post-content h3');
|
||||
var links = document.querySelectorAll('.toc-list a');
|
||||
if (!headings.length || !links.length) return;
|
||||
|
||||
var map = {};
|
||||
links.forEach(function (a) {
|
||||
map[decodeURIComponent(a.getAttribute('href').slice(1))] = a;
|
||||
});
|
||||
|
||||
var active = null;
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
if (active) active.classList.remove('toc-active');
|
||||
active = map[entry.target.id] || null;
|
||||
if (active) active.classList.add('toc-active');
|
||||
}
|
||||
if (headings.length && links.length) {
|
||||
var map = {};
|
||||
links.forEach(function (a) {
|
||||
map[decodeURIComponent(a.getAttribute('href').slice(1))] = a;
|
||||
});
|
||||
}, { rootMargin: '-8% 0px -82% 0px', threshold: 0 });
|
||||
|
||||
headings.forEach(function (h) { observer.observe(h); });
|
||||
var active = null;
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
if (active) active.classList.remove('toc-active');
|
||||
active = map[entry.target.id] || null;
|
||||
if (active) active.classList.add('toc-active');
|
||||
}
|
||||
});
|
||||
}, { 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' });
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user