From bb3ab4561d25ab54ba5802a49449f9031b1d8663 Mon Sep 17 00:00:00 2001 From: matt Date: Sat, 4 Jul 2026 21:08:08 -0400 Subject: [PATCH] Add moderated threaded comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- drizzle/0004_comments.sql | 18 + drizzle/meta/0004_snapshot.json | 895 ++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/actions/comments.ts | 78 ++ src/app/(public)/posts/[slug]/page.tsx | 11 +- src/app/admin/(panel)/comments/page.tsx | 112 +++ src/app/admin/(panel)/layout.tsx | 16 +- src/app/admin/(panel)/page.tsx | 17 +- src/components/public/CommentsSection.tsx | 240 ++++++ src/db/schema.ts | 33 + src/lib/services/comments.ts | 171 +++++ src/lib/services/import-export.ts | 150 +++- src/lib/validation.ts | 23 + tests/helpers/db.ts | 2 +- tests/integration/comments.test.ts | 163 ++++ tests/integration/import-export.test.ts | 91 +++ 16 files changed, 2013 insertions(+), 14 deletions(-) create mode 100644 drizzle/0004_comments.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 src/actions/comments.ts create mode 100644 src/app/admin/(panel)/comments/page.tsx create mode 100644 src/components/public/CommentsSection.tsx create mode 100644 src/lib/services/comments.ts create mode 100644 tests/integration/comments.test.ts diff --git a/drizzle/0004_comments.sql b/drizzle/0004_comments.sql new file mode 100644 index 0000000..112b91c --- /dev/null +++ b/drizzle/0004_comments.sql @@ -0,0 +1,18 @@ +CREATE TYPE "public"."comment_status" AS ENUM('pending', 'approved');--> statement-breakpoint +CREATE TABLE "comments" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "comments_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "post_id" integer NOT NULL, + "parent_id" integer, + "author_name" text NOT NULL, + "author_email" text NOT NULL, + "email_public" boolean DEFAULT false NOT NULL, + "body" text NOT NULL, + "status" "comment_status" DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "comments_post_id_status_idx" ON "comments" USING btree ("post_id","status");--> statement-breakpoint +CREATE INDEX "comments_parent_id_idx" ON "comments" USING btree ("parent_id");--> statement-breakpoint +CREATE INDEX "comments_status_idx" ON "comments" USING btree ("status"); \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..f120eea --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,895 @@ +{ + "id": "b1253e7e-39b6-4405-b99a-427b27e0266f", + "prevId": "ad3939eb-f52e-4a81-8732-6bcede794ad2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "comments_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_public": { + "name": "email_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "comment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_post_id_status_idx": { + "name": "comments_post_id_status_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_id_idx": { + "name": "comments_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_status_idx": { + "name": "comments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_post_id_posts_id_fk": { + "name": "comments_post_id_posts_id_fk", + "tableFrom": "comments", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_parent_id_comments_id_fk": { + "name": "comments_parent_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nav_items": { + "name": "nav_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "nav_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "nav_items_page_id_pages_id_fk": { + "name": "nav_items_page_id_pages_id_fk", + "tableFrom": "nav_items", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "nav_items_target_check": { + "name": "nav_items_target_check", + "value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "pages_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "content_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pages_slug_unique": { + "name": "pages_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tags": { + "name": "post_tags", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "post_tags_tag_id_idx": { + "name": "post_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_tags_post_id_posts_id_fk": { + "name": "post_tags_post_id_posts_id_fk", + "tableFrom": "post_tags", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tags_tag_id_tags_id_fk": { + "name": "post_tags_tag_id_tags_id_fk", + "tableFrom": "post_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tags_post_id_tag_id_pk": { + "name": "post_tags_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "posts_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "featured_image_url": { + "name": "featured_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "featured_image_alt": { + "name": "featured_image_alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "content_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "posts_status_published_at_idx": { + "name": "posts_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "posts_slug_unique": { + "name": "posts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'My Blog'" + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "footer_text": { + "name": "footer_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "posts_per_page": { + "name": "posts_per_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "excerpt_words": { + "name": "excerpt_words", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 40 + }, + "home_mode": { + "name": "home_mode", + "type": "home_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'posts'" + }, + "home_tag_id": { + "name": "home_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "home_page_id": { + "name": "home_page_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "theme", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'solarized-dark'" + }, + "font": { + "name": "font", + "type": "font", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'geist'" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_home_tag_id_tags_id_fk": { + "name": "settings_home_tag_id_tags_id_fk", + "tableFrom": "settings", + "tableTo": "tags", + "columnsFrom": [ + "home_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "settings_home_page_id_pages_id_fk": { + "name": "settings_home_page_id_pages_id_fk", + "tableFrom": "settings", + "tableTo": "pages", + "columnsFrom": [ + "home_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "settings_single_row_check": { + "name": "settings_single_row_check", + "value": "\"settings\".\"id\" = 1" + }, + "settings_posts_per_page_check": { + "name": "settings_posts_per_page_check", + "value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50" + }, + "settings_excerpt_words_check": { + "name": "settings_excerpt_words_check", + "value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200" + } + }, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tags_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "tags_slug_unique": { + "name": "tags_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.comment_status": { + "name": "comment_status", + "schema": "public", + "values": [ + "pending", + "approved" + ] + }, + "public.content_status": { + "name": "content_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.font": { + "name": "font", + "schema": "public", + "values": [ + "geist", + "inter", + "lora", + "merriweather", + "jetbrains-mono", + "source-serif", + "eb-garamond", + "playfair-display", + "open-sans", + "work-sans", + "atkinson-hyperlegible", + "space-grotesk" + ] + }, + "public.home_mode": { + "name": "home_mode", + "schema": "public", + "values": [ + "posts", + "tag", + "page" + ] + }, + "public.theme": { + "name": "theme", + "schema": "public", + "values": [ + "solarized-dark", + "solarized-light", + "dracula", + "nord", + "gruvbox-dark", + "mono", + "mono-dark", + "catppuccin-mocha", + "catppuccin-latte", + "tokyo-night", + "one-dark", + "rose-pine", + "everforest-dark", + "monokai", + "github-light" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9aaa402..af87bbb 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1783037839786, "tag": "0003_more-themes-fonts", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1783213112664, + "tag": "0004_comments", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/actions/comments.ts b/src/actions/comments.ts new file mode 100644 index 0000000..1ea86fc --- /dev/null +++ b/src/actions/comments.ts @@ -0,0 +1,78 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { requireAdmin } from "@/lib/auth/dal"; +import type { FormState } from "@/lib/forms"; +import { zodErrorToFormState } from "@/lib/forms"; +import { + createComment, + deleteComment, + setCommentStatus, +} from "@/lib/services/comments"; +import { commentFormSchema } from "@/lib/validation"; + +/** + * The one unauthenticated mutation in the app. Safe because the result is + * always a pending comment — nothing shows publicly until an admin approves + * it — and the service re-checks that the post is published and the parent + * comment is approved. + */ +export async function submitCommentAction( + _prev: FormState, + formData: FormData, +): Promise { + // Honeypot: real visitors never see this field; bots that fill it get a + // fake success so they don't learn to skip it. + const honeypot = formData.get("website"); + if (typeof honeypot === "string" && honeypot !== "") { + return { status: "success" }; + } + + const parsed = commentFormSchema.safeParse({ + postId: formData.get("postId"), + parentId: formData.get("parentId"), + authorName: formData.get("authorName"), + authorEmail: formData.get("authorEmail"), + emailPublic: formData.get("emailPublic"), + body: formData.get("body"), + }); + if (!parsed.success) return zodErrorToFormState(parsed.error); + const data = parsed.data; + + try { + const result = await createComment({ + postId: data.postId, + parentId: data.parentId, + authorName: data.authorName, + authorEmail: data.authorEmail, + emailPublic: data.emailPublic, + body: data.body, + }); + if (!result.ok) return { formError: result.error }; + } catch (error) { + console.error("submitCommentAction failed", error); + return { formError: "Something went wrong while posting. Please try again." }; + } + + // Pending comments are invisible publicly, so nothing to revalidate here. + return { status: "success" }; +} + +export async function setCommentStatusAction( + id: number, + status: "pending" | "approved", +): Promise { + await requireAdmin(); + const commentId = z.number().int().positive().parse(id); + const nextStatus = z.enum(["pending", "approved"]).parse(status); + await setCommentStatus(commentId, nextStatus); + revalidatePath("/", "layout"); +} + +export async function deleteCommentAction(id: number): Promise { + await requireAdmin(); + const commentId = z.number().int().positive().parse(id); + await deleteComment(commentId); + revalidatePath("/", "layout"); +} diff --git a/src/app/(public)/posts/[slug]/page.tsx b/src/app/(public)/posts/[slug]/page.tsx index a044852..ea02bb4 100644 --- a/src/app/(public)/posts/[slug]/page.tsx +++ b/src/app/(public)/posts/[slug]/page.tsx @@ -1,7 +1,10 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { submitCommentAction } from "@/actions/comments"; +import { CommentsSection } from "@/components/public/CommentsSection"; import { PostArticle } from "@/components/public/PostArticle"; import { generateExcerpt } from "@/lib/excerpt"; +import { listApprovedComments } from "@/lib/services/comments"; import { getPublishedPostBySlug } from "@/lib/services/posts"; type Props = { params: Promise<{ slug: string }> }; @@ -18,5 +21,11 @@ export default async function PostPage({ params }: Props) { // Draft posts are filtered inside the query — they 404 like unknown slugs. const post = await getPublishedPostBySlug(slug); if (!post) notFound(); - return ; + const comments = await listApprovedComments(post.id); + return ( + <> + + + + ); } diff --git a/src/app/admin/(panel)/comments/page.tsx b/src/app/admin/(panel)/comments/page.tsx new file mode 100644 index 0000000..1fa4152 --- /dev/null +++ b/src/app/admin/(panel)/comments/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { deleteCommentAction, setCommentStatusAction } from "@/actions/comments"; +import { ConfirmButton } from "@/components/admin/ConfirmButton"; +import { Button } from "@/components/ui"; +import { requireAdmin } from "@/lib/auth/dal"; +import { formatDate } from "@/lib/format"; +import { type AdminComment, listCommentsForAdmin } from "@/lib/services/comments"; + +export const metadata: Metadata = { title: "Comments" }; + +function CommentCard({ comment }: { comment: AdminComment }) { + return ( +
  • +
    + {comment.authorName} + + {comment.authorEmail}{" "} + + {comment.emailPublic ? "email shown publicly" : "email private"} + + + {formatDate(comment.createdAt)} +
    +

    + On{" "} + + {comment.postTitle} + + {comment.parentAuthorName && <> · replying to {comment.parentAuthorName}} +

    +

    + {comment.body} +

    +
    + {comment.status === "pending" ? ( +
    + +
    + ) : ( +
    + +
    + )} +
    + + Delete + +
    +
    +
  • + ); +} + +export default async function AdminCommentsPage() { + await requireAdmin(); + const all = await listCommentsForAdmin(); + const pending = all.filter((c) => c.status === "pending"); + const approved = all.filter((c) => c.status === "approved"); + + return ( +
    +

    Comments

    + +
    +

    + Awaiting approval {pending.length > 0 && `(${pending.length})`} +

    + {pending.length === 0 ? ( +

    + Nothing waiting — all caught up. +

    + ) : ( +
      + {pending.map((comment) => ( + + ))} +
    + )} +
    + +
    +

    + Approved {approved.length > 0 && `(${approved.length})`} +

    + {approved.length === 0 ? ( +

    No approved comments yet.

    + ) : ( +
      + {approved.map((comment) => ( + + ))} +
    + )} +
    +
    + ); +} diff --git a/src/app/admin/(panel)/layout.tsx b/src/app/admin/(panel)/layout.tsx index d8000cd..3b7876f 100644 --- a/src/app/admin/(panel)/layout.tsx +++ b/src/app/admin/(panel)/layout.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { logoutAction } from "@/actions/auth"; import { requireAdmin } from "@/lib/auth/dal"; +import { countCommentsByStatus } from "@/lib/services/comments"; import { getSettings } from "@/lib/services/settings"; const navLinkClasses = @@ -13,7 +14,10 @@ const navLinkClasses = */ export default async function AdminLayout({ children }: { children: React.ReactNode }) { const user = await requireAdmin(); - const settings = await getSettings(); + const [settings, commentCounts] = await Promise.all([ + getSettings(), + countCommentsByStatus(), + ]); return ( <> @@ -32,6 +36,16 @@ export default async function AdminLayout({ children }: { children: React.ReactN
  • Dashboard
  • Posts
  • Pages
  • +
  • + + Comments + {commentCounts.pending > 0 && ( + + {commentCounts.pending} + + )} + +
  • Settings
  • diff --git a/src/app/admin/(panel)/page.tsx b/src/app/admin/(panel)/page.tsx index a15bf6e..d846891 100644 --- a/src/app/admin/(panel)/page.tsx +++ b/src/app/admin/(panel)/page.tsx @@ -4,6 +4,7 @@ import { StatusBadge } from "@/components/admin/StatusBadge"; import { LinkButton } from "@/components/ui"; import { requireAdmin } from "@/lib/auth/dal"; import { formatDate } from "@/lib/format"; +import { countCommentsByStatus } from "@/lib/services/comments"; import { listAllPages } from "@/lib/services/pages"; import { countPostsByStatus, listAllPosts } from "@/lib/services/posts"; import { listAllTags } from "@/lib/services/tags"; @@ -12,11 +13,12 @@ export const metadata: Metadata = { title: "Dashboard" }; export default async function AdminDashboard() { await requireAdmin(); - const [postCounts, allPosts, allPages, allTags] = await Promise.all([ + const [postCounts, allPosts, allPages, allTags, commentCounts] = await Promise.all([ countPostsByStatus(), listAllPosts(), listAllPages(), listAllTags(), + countCommentsByStatus(), ]); const recentPosts = allPosts.slice(0, 5); @@ -25,6 +27,7 @@ export default async function AdminDashboard() { { label: "Draft posts", value: postCounts.draft }, { label: "Pages", value: allPages.length }, { label: "Tags", value: allTags.length }, + { label: "Pending comments", value: commentCounts.pending, href: "/admin/comments" }, ]; return ( @@ -37,10 +40,18 @@ export default async function AdminDashboard() { -
    +
    {stats.map((stat) => (
    -
    {stat.label}
    +
    + {stat.href ? ( + + {stat.label} + + ) : ( + stat.label + )} +
    {stat.value}
    ))} diff --git a/src/components/public/CommentsSection.tsx b/src/components/public/CommentsSection.tsx new file mode 100644 index 0000000..b31cef5 --- /dev/null +++ b/src/components/public/CommentsSection.tsx @@ -0,0 +1,240 @@ +"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; + +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 ( +

    + Thanks! Your comment is awaiting moderation and will appear once approved. +

    + ); + } + + return ( +
    + {state.formError && ( +

    + {state.formError} +

    + )} + + + {/* Honeypot — hidden from people, tempting to bots. */} + + +
    +
    + + + {err("authorName")} +
    +
    + + + {err("authorEmail")} +
    +
    + +
    + +