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'; ...@@ -11,6 +11,12 @@ import styles from './blog.module.css';
* "en attente de validation", never instant publication. The honeypot triad * "en attente de validation", never instant publication. The honeypot triad
* (empty website field + mount timestamp + server-side per-IP windows) is * (empty website field + mount timestamp + server-side per-IP windows) is
* wired here; there is no captcha, by decision. * 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'; const API = process.env.NEXT_PUBLIC_COMMENTS_API || 'https://api.nech.pl/www/v1';
...@@ -22,13 +28,19 @@ const LABELS = { ...@@ -22,13 +28,19 @@ const LABELS = {
noscript: 'Les commentaires se chargent avec JavaScript.', noscript: 'Les commentaires se chargent avec JavaScript.',
none: 'Pas encore de commentaire.', none: 'Pas encore de commentaire.',
pending: 'Reçu — en attente de validation, il paraîtra dès qu’il est approuvé.', 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', name: 'Nom',
email: 'E-mail (optionnel, jamais affiché)', email: 'E-mail (optionnel, jamais affiché)',
body: 'Votre commentaire…', body: 'Votre commentaire…',
send: 'Envoyer', send: 'Envoyer',
update: 'Mettre à jour',
cancelEdit: 'annuler',
reply: 'Répondre', reply: 'Répondre',
cancel: 'annuler', cancel: 'annuler',
replying: 'en réponse à', replying: 'en réponse à',
edit: 'éditer',
remove: 'supprimer',
confirmRemove: 'Supprimer votre commentaire ?',
mdHelp: 'Markdown léger : **gras**, *italique*, `code`, [lien](https://…), ![image](https://…).', mdHelp: 'Markdown léger : **gras**, *italique*, `code`, [lien](https://…), ![image](https://…).',
error: 'Rejeté par le filtre anti-spam — réessayez dans un instant.', error: 'Rejeté par le filtre anti-spam — réessayez dans un instant.',
react: 'Réagir', react: 'Réagir',
...@@ -39,19 +51,41 @@ const LABELS = { ...@@ -39,19 +51,41 @@ const LABELS = {
noscript: 'Comments load with JavaScript.', noscript: 'Comments load with JavaScript.',
none: 'No comments yet.', none: 'No comments yet.',
pending: 'Received — on the approval queue, it shows as soon as it is approved.', 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', name: 'Name',
email: 'Email (optional, never displayed)', email: 'Email (optional, never displayed)',
body: 'Your comment…', body: 'Your comment…',
send: 'Send', send: 'Send',
update: 'Update',
cancelEdit: 'cancel',
reply: 'Reply', reply: 'Reply',
cancel: 'cancel', cancel: 'cancel',
replying: 'replying to', replying: 'replying to',
edit: 'edit',
remove: 'delete',
confirmRemove: 'Delete your comment?',
mdHelp: 'Light markdown: **bold**, *italic*, `code`, [link](https://…), ![image](https://…).', mdHelp: 'Light markdown: **bold**, *italic*, `code`, [link](https://…), ![image](https://…).',
error: 'Rejected by the anti-spam filter — try again in a moment.', error: 'Rejected by the anti-spam filter — try again in a moment.',
react: 'React', 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) { async function api(path, opts) {
const res = await fetch(`${API}${path}`, { const res = await fetch(`${API}${path}`, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
...@@ -61,7 +95,7 @@ async function api(path, opts) { ...@@ -61,7 +95,7 @@ async function api(path, opts) {
return res.json(); return res.json();
} }
function CommentNode({ node, labels, onReply, onVote, voted, depth }) { function CommentNode({ node, labels, onReply, onVote, myVotes, editable, onEdit, onDelete, depth }) {
return ( return (
<li className={styles.comment} style={depth ? { marginLeft: '1.2rem' } : undefined}> <li className={styles.comment} style={depth ? { marginLeft: '1.2rem' } : undefined}>
<div className={styles.commentHead}> <div className={styles.commentHead}>
...@@ -69,9 +103,9 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) { ...@@ -69,9 +103,9 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) {
<span className={styles.commentScore}> <span className={styles.commentScore}>
<button <button
type="button" type="button"
className={styles.voteBtn} className={`${styles.voteBtn} ${myVotes.get(node.id) === 1 ? styles.voteMine : ''}`}
aria-label="+1" aria-label="+1"
disabled={voted.has(node.id)} aria-pressed={myVotes.get(node.id) === 1}
onClick={() => onVote(node.id, 1)} onClick={() => onVote(node.id, 1)}
> >
▲ ▲
...@@ -79,9 +113,9 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) { ...@@ -79,9 +113,9 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) {
{node.score} {node.score}
<button <button
type="button" type="button"
className={styles.voteBtn} className={`${styles.voteBtn} ${myVotes.get(node.id) === -1 ? styles.voteMine : ''}`}
aria-label="-1" aria-label="-1"
disabled={voted.has(node.id)} aria-pressed={myVotes.get(node.id) === -1}
onClick={() => onVote(node.id, -1)} onClick={() => onVote(node.id, -1)}
> >
▼ ▼
...@@ -94,6 +128,24 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) { ...@@ -94,6 +128,24 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) {
<button type="button" className={styles.linkBtn} onClick={() => onReply(node)}> <button type="button" className={styles.linkBtn} onClick={() => onReply(node)}>
{labels.reply} {labels.reply}
</button> </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> </div>
{node.children?.length > 0 && ( {node.children?.length > 0 && (
<ul className={styles.commentChildren}> <ul className={styles.commentChildren}>
...@@ -104,7 +156,10 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) { ...@@ -104,7 +156,10 @@ function CommentNode({ node, labels, onReply, onVote, voted, depth }) {
labels={labels} labels={labels}
onReply={onReply} onReply={onReply}
onVote={onVote} onVote={onVote}
voted={voted} myVotes={myVotes}
editable={editable}
onEdit={onEdit}
onDelete={onDelete}
depth={depth + 1} depth={depth + 1}
/> />
))} ))}
...@@ -119,16 +174,35 @@ export default function CommentsSection({ slug, lang }) { ...@@ -119,16 +174,35 @@ export default function CommentsSection({ slug, lang }) {
const [threads, setThreads] = useState(null); // null = loading const [threads, setThreads] = useState(null); // null = loading
const [emoji, setEmoji] = useState([]); const [emoji, setEmoji] = useState([]);
const [counts, setCounts] = useState({}); const [counts, setCounts] = useState({});
const [reacted, setReacted] = useState(() => new Set()); const [mine, setMine] = useState(() => new Set());
const [voted, setVoted] = useState(() => new Set()); const [myVotes, setMyVotes] = useState(() => new Map());
const [editable, setEditable] = useState(() => new Set());
const [replyTo, setReplyTo] = useState(null); 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 [dead, setDead] = useState(false); // API unreachable — the section stays honest and hides
const mountedAt = useRef(Date.now()); 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 = () => const loadThreads = () =>
api(`/posts/${slug}/comments`) api(`/posts/${slug}/comments`)
.then((d) => setThreads(d.comments)) .then((d) => adoptThreads(d.comments))
.catch(() => { .catch(() => {
setThreads([]); setThreads([]);
setDead(true); setDead(true);
...@@ -136,7 +210,10 @@ export default function CommentsSection({ slug, lang }) { ...@@ -136,7 +210,10 @@ export default function CommentsSection({ slug, lang }) {
const loadReactions = () => const loadReactions = () =>
api(`/posts/${slug}/reactions`) 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(() => {}); .catch(() => {});
useEffect(() => { useEffect(() => {
...@@ -146,9 +223,10 @@ export default function CommentsSection({ slug, lang }) { ...@@ -146,9 +223,10 @@ export default function CommentsSection({ slug, lang }) {
}, [slug]); }, [slug]);
const onVote = async (id, value) => { const onVote = async (id, value) => {
if (myVotes.get(id) === value) return; // same vote again = no-op
try { try {
await api(`/comments/${id}/vote`, { method: 'POST', body: JSON.stringify({ value }) }); 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(); loadThreads();
} catch { } catch {
/* a rejected vote is a silent no-op for the reader */ /* a rejected vote is a silent no-op for the reader */
...@@ -156,13 +234,49 @@ export default function CommentsSection({ slug, lang }) { ...@@ -156,13 +234,49 @@ export default function CommentsSection({ slug, lang }) {
}; };
const onReact = async (e) => { const onReact = async (e) => {
if (reacted.has(e)) return;
try { 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 }) }); await api(`/posts/${slug}/reactions`, { method: 'POST', body: JSON.stringify({ emoji: e }) });
setReacted((s) => new Set(s).add(e)); setMine((s) => new Set(s).add(e));
loadReactions(); setCounts((c) => ({ ...c, [e]: (c[e] ?? 0) + 1 }));
}
} catch { } 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 }) { ...@@ -170,7 +284,18 @@ export default function CommentsSection({ slug, lang }) {
e.preventDefault(); e.preventDefault();
const form = new FormData(e.target); const form = new FormData(e.target);
try { 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', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
author_name: form.get('author_name'), author_name: form.get('author_name'),
...@@ -181,9 +306,11 @@ export default function CommentsSection({ slug, lang }) { ...@@ -181,9 +306,11 @@ export default function CommentsSection({ slug, lang }) {
ts: mountedAt.current, // min-submit-time — humans read before writing ts: mountedAt.current, // min-submit-time — humans read before writing
}), }),
}); });
if (res.secret && res.id) saveSecret(res.id, res.secret);
setStatus('pending'); setStatus('pending');
e.target.reset();
setReplyTo(null); setReplyTo(null);
}
e.target.reset();
} catch { } catch {
setStatus('error'); setStatus('error');
} }
...@@ -210,7 +337,10 @@ export default function CommentsSection({ slug, lang }) { ...@@ -210,7 +337,10 @@ export default function CommentsSection({ slug, lang }) {
labels={labels} labels={labels}
onReply={setReplyTo} onReply={setReplyTo}
onVote={onVote} onVote={onVote}
voted={voted} myVotes={myVotes}
editable={editable}
onEdit={startEdit}
onDelete={onDelete}
depth={0} depth={0}
/> />
))} ))}
...@@ -223,9 +353,9 @@ export default function CommentsSection({ slug, lang }) { ...@@ -223,9 +353,9 @@ export default function CommentsSection({ slug, lang }) {
<button <button
key={e} key={e}
type="button" type="button"
className={`${styles.reactionPill} ${reacted.has(e) ? styles.reactionDone : ''}`} className={`${styles.reactionPill} ${mine.has(e) ? styles.reactionDone : ''}`}
onClick={() => onReact(e)} onClick={() => onReact(e)}
aria-pressed={reacted.has(e)} aria-pressed={mine.has(e)}
> >
{e} {counts[e] > 0 ? counts[e] : ''} {e} {counts[e] > 0 ? counts[e] : ''}
</button> </button>
...@@ -233,7 +363,7 @@ export default function CommentsSection({ slug, lang }) { ...@@ -233,7 +363,7 @@ export default function CommentsSection({ slug, lang }) {
</div> </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. */} {/* Honeypot: visually gone, keyboard-gone, but a bot filling it dies. */}
<input <input
type="text" type="text"
...@@ -243,7 +373,22 @@ export default function CommentsSection({ slug, lang }) { ...@@ -243,7 +373,22 @@ export default function CommentsSection({ slug, lang }) {
aria-hidden="true" aria-hidden="true"
className={styles.hpField} 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}> <p className={styles.replying}>
{labels.replying} <strong>{replyTo.author}</strong>{' '} {labels.replying} <strong>{replyTo.author}</strong>{' '}
<button type="button" className={styles.linkBtn} onClick={() => setReplyTo(null)}> <button type="button" className={styles.linkBtn} onClick={() => setReplyTo(null)}>
...@@ -251,17 +396,28 @@ export default function CommentsSection({ slug, lang }) { ...@@ -251,17 +396,28 @@ export default function CommentsSection({ slug, lang }) {
</button> </button>
</p> </p>
)} )}
{!editing && (
<div className={styles.formRow}> <div className={styles.formRow}>
<input name="author_name" required maxLength={60} placeholder={labels.name} /> <input name="author_name" required maxLength={60} placeholder={labels.name} />
<input name="author_email" type="email" placeholder={labels.email} /> <input name="author_email" type="email" placeholder={labels.email} />
</div> </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> <p className={styles.mdHelp}>{labels.mdHelp}</p>
<div className={styles.formRow}> <div className={styles.formRow}>
<button type="submit" className={styles.sendBtn}> <button type="submit" className={styles.sendBtn}>
{labels.send} {editing ? labels.update : labels.send}
</button> </button>
{status === 'pending' && <span className={styles.statusOk}>{labels.pending}</span>} {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>} {status === 'error' && <span className={styles.statusErr}>{labels.error}</span>}
</div> </div>
</form> </form>
......
...@@ -1320,3 +1320,7 @@ ...@@ -1320,3 +1320,7 @@
color: var(--text-muted); color: var(--text-muted);
margin: 0; 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