yap-blog/src/components/public/CommentsSection.tsx
matt bb3ab4561d Add moderated threaded comments
Visitors comment with just a name and email; a checkbox controls
whether the email is shown publicly (default private — only the admin
sees it). Every comment lands as pending and is invisible until
approved on the new /admin/comments page (approve / unapprove /
delete, with a pending-count badge in the admin nav and a dashboard
stat). Replies nest under their parent; a reply is only accepted on an
approved comment of the same post, and replies stay hidden while their
parent is unapproved so threads never render out of context. A hidden
honeypot field silently drops naive bots. Comment bodies are plain
text, rendered escaped.

The backup format gains a comments section (export version 2; v1 files
still import) with parent links remapped through file-local ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:08:08 -04:00

241 lines
7.3 KiB
TypeScript

"use client";
import { useActionState, useId, useState } from "react";
import { Button, ErrorText, HelpText, Input, Label, Textarea } from "@/components/ui";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
import { formatDate, isoDate } from "@/lib/format";
import type { PublicComment } from "@/lib/services/comments";
type CommentAction = (prev: FormState, formData: FormData) => Promise<FormState>;
function countComments(list: PublicComment[]): number {
return list.reduce((sum, c) => sum + 1 + countComments(c.replies), 0);
}
function CommentForm({
postId,
parentId,
action,
onCancel,
}: {
postId: number;
parentId: number | null;
action: CommentAction;
onCancel?: () => void;
}) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
const err = (field: string) => firstFieldError(state, field);
if (state.status === "success") {
return (
<p
role="status"
className="rounded-md border border-success/40 bg-success/10 px-4 py-3 text-sm text-success"
>
Thanks! Your comment is awaiting moderation and will appear once approved.
</p>
);
}
return (
<form action={formAction} className="space-y-4">
{state.formError && (
<p
role="alert"
className="rounded-md border border-danger/40 bg-danger/10 px-4 py-2.5 text-sm text-danger"
>
{state.formError}
</p>
)}
<input type="hidden" name="postId" value={postId} />
<input type="hidden" name="parentId" value={parentId ?? ""} />
{/* Honeypot — hidden from people, tempting to bots. */}
<div className="hidden" aria-hidden="true">
<label htmlFor={`${ids}-website`}>Website</label>
<input id={`${ids}-website`} name="website" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor={`${ids}-name`}>Name</Label>
<Input
id={`${ids}-name`}
name="authorName"
required
maxLength={120}
autoComplete="name"
aria-invalid={err("authorName") ? true : undefined}
/>
<ErrorText>{err("authorName")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-email`}>Email</Label>
<Input
id={`${ids}-email`}
name="authorEmail"
type="email"
required
maxLength={254}
autoComplete="email"
aria-invalid={err("authorEmail") ? true : undefined}
/>
<ErrorText>{err("authorEmail")}</ErrorText>
</div>
</div>
<label className="flex cursor-pointer items-start gap-2 text-sm text-ink">
<input type="checkbox" name="emailPublic" className="mt-0.5 size-4 accent-(--link)" />
<span>
Show my email publicly
<span className="block text-xs text-ink-muted">
Leave unchecked to keep your email visible to the site owner only.
</span>
</span>
</label>
<div>
<Label htmlFor={`${ids}-body`}>Comment</Label>
<Textarea
id={`${ids}-body`}
name="body"
required
rows={parentId === null ? 5 : 3}
maxLength={5000}
aria-invalid={err("body") ? true : undefined}
/>
<ErrorText>{err("body")}</ErrorText>
</div>
<div className="flex items-center gap-3">
<Button type="submit">{parentId === null ? "Post comment" : "Post reply"}</Button>
{onCancel && (
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
)}
<HelpText>Comments are reviewed before they appear.</HelpText>
</div>
</form>
);
}
function CommentItem({
comment,
postId,
action,
replyTo,
setReplyTo,
depth,
}: {
comment: PublicComment;
postId: number;
action: CommentAction;
replyTo: number | null;
setReplyTo: (id: number | null) => void;
depth: number;
}) {
return (
<li>
<article className="rounded-lg border border-edge bg-surface p-4">
<header className="flex flex-wrap items-baseline gap-x-2 text-sm">
<span className="font-semibold text-ink-strong">{comment.authorName}</span>
{comment.authorEmail && (
<a
href={`mailto:${comment.authorEmail}`}
className="text-xs text-link hover:underline"
>
{comment.authorEmail}
</a>
)}
<time dateTime={isoDate(comment.createdAt)} className="text-xs text-ink-muted">
{formatDate(comment.createdAt)}
</time>
</header>
<p className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-ink">
{comment.body}
</p>
<footer className="mt-2">
<button
type="button"
className="text-xs font-medium text-link hover:underline"
onClick={() => setReplyTo(replyTo === comment.id ? null : comment.id)}
>
{replyTo === comment.id ? "Close reply form" : "Reply"}
</button>
</footer>
</article>
{replyTo === comment.id && (
<div className="mt-3 border-l-2 border-edge-strong pl-4">
<CommentForm
postId={postId}
parentId={comment.id}
action={action}
onCancel={() => setReplyTo(null)}
/>
</div>
)}
{comment.replies.length > 0 && (
// Cap the visual indent so deep threads stay readable on phones.
<ul className={`mt-3 space-y-3 ${depth < 4 ? "border-l-2 border-edge pl-4 sm:pl-6" : ""}`}>
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
postId={postId}
action={action}
replyTo={replyTo}
setReplyTo={setReplyTo}
depth={depth + 1}
/>
))}
</ul>
)}
</li>
);
}
export function CommentsSection({
postId,
comments,
action,
}: {
postId: number;
comments: PublicComment[];
action: CommentAction;
}) {
const [replyTo, setReplyTo] = useState<number | null>(null);
const total = countComments(comments);
const ids = useId();
return (
<section aria-labelledby={`${ids}-comments`} className="mx-auto mt-12 max-w-[46rem] border-t border-edge pt-8">
<h2 id={`${ids}-comments`} className="text-xl font-bold tracking-tight text-ink-bright">
{total === 0 ? "Comments" : total === 1 ? "1 comment" : `${total} comments`}
</h2>
{total === 0 ? (
<p className="mt-4 text-sm text-ink-muted">No comments yet. Start the conversation!</p>
) : (
<ul className="mt-6 space-y-4">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
postId={postId}
action={action}
replyTo={replyTo}
setReplyTo={setReplyTo}
depth={0}
/>
))}
</ul>
)}
<div className="mt-10">
<h3 className="mb-4 text-lg font-semibold text-ink-strong">Leave a comment</h3>
<CommentForm postId={postId} parentId={null} action={action} />
</div>
</section>
);
}