Commit 5bc56220 by PLN (Algolia)

comments: toggle reactions off, switchable votes from server state,…

comments: toggle reactions off, switchable votes from server state, author-secret edit/delete on your own comments (secret in localStorage)
parent cdd70549
......@@ -11,6 +11,12 @@ import styles from './blog.module.css';
* "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.
*
* Authorship: every comment POST returns an author secret, shown once and
* kept in localStorage (`seurat:secret:<id>`). A comment whose secret this
* browser holds grows éditer/supprimer controls — that secret is the only
* key, so authorship stays with the machine that wrote the words. Reactions
* and votes are per-IP server-side and toggle on/off.
*/
const API = process.env.NEXT_PUBLIC_COMMENTS_API || 'https://api.nech.pl/www/v1';
......@@ -22,13 +28,19 @@ const LABELS = {
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é.',
editedPending: 'Modification reçue — elle repasse par la validation.',
name: 'Nom',
email: 'E-mail (optionnel, jamais affiché)',
body: 'Votre commentaire…',
send: 'Envoyer',
update: 'Mettre à jour',
cancelEdit: 'annuler',
reply: 'Répondre',
cancel: 'annuler',
replying: 'en réponse à',
edit: 'éditer',
remove: 'supprimer',
confirmRemove: 'Supprimer votre commentaire ?',
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',
......@@ -39,19 +51,41 @@ const LABELS = {
noscript: 'Comments load with JavaScript.',
none: 'No comments yet.',
pending: 'Received — on the approval queue, it shows as soon as it is approved.',
editedPending: 'Edit received — it goes back through the approval queue.',
name: 'Name',
email: 'Email (optional, never displayed)',
body: 'Your comment…',
send: 'Send',
update: 'Update',
cancelEdit: 'cancel',
reply: 'Reply',
cancel: 'cancel',
replying: 'replying to',
edit: 'edit',
remove: 'delete',
confirmRemove: 'Delete your comment?',
mdHelp: 'Light markdown: **bold**, *italic*, `code`, [link](https://…), ![image](https://…).',
error: 'Rejected by the anti-spam filter — try again in a moment.',
react: 'React',
},
};
const secretKey = (id) => `seurat:secret:${id}`;
const loadSecret = (id) => {
try {
return window.localStorage.getItem(secretKey(id));
} catch {
return null;
}
};
const saveSecret = (id, secret) => {
try {
window.localStorage.setItem(secretKey(id), secret);
} catch {
/* private mode — the comment still works, only editing is lost */
}
};
async function api(path, opts) {
const res = await fetch(`${API}${path}`, {
headers: { 'Content-Type': 'application/json' },
......@@ -61,7 +95,7 @@ async function api(path, opts) {
return res.json();
}
function CommentNode({ node, labels, onReply, onVote, voted, depth }) {
function CommentNode({ node, labels, onReply, onVote, myVotes, editable, onEdit, onDelete, depth }) {
return (
<li className={styles.comment} style={depth ? { marginLeft: '1.2rem' } : undefined}>
<div className={styles.commentHead}>
......@@ -69,9 +103,9 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) {
<span className={styles.commentScore}>
<button
type="button"
className={styles.voteBtn}
className={`${styles.voteBtn} ${myVotes.get(node.id) === 1 ? styles.voteMine : ''}`}
aria-label="+1"
disabled={voted.has(node.id)}
aria-pressed={myVotes.get(node.id) === 1}
onClick={() => onVote(node.id, 1)}
>
▲
......@@ -79,9 +113,9 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) {
{node.score}
<button
type="button"
className={styles.voteBtn}
className={`${styles.voteBtn} ${myVotes.get(node.id) === -1 ? styles.voteMine : ''}`}
aria-label="-1"
disabled={voted.has(node.id)}
aria-pressed={myVotes.get(node.id) === -1}
onClick={() => onVote(node.id, -1)}
>
▼
......@@ -94,6 +128,24 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) {
<button type="button" className={styles.linkBtn} onClick={() => onReply(node)}>
{labels.reply}
</button>
{editable.has(node.id) && (
<>
{' · '}
<button type="button" className={styles.linkBtn} onClick={() => onEdit(node)}>
{labels.edit}
</button>
{' · '}
<button
type="button"
className={styles.linkBtn}
onClick={() => {
if (window.confirm(labels.confirmRemove)) onDelete(node.id);
}}
>
{labels.remove}
</button>
</>
)}
</div>
{node.children?.length > 0 && (
<ul className={styles.commentChildren}>
......@@ -104,7 +156,10 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) {
labels={labels}
onReply={onReply}
onVote={onVote}
voted={voted}
myVotes={myVotes}
editable={editable}
onEdit={onEdit}
onDelete={onDelete}
depth={depth + 1}
/>
))}
......@@ -119,16 +174,35 @@ export default function CommentsSection({ slug, lang }) {
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 [mine, setMine] = useState(() => new Set());
const [myVotes, setMyVotes] = useState(() => new Map());
const [editable, setEditable] = useState(() => new Set());
const [replyTo, setReplyTo] = useState(null);
const [status, setStatus] = useState(null); // 'pending' | 'error'
const [editing, setEditing] = useState(null);
const [status, setStatus] = useState(null); // 'pending' | 'edited' | 'error'
const [dead, setDead] = useState(false); // API unreachable — the section stays honest and hides
const mountedAt = useRef(Date.now());
const formRef = useRef(null);
const adoptThreads = (comments) => {
setThreads(comments);
const votes = new Map();
const walk = (nodes) => {
for (const n of nodes) {
if (n.myVote) votes.set(n.id, n.myVote);
if (loadSecret(n.id)) {
setEditable((s) => new Set(s).add(n.id));
}
walk(n.children ?? []);
}
};
walk(comments);
setMyVotes(votes);
};
const loadThreads = () =>
api(`/posts/${slug}/comments`)
.then((d) => setThreads(d.comments))
.then((d) => adoptThreads(d.comments))
.catch(() => {
setThreads([]);
setDead(true);
......@@ -136,7 +210,10 @@ export default function CommentsSection({ slug, lang }) {
const loadReactions = () =>
api(`/posts/${slug}/reactions`)
.then((d) => setCounts(Object.fromEntries(d.reactions.map((r) => [r.emoji, r.count]))))
.then((d) => {
setCounts(Object.fromEntries(d.reactions.map((r) => [r.emoji, r.count])));
setMine(new Set(d.reactions.filter((r) => r.mine).map((r) => r.emoji)));
})
.catch(() => {});
useEffect(() => {
......@@ -146,9 +223,10 @@ export default function CommentsSection({ slug, lang }) {
}, [slug]);
const onVote = async (id, value) => {
if (myVotes.get(id) === value) return; // same vote again = no-op
try {
await api(`/comments/${id}/vote`, { method: 'POST', body: JSON.stringify({ value }) });
setVoted((s) => new Set(s).add(id));
setMyVotes((m) => new Map(m).set(id, value));
loadThreads();
} catch {
/* a rejected vote is a silent no-op for the reader */
......@@ -156,13 +234,49 @@ export default function CommentsSection({ slug, lang }) {
};
const onReact = async (e) => {
if (reacted.has(e)) return;
try {
if (mine.has(e)) {
await api(`/posts/${slug}/reactions`, { method: 'DELETE', body: JSON.stringify({ emoji: e }) });
setMine((s) => {
const n = new Set(s);
n.delete(e);
return n;
});
setCounts((c) => ({ ...c, [e]: Math.max(0, (c[e] ?? 1) - 1) }));
} else {
await api(`/posts/${slug}/reactions`, { method: 'POST', body: JSON.stringify({ emoji: e }) });
setReacted((s) => new Set(s).add(e));
loadReactions();
setMine((s) => new Set(s).add(e));
setCounts((c) => ({ ...c, [e]: (c[e] ?? 0) + 1 }));
}
} catch {
/* daily cap or rate window — the refetch shows the truth */
loadReactions(); // daily cap or rate window — the refetch shows the truth
}
};
const startEdit = (node) => {
setEditing(node);
setReplyTo(null);
// let the render put the form in view before focusing
requestAnimationFrame(() => formRef.current?.querySelector('textarea')?.focus());
};
const onDelete = async (id) => {
const secret = loadSecret(id);
if (!secret) return;
try {
await api(`/comments/${id}`, { method: 'DELETE', body: JSON.stringify({ author_secret: secret }) });
try {
window.localStorage.removeItem(secretKey(id));
} catch {}
setEditable((s) => {
const n = new Set(s);
n.delete(id);
return n;
});
loadThreads();
} catch {
/* wrong secret or gone already — refetch shows the truth */
loadThreads();
}
};
......@@ -170,7 +284,18 @@ export default function CommentsSection({ slug, lang }) {
e.preventDefault();
const form = new FormData(e.target);
try {
await api(`/posts/${slug}/comments`, {
if (editing) {
await api(`/comments/${editing.id}`, {
method: 'PUT',
body: JSON.stringify({
author_secret: loadSecret(editing.id),
body: form.get('body'),
}),
});
setStatus('edited');
setEditing(null);
} else {
const res = await api(`/posts/${slug}/comments`, {
method: 'POST',
body: JSON.stringify({
author_name: form.get('author_name'),
......@@ -181,9 +306,11 @@ export default function CommentsSection({ slug, lang }) {
ts: mountedAt.current, // min-submit-time — humans read before writing
}),
});
if (res.secret && res.id) saveSecret(res.id, res.secret);
setStatus('pending');
e.target.reset();
setReplyTo(null);
}
e.target.reset();
} catch {
setStatus('error');
}
......@@ -210,7 +337,10 @@ export default function CommentsSection({ slug, lang }) {
labels={labels}
onReply={setReplyTo}
onVote={onVote}
voted={voted}
myVotes={myVotes}
editable={editable}
onEdit={startEdit}
onDelete={onDelete}
depth={0}
/>
))}
......@@ -223,9 +353,9 @@ export default function CommentsSection({ slug, lang }) {
<button
key={e}
type="button"
className={`${styles.reactionPill} ${reacted.has(e) ? styles.reactionDone : ''}`}
className={`${styles.reactionPill} ${mine.has(e) ? styles.reactionDone : ''}`}
onClick={() => onReact(e)}
aria-pressed={reacted.has(e)}
aria-pressed={mine.has(e)}
>
{e} {counts[e] > 0 ? counts[e] : ''}
</button>
......@@ -233,7 +363,7 @@ export default function CommentsSection({ slug, lang }) {
</div>
)}
<form className={styles.commentForm} onSubmit={onSubmit}>
<form className={styles.commentForm} onSubmit={onSubmit} ref={formRef}>
{/* Honeypot: visually gone, keyboard-gone, but a bot filling it dies. */}
<input
type="text"
......@@ -243,7 +373,22 @@ export default function CommentsSection({ slug, lang }) {
aria-hidden="true"
className={styles.hpField}
/>
{replyTo && (
{editing && (
<p className={styles.replying}>
{labels.edit} ·{' '}
<button
type="button"
className={styles.linkBtn}
onClick={() => {
setEditing(null);
formRef.current?.querySelector('textarea').value = '';
}}
>
({labels.cancelEdit})
</button>
</p>
)}
{!editing && replyTo && (
<p className={styles.replying}>
{labels.replying} <strong>{replyTo.author}</strong>{' '}
<button type="button" className={styles.linkBtn} onClick={() => setReplyTo(null)}>
......@@ -251,17 +396,28 @@ export default function CommentsSection({ slug, lang }) {
</button>
</p>
)}
{!editing && (
<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} />
)}
<textarea
name="body"
required
maxLength={2000}
rows={4}
placeholder={labels.body}
defaultValue={editing?.body ?? ''}
key={editing ? `edit-${editing.id}` : 'new'}
/>
<p className={styles.mdHelp}>{labels.mdHelp}</p>
<div className={styles.formRow}>
<button type="submit" className={styles.sendBtn}>
{labels.send}
{editing ? labels.update : labels.send}
</button>
{status === 'pending' && <span className={styles.statusOk}>{labels.pending}</span>}
{status === 'edited' && <span className={styles.statusOk}>{labels.editedPending}</span>}
{status === 'error' && <span className={styles.statusErr}>{labels.error}</span>}
</div>
</form>
......
......@@ -1320,3 +1320,7 @@
color: var(--text-muted);
margin: 0;
}
.voteMine {
color: currentColor;
opacity: 1;
}
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