Commit c7de713e by PLN (Algolia)

comments: Seurat island — client-fetched threads with votes + curated reactions,…

comments: Seurat island — client-fetched threads with votes + curated reactions, hold-queue form (honeypot triad, no captcha), honest noscript/dead-API states; slug whitelist sync script
parent 0814673e
'use client';
import { useEffect, useRef, useState } from 'react';
import styles from './blog.module.css';
/**
* Comments & reactions island (Seurat, api.nech.pl/www/v1 — see
* decisions/2026-11-01-comments-reactions/). Fetches approved threads on
* load; the page reads honestly without JS ("les commentaires se chargent
* avec JS"). Writes ride the hold queue: the reader always sees
* "en attente de validation", never instant publication. The honeypot triad
* (empty website field + mount timestamp + server-side per-IP windows) is
* wired here; there is no captcha, by decision.
*/
const API = process.env.NEXT_PUBLIC_COMMENTS_API || 'https://api.nech.pl/www/v1';
const LABELS = {
fr: {
title: 'Commentaires',
loading: 'Les commentaires se chargent…',
noscript: 'Les commentaires se chargent avec JavaScript.',
none: 'Pas encore de commentaire.',
pending: 'Reçu — en attente de validation, il paraîtra dès qu’il est approuvé.',
name: 'Nom',
email: 'E-mail (optionnel, jamais affiché)',
body: 'Votre commentaire…',
send: 'Envoyer',
reply: 'Répondre',
cancel: 'annuler',
replying: 'en réponse à',
mdHelp: 'Markdown léger : **gras**, *italique*, `code`, [lien](https://…), ![image](https://…).',
error: 'Rejeté par le filtre anti-spam — réessayez dans un instant.',
react: 'Réagir',
},
en: {
title: 'Comments',
loading: 'Comments are loading…',
noscript: 'Comments load with JavaScript.',
none: 'No comments yet.',
pending: 'Received — on the approval queue, it shows as soon as it is approved.',
name: 'Name',
email: 'Email (optional, never displayed)',
body: 'Your comment…',
send: 'Send',
reply: 'Reply',
cancel: 'cancel',
replying: 'replying to',
mdHelp: 'Light markdown: **bold**, *italic*, `code`, [link](https://…), ![image](https://…).',
error: 'Rejected by the anti-spam filter — try again in a moment.',
react: 'React',
},
};
async function api(path, opts) {
const res = await fetch(`${API}${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
function CommentNode({ node, labels, onReply, onVote, voted, depth }) {
return (
<li className={styles.comment} style={depth ? { marginLeft: '1.2rem' } : undefined}>
<div className={styles.commentHead}>
<span className={styles.commentAuthor}>{node.author}</span>
<span className={styles.commentScore}>
<button
type="button"
className={styles.voteBtn}
aria-label="+1"
disabled={voted.has(node.id)}
onClick={() => onVote(node.id, 1)}
>
▲
</button>
{node.score}
<button
type="button"
className={styles.voteBtn}
aria-label="-1"
disabled={voted.has(node.id)}
onClick={() => onVote(node.id, -1)}
>
▼
</button>
</span>
</div>
{/* Server-rendered, escape-first HTML from Seurat — light markdown only. */}
<div className={styles.commentBody} dangerouslySetInnerHTML={{ __html: node.bodyHtml }} />
<div className={styles.commentFoot}>
<button type="button" className={styles.linkBtn} onClick={() => onReply(node)}>
{labels.reply}
</button>
</div>
{node.children?.length > 0 && (
<ul className={styles.commentChildren}>
{node.children.map((child) => (
<CommentNode
key={child.id}
node={child}
labels={labels}
onReply={onReply}
onVote={onVote}
voted={voted}
depth={depth + 1}
/>
))}
</ul>
)}
</li>
);
}
export default function CommentsSection({ slug, lang }) {
const labels = LABELS[lang] ?? LABELS.fr;
const [threads, setThreads] = useState(null); // null = loading
const [emoji, setEmoji] = useState([]);
const [counts, setCounts] = useState({});
const [reacted, setReacted] = useState(() => new Set());
const [voted, setVoted] = useState(() => new Set());
const [replyTo, setReplyTo] = useState(null);
const [status, setStatus] = useState(null); // 'pending' | 'error'
const [dead, setDead] = useState(false); // API unreachable — the section stays honest and hides
const mountedAt = useRef(Date.now());
const loadThreads = () =>
api(`/posts/${slug}/comments`)
.then((d) => setThreads(d.comments))
.catch(() => {
setThreads([]);
setDead(true);
});
const loadReactions = () =>
api(`/posts/${slug}/reactions`)
.then((d) => setCounts(Object.fromEntries(d.reactions.map((r) => [r.emoji, r.count]))))
.catch(() => {});
useEffect(() => {
loadThreads();
loadReactions();
api('/reactions/meta').then((d) => setEmoji(d.emoji)).catch(() => {});
}, [slug]);
const onVote = async (id, value) => {
try {
await api(`/comments/${id}/vote`, { method: 'POST', body: JSON.stringify({ value }) });
setVoted((s) => new Set(s).add(id));
loadThreads();
} catch {
/* a rejected vote is a silent no-op for the reader */
}
};
const onReact = async (e) => {
if (reacted.has(e)) return;
try {
await api(`/posts/${slug}/reactions`, { method: 'POST', body: JSON.stringify({ emoji: e }) });
setReacted((s) => new Set(s).add(e));
loadReactions();
} catch {
/* daily cap or rate window — the refetch shows the truth */
}
};
const onSubmit = async (e) => {
e.preventDefault();
const form = new FormData(e.target);
try {
await api(`/posts/${slug}/comments`, {
method: 'POST',
body: JSON.stringify({
author_name: form.get('author_name'),
author_email: form.get('author_email') || null,
body: form.get('body'),
parent_id: replyTo?.id ?? null,
website: form.get('website') ?? '', // honeypot — must stay empty
ts: mountedAt.current, // min-submit-time — humans read before writing
}),
});
setStatus('pending');
e.target.reset();
setReplyTo(null);
} catch {
setStatus('error');
}
};
return (
<section className={styles.comments} aria-label={labels.title}>
<noscript>
<p className={styles.commentsNote}>{labels.noscript}</p>
</noscript>
{dead ? null : (
<>
<h2 className={styles.commentsTitle}>{labels.title}</h2>
{threads === null ? (
<p className={styles.commentsNote}>{labels.loading}</p>
) : threads.length === 0 ? (
<p className={styles.commentsNote}>{labels.none}</p>
) : (
<ul className={styles.commentRoot}>
{threads.map((node) => (
<CommentNode
key={node.id}
node={node}
labels={labels}
onReply={setReplyTo}
onVote={onVote}
voted={voted}
depth={0}
/>
))}
</ul>
)}
{emoji.length > 0 && (
<div className={styles.reactions} aria-label={labels.react}>
{emoji.map((e) => (
<button
key={e}
type="button"
className={`${styles.reactionPill} ${reacted.has(e) ? styles.reactionDone : ''}`}
onClick={() => onReact(e)}
aria-pressed={reacted.has(e)}
>
{e} {counts[e] > 0 ? counts[e] : ''}
</button>
))}
</div>
)}
<form className={styles.commentForm} onSubmit={onSubmit}>
{/* Honeypot: visually gone, keyboard-gone, but a bot filling it dies. */}
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className={styles.hpField}
/>
{replyTo && (
<p className={styles.replying}>
{labels.replying} <strong>{replyTo.author}</strong>{' '}
<button type="button" className={styles.linkBtn} onClick={() => setReplyTo(null)}>
({labels.cancel})
</button>
</p>
)}
<div className={styles.formRow}>
<input name="author_name" required maxLength={60} placeholder={labels.name} />
<input name="author_email" type="email" placeholder={labels.email} />
</div>
<textarea name="body" required maxLength={2000} rows={4} placeholder={labels.body} />
<p className={styles.mdHelp}>{labels.mdHelp}</p>
<div className={styles.formRow}>
<button type="submit" className={styles.sendBtn}>
{labels.send}
</button>
{status === 'pending' && <span className={styles.statusOk}>{labels.pending}</span>}
{status === 'error' && <span className={styles.statusErr}>{labels.error}</span>}
</div>
</form>
</>
)}
</section>
);
}
......@@ -6,6 +6,7 @@ import { syne, serifBody } from "@/app/blog/article-fonts";
import { sansBody } from "@/app/blog/dev-fonts";
import { getBlogNeighbors } from "@/lib/blog";
import ArticleEnhance from "./ArticleEnhance";
import CommentsSection from "./CommentsSection";
import FontSwitcher from "./FontSwitcher";
import HydraEmbed from "./HydraEmbed";
......@@ -151,6 +152,9 @@ export default function PostArticle({ post, switcher, banner }) {
)}
</div>
</div>
{/* Seurat island — approved threads fetched client-side; writes ride
the hold queue. Hides itself honestly if the API is unreachable. */}
<CommentsSection slug={post.slug} lang={post.lang} />
<ArticleEnhance lang={post.lang} languages={post.languages} />
<FontSwitcher />
</article>
......
......@@ -1124,3 +1124,199 @@
letter-spacing: 0.08em;
vertical-align: middle;
}
/* Seurat — comments & reactions island (CommentsSection). Reads live on the
page; writes ride the hold queue, so the only promise the UI makes is
"en attente de validation". Uses the theme tokens — this section sits on
the page, never floating over a sketch. */
.comments {
margin: 2.5rem 0 1rem;
border-top: 1px solid var(--border);
padding-top: 1.2rem;
}
.commentsTitle {
font-size: 1.05rem;
margin: 0 0 0.8rem;
}
.commentsNote {
color: var(--text-muted);
font-size: 0.85rem;
}
.commentRoot,
.commentChildren {
list-style: none;
margin: 0;
padding: 0;
}
.commentChildren {
border-left: 2px solid var(--border);
padding-left: 0.6rem;
margin-top: 0.6rem;
}
.comment {
padding: 0.5rem 0;
}
.commentHead {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.commentAuthor {
font-weight: 600;
font-size: 0.9rem;
}
.commentScore {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.8rem;
}
.voteBtn {
border: 0;
background: none;
color: var(--text-muted);
cursor: pointer;
font-size: 0.65rem;
padding: 0 0.15rem;
}
.voteBtn:disabled {
opacity: 0.4;
cursor: default;
}
.commentBody {
font-size: 0.92rem;
margin: 0.2rem 0;
}
.commentBody img {
max-width: 100%;
border-radius: 6px;
}
.commentBody p {
margin: 0.35rem 0;
}
/* Light GitHub link cards — rendered by Seurat, styled here. */
.commentBody .seurat-gh {
display: inline-flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.4rem 0.7rem;
margin: 0.2rem 0;
border: 1px solid var(--border);
border-radius: 8px;
text-decoration: none;
background: var(--surface);
}
.commentBody .seurat-gh:hover {
border-color: var(--text-muted);
}
.commentBody .seurat-gh-repo {
font-weight: 600;
font-size: 0.85rem;
}
.commentBody .seurat-gh-tail {
color: var(--text-muted);
font-size: 0.75rem;
}
.commentFoot {
font-size: 0.78rem;
}
.linkBtn {
border: 0;
background: none;
color: var(--text-muted);
cursor: pointer;
font-size: inherit;
padding: 0;
text-decoration: underline;
text-underline-offset: 2px;
}
.reactions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 1rem 0;
}
.reactionPill {
border: 1px solid var(--border);
border-radius: 999px;
background: none;
padding: 0.25rem 0.7rem;
cursor: pointer;
font-size: 0.95rem;
color: inherit;
}
.reactionPill:hover {
border-color: var(--text-muted);
}
.reactionDone {
border-color: currentColor;
background: var(--surface);
}
.commentForm {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-top: 1.2rem;
max-width: 34rem;
}
.commentForm input,
.commentForm textarea {
font: inherit;
padding: 0.5rem 0.7rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: inherit;
}
.formRow {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.formRow input {
flex: 1 1 12rem;
}
.commentForm textarea {
resize: vertical;
}
/* The honeypot: present in the DOM, absent from every human sense. */
.hpField {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
}
.mdHelp {
color: var(--text-muted);
font-size: 0.75rem;
margin: 0;
}
.sendBtn {
font: inherit;
padding: 0.45rem 1.1rem;
border: 1px solid var(--border);
border-radius: 999px;
background: none;
color: inherit;
cursor: pointer;
}
.sendBtn:hover {
border-color: var(--text-muted);
background: var(--surface);
}
.statusOk {
color: var(--text-muted);
font-size: 0.85rem;
}
.statusErr {
color: #b3261e;
font-size: 0.85rem;
}
.replying {
font-size: 0.85rem;
color: var(--text-muted);
margin: 0;
}
#!/usr/bin/env node
/**
* Sync the blog's non-draft slugs into Seurat's whitelist
* (`PUT /posts/{slug}` on api.nech.pl/www/v1). A slug must exist there
* before it accepts a comment — no comments on phantom URLs.
*
* The admin token is read from the SEURAT_ADMIN_TOKEN env or from a chmod-600
* file (SEURAT_TOKEN_FILE, default ~/.seurat-admin-token) — never hardcoded,
* never echoed. Run after a deploy: `node scripts/comments-sync.mjs`.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const BLOG_ROOT = path.resolve(process.cwd(), 'content/blog');
const BASE = process.env.SEURAT_API || 'https://api.nech.pl/www/v1';
const LANGS = ['fr', 'en'];
const token =
process.env.SEURAT_ADMIN_TOKEN ||
(() => {
const file = process.env.SEURAT_TOKEN_FILE || path.join(os.homedir(), '.seurat-admin-token');
return fs.readFileSync(file, 'utf8').trim();
})();
// Same post-ness rule as lib/blog.js (kept inline: that module is
// Next-bundler ESM, not importable from a bare node script).
const slugs = fs
.readdirSync(BLOG_ROOT)
.filter((f) => fs.statSync(path.join(BLOG_ROOT, f)).isDirectory())
.filter(
(f) =>
fs.existsSync(path.join(BLOG_ROOT, f, 'meta.md')) ||
LANGS.some((lang) => fs.existsSync(path.join(BLOG_ROOT, f, `${lang}.md`))),
)
.filter((f) => {
const meta = fs.readFileSync(path.join(BLOG_ROOT, f, 'meta.md'), 'utf8');
return !/^draft:\s*true/m.test(meta);
});
let ok = 0;
for (const slug of slugs) {
const res = await fetch(`${BASE}/posts/${slug}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) ok += 1;
else console.error(`${slug}: HTTP ${res.status}`);
}
console.log(`comments-sync: ${ok}/${slugs.length} slugs whitelisted`);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment