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>
241 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|