From 0a44ab9da279c0518aae729d5d0c84a0c3d90685 Mon Sep 17 00:00:00 2001 From: Cedric Abonnel Date: Wed, 13 May 2026 01:18:24 +0200 Subject: [PATCH] feat: boutons haut/bas de page dans la sidebar article --- database/interactions_create.sql | 28 +++++++ public/assets/css/style.css | 25 ++++++ public/assets/js/reactions.js | 41 +++++++++ public/assets/js/toc.js | 46 ++++++---- src/CommentManager.php | 84 +++++++++++++++++++ src/ReactionManager.php | 63 ++++++++++++++ templates/comments_section.php | 139 +++++++++++++++++++++++++++++++ templates/post_view.php | 7 +- 8 files changed, 415 insertions(+), 18 deletions(-) create mode 100644 database/interactions_create.sql create mode 100644 public/assets/js/reactions.js create mode 100644 src/CommentManager.php create mode 100644 src/ReactionManager.php create mode 100644 templates/comments_section.php diff --git a/database/interactions_create.sql b/database/interactions_create.sql new file mode 100644 index 0000000..e7b06e2 --- /dev/null +++ b/database/interactions_create.sql @@ -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; diff --git a/public/assets/css/style.css b/public/assets/css/style.css index 5088c3c..4e337ec 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -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; diff --git a/public/assets/js/reactions.js b/public/assets/js/reactions.js new file mode 100644 index 0000000..1dc0606 --- /dev/null +++ b/public/assets/js/reactions.js @@ -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(); }); + }); + }); +}); diff --git a/public/assets/js/toc.js b/public/assets/js/toc.js index 24ddbb2..ea52b50 100644 --- a/public/assets/js/toc.js +++ b/public/assets/js/toc.js @@ -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' }); + }); + } })(); diff --git a/src/CommentManager.php b/src/CommentManager.php new file mode 100644 index 0000000..cd9e185 --- /dev/null +++ b/src/CommentManager.php @@ -0,0 +1,84 @@ +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> */ + 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> */ + 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); + } +} diff --git a/src/ReactionManager.php b/src/ReactionManager.php new file mode 100644 index 0000000..842b344 --- /dev/null +++ b/src/ReactionManager.php @@ -0,0 +1,63 @@ +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 */ + 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'); + } +} diff --git a/templates/comments_section.php b/templates/comments_section.php new file mode 100644 index 0000000..c68b6f2 --- /dev/null +++ b/templates/comments_section.php @@ -0,0 +1,139 @@ + +// $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
+
+ [$icon, $label]): ?> + +
+ + + + +
+ +
+
+
+ + +
+ +
+ Commentaires + + + +
+ + +
+ Un code de confirmation vous a été envoyé par email. + Cliquez sur le lien reçu pour publier votre commentaire. +
+ + + +
Votre commentaire a été publié. Merci !
+ + + +
+ + + +
+
+
+ + + + +
+
+ +
+
+ + + +
+
+ +
+
+ + + +

Aucun commentaire pour l'instant. Soyez le premier !

+ + + +
+
+
Laisser un commentaire
+
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Un code de vérification sera envoyé à votre adresse email. +
+
+
+
+
+ +
diff --git a/templates/post_view.php b/templates/post_view.php index 0aefb68..dce4463 100644 --- a/templates/post_view.php +++ b/templates/post_view.php @@ -186,6 +186,11 @@ $hasSources = (!empty($externalLinks) || !empty($files)) +
+ + +
+ -= 3): ?> -