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>
This commit is contained in:
parent
85dd16a564
commit
bb3ab4561d
18
drizzle/0004_comments.sql
Normal file
18
drizzle/0004_comments.sql
Normal file
|
|
@ -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");
|
||||
895
drizzle/meta/0004_snapshot.json
Normal file
895
drizzle/meta/0004_snapshot.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,13 @@
|
|||
"when": 1783037839786,
|
||||
"tag": "0003_more-themes-fonts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1783213112664,
|
||||
"tag": "0004_comments",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
78
src/actions/comments.ts
Normal file
78
src/actions/comments.ts
Normal file
|
|
@ -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<FormState> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
await requireAdmin();
|
||||
const commentId = z.number().int().positive().parse(id);
|
||||
await deleteComment(commentId);
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
|
@ -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 <PostArticle post={post} />;
|
||||
const comments = await listApprovedComments(post.id);
|
||||
return (
|
||||
<>
|
||||
<PostArticle post={post} />
|
||||
<CommentsSection postId={post.id} comments={comments} action={submitCommentAction} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
112
src/app/admin/(panel)/comments/page.tsx
Normal file
112
src/app/admin/(panel)/comments/page.tsx
Normal file
|
|
@ -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 (
|
||||
<li className="rounded-lg border border-edge bg-surface p-4">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span className="font-semibold text-ink-strong">{comment.authorName}</span>
|
||||
<span className="text-xs text-ink-muted">
|
||||
{comment.authorEmail}{" "}
|
||||
<span
|
||||
className={
|
||||
comment.emailPublic
|
||||
? "rounded border border-warning/40 px-1 text-warning"
|
||||
: "rounded border border-edge px-1"
|
||||
}
|
||||
>
|
||||
{comment.emailPublic ? "email shown publicly" : "email private"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-ink-muted">{formatDate(comment.createdAt)}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-ink-muted">
|
||||
On{" "}
|
||||
<Link
|
||||
href={`/posts/${comment.postSlug}`}
|
||||
className="text-link hover:underline"
|
||||
>
|
||||
{comment.postTitle}
|
||||
</Link>
|
||||
{comment.parentAuthorName && <> · replying to {comment.parentAuthorName}</>}
|
||||
</p>
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm leading-relaxed text-ink">
|
||||
{comment.body}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{comment.status === "pending" ? (
|
||||
<form action={setCommentStatusAction.bind(null, comment.id, "approved")}>
|
||||
<Button type="submit" className="px-3 py-1.5 text-xs">Approve</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={setCommentStatusAction.bind(null, comment.id, "pending")}>
|
||||
<Button type="submit" variant="secondary" className="px-3 py-1.5 text-xs">
|
||||
Unapprove
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<form action={deleteCommentAction.bind(null, comment.id)}>
|
||||
<ConfirmButton
|
||||
confirmMessage={`Delete this comment by ${comment.authorName}? Replies to it are deleted too. This cannot be undone.`}
|
||||
className="px-3 py-1.5 text-xs"
|
||||
>
|
||||
Delete
|
||||
</ConfirmButton>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">Comments</h1>
|
||||
|
||||
<section aria-labelledby="pending-heading">
|
||||
<h2 id="pending-heading" className="text-lg font-semibold text-ink-strong">
|
||||
Awaiting approval {pending.length > 0 && `(${pending.length})`}
|
||||
</h2>
|
||||
{pending.length === 0 ? (
|
||||
<p className="mt-3 rounded-lg border border-dashed border-edge-strong px-4 py-8 text-center text-sm text-ink-muted">
|
||||
Nothing waiting — all caught up.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-3">
|
||||
{pending.map((comment) => (
|
||||
<CommentCard key={comment.id} comment={comment} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="approved-heading" className="mt-10">
|
||||
<h2 id="approved-heading" className="text-lg font-semibold text-ink-strong">
|
||||
Approved {approved.length > 0 && `(${approved.length})`}
|
||||
</h2>
|
||||
{approved.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-ink-muted">No approved comments yet.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-3">
|
||||
{approved.map((comment) => (
|
||||
<CommentCard key={comment.id} comment={comment} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|||
<li><Link href="/admin" className={navLinkClasses}>Dashboard</Link></li>
|
||||
<li><Link href="/admin/posts" className={navLinkClasses}>Posts</Link></li>
|
||||
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
|
||||
<li>
|
||||
<Link href="/admin/comments" className={navLinkClasses}>
|
||||
Comments
|
||||
{commentCounts.pending > 0 && (
|
||||
<span className="ml-1.5 inline-flex min-w-5 items-center justify-center rounded-full bg-warning/20 px-1.5 py-0.5 text-xs font-semibold text-warning">
|
||||
{commentCounts.pending}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
<li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<dl className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg border border-edge bg-surface p-5">
|
||||
<dt className="text-sm text-ink-muted">{stat.label}</dt>
|
||||
<dt className="text-sm text-ink-muted">
|
||||
{stat.href ? (
|
||||
<Link href={stat.href} className="transition-colors hover:text-link">
|
||||
{stat.label}
|
||||
</Link>
|
||||
) : (
|
||||
stat.label
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-ink-bright">{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
240
src/components/public/CommentsSection.tsx
Normal file
240
src/components/public/CommentsSection.tsx
Normal file
|
|
@ -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<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
type AnyPgColumn,
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
|
|
@ -11,6 +13,7 @@ import {
|
|||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const contentStatusEnum = pgEnum("content_status", ["draft", "published"]);
|
||||
export const commentStatusEnum = pgEnum("comment_status", ["pending", "approved"]);
|
||||
export const homeModeEnum = pgEnum("home_mode", ["posts", "tag", "page"]);
|
||||
export const themeEnum = pgEnum("theme", [
|
||||
"solarized-dark",
|
||||
|
|
@ -101,6 +104,34 @@ export const postTags = pgTable(
|
|||
],
|
||||
);
|
||||
|
||||
export const comments = pgTable(
|
||||
"comments",
|
||||
{
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
postId: integer("post_id")
|
||||
.notNull()
|
||||
.references(() => posts.id, { onDelete: "cascade" }),
|
||||
// Threading: replies point at their parent; deleting a comment removes
|
||||
// its whole subtree via the cascade.
|
||||
parentId: integer("parent_id").references((): AnyPgColumn => comments.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
authorName: text("author_name").notNull(),
|
||||
// Always required (spam accountability); shown publicly only when the
|
||||
// commenter opted in via emailPublic.
|
||||
authorEmail: text("author_email").notNull(),
|
||||
emailPublic: boolean("email_public").notNull().default(false),
|
||||
body: text("body").notNull(),
|
||||
status: commentStatusEnum("status").notNull().default("pending"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("comments_post_id_status_idx").on(t.postId, t.status),
|
||||
index("comments_parent_id_idx").on(t.parentId),
|
||||
index("comments_status_idx").on(t.status),
|
||||
],
|
||||
);
|
||||
|
||||
export const pages = pgTable("pages", {
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
title: text("title").notNull(),
|
||||
|
|
@ -155,7 +186,9 @@ export type Tag = typeof tags.$inferSelect;
|
|||
export type Page = typeof pages.$inferSelect;
|
||||
export type Settings = typeof settings.$inferSelect;
|
||||
export type NavItem = typeof navItems.$inferSelect;
|
||||
export type Comment = typeof comments.$inferSelect;
|
||||
export type ContentStatus = Post["status"];
|
||||
export type CommentStatus = Comment["status"];
|
||||
export type HomeMode = Settings["homeMode"];
|
||||
export type Theme = Settings["theme"];
|
||||
export type Font = Settings["font"];
|
||||
|
|
|
|||
171
src/lib/services/comments.ts
Normal file
171
src/lib/services/comments.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { and, asc, count, desc, eq } from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import { db } from "@/db";
|
||||
import { type Comment, type CommentStatus, comments, posts } from "@/db/schema";
|
||||
|
||||
export type CommentInput = {
|
||||
postId: number;
|
||||
/** null for a top-level comment, otherwise the comment being replied to. */
|
||||
parentId: number | null;
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
emailPublic: boolean;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type CreateCommentResult =
|
||||
| { ok: true; comment: Comment }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Public submission path: verifies the target post is published and the
|
||||
* parent (if any) is an approved comment on the same post, then stores the
|
||||
* comment as pending. Nothing a visitor submits is visible until approved.
|
||||
*/
|
||||
export async function createComment(input: CommentInput): Promise<CreateCommentResult> {
|
||||
const [post] = await db
|
||||
.select({ id: posts.id })
|
||||
.from(posts)
|
||||
.where(and(eq(posts.id, input.postId), eq(posts.status, "published")))
|
||||
.limit(1);
|
||||
if (!post) return { ok: false, error: "This post does not accept comments." };
|
||||
|
||||
if (input.parentId !== null) {
|
||||
const [parent] = await db
|
||||
.select({ id: comments.id })
|
||||
.from(comments)
|
||||
.where(
|
||||
and(
|
||||
eq(comments.id, input.parentId),
|
||||
eq(comments.postId, input.postId),
|
||||
eq(comments.status, "approved"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!parent) {
|
||||
return { ok: false, error: "The comment you are replying to no longer exists." };
|
||||
}
|
||||
}
|
||||
|
||||
const [comment] = await db
|
||||
.insert(comments)
|
||||
.values({
|
||||
postId: input.postId,
|
||||
parentId: input.parentId,
|
||||
authorName: input.authorName,
|
||||
authorEmail: input.authorEmail,
|
||||
emailPublic: input.emailPublic,
|
||||
body: input.body,
|
||||
status: "pending",
|
||||
})
|
||||
.returning();
|
||||
return { ok: true, comment };
|
||||
}
|
||||
|
||||
/** What the public site sees: private emails are never in the payload. */
|
||||
export type PublicComment = {
|
||||
id: number;
|
||||
authorName: string;
|
||||
/** Present only when the commenter chose to show it. */
|
||||
authorEmail: string | null;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
replies: PublicComment[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Approved comments for a post as a tree, oldest first at every level.
|
||||
* A reply whose parent is still pending stays hidden until the parent is
|
||||
* approved — a thread never renders out of context.
|
||||
*/
|
||||
export async function listApprovedComments(postId: number): Promise<PublicComment[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(and(eq(comments.postId, postId), eq(comments.status, "approved")))
|
||||
.orderBy(asc(comments.createdAt), asc(comments.id));
|
||||
|
||||
const nodes = new Map<number, PublicComment>();
|
||||
for (const row of rows) {
|
||||
nodes.set(row.id, {
|
||||
id: row.id,
|
||||
authorName: row.authorName,
|
||||
authorEmail: row.emailPublic ? row.authorEmail : null,
|
||||
body: row.body,
|
||||
createdAt: row.createdAt,
|
||||
replies: [],
|
||||
});
|
||||
}
|
||||
const roots: PublicComment[] = [];
|
||||
for (const row of rows) {
|
||||
const node = nodes.get(row.id)!;
|
||||
const parent = row.parentId !== null ? nodes.get(row.parentId) : undefined;
|
||||
if (row.parentId === null) {
|
||||
roots.push(node);
|
||||
} else if (parent) {
|
||||
parent.replies.push(node);
|
||||
}
|
||||
// else: parent not approved (or deleted mid-query) — hide the reply.
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export type AdminComment = Comment & {
|
||||
postTitle: string;
|
||||
postSlug: string;
|
||||
/** Author of the parent comment, when this is a reply. */
|
||||
parentAuthorName: string | null;
|
||||
};
|
||||
|
||||
/** Admin moderation list: every comment with its post, newest first. */
|
||||
export async function listCommentsForAdmin(): Promise<AdminComment[]> {
|
||||
const parent = alias(comments, "parent");
|
||||
const rows = await db
|
||||
.select({
|
||||
comment: comments,
|
||||
postTitle: posts.title,
|
||||
postSlug: posts.slug,
|
||||
parentAuthorName: parent.authorName,
|
||||
})
|
||||
.from(comments)
|
||||
.innerJoin(posts, eq(posts.id, comments.postId))
|
||||
.leftJoin(parent, eq(parent.id, comments.parentId))
|
||||
.orderBy(desc(comments.createdAt), desc(comments.id));
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r.comment,
|
||||
postTitle: r.postTitle,
|
||||
postSlug: r.postSlug,
|
||||
parentAuthorName: r.parentAuthorName ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function setCommentStatus(
|
||||
id: number,
|
||||
status: CommentStatus,
|
||||
): Promise<Comment | null> {
|
||||
const [comment] = await db
|
||||
.update(comments)
|
||||
.set({ status })
|
||||
.where(eq(comments.id, id))
|
||||
.returning();
|
||||
return comment ?? null;
|
||||
}
|
||||
|
||||
/** Deletes the comment and, via FK cascade, every reply beneath it. */
|
||||
export async function deleteComment(id: number): Promise<void> {
|
||||
await db.delete(comments).where(eq(comments.id, id));
|
||||
}
|
||||
|
||||
export async function countCommentsByStatus(): Promise<{
|
||||
pending: number;
|
||||
approved: number;
|
||||
}> {
|
||||
const rows = await db
|
||||
.select({ status: comments.status, value: count() })
|
||||
.from(comments)
|
||||
.groupBy(comments.status);
|
||||
const result = { pending: 0, approved: 0 };
|
||||
for (const row of rows) result[row.status] = row.value;
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,7 +1,17 @@
|
|||
import { asc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { fontEnum, navItems, pages, postTags, posts, settings, tags, themeEnum } from "@/db/schema";
|
||||
import {
|
||||
comments,
|
||||
fontEnum,
|
||||
navItems,
|
||||
pages,
|
||||
postTags,
|
||||
posts,
|
||||
settings,
|
||||
tags,
|
||||
themeEnum,
|
||||
} from "@/db/schema";
|
||||
import { sanitizeHtml } from "@/lib/html";
|
||||
import { isValidLinkUrl } from "@/lib/validation";
|
||||
import { getSettings } from "./settings";
|
||||
|
|
@ -12,7 +22,9 @@ import { getSettings } from "./settings";
|
|||
* columns are regenerated on import).
|
||||
*/
|
||||
export const SITE_EXPORT_FORMAT = "yap-blog-export";
|
||||
export const SITE_EXPORT_VERSION = 1;
|
||||
// v1: posts/pages/tags/nav/settings. v2 adds comments. v1 files still
|
||||
// import fine (they simply carry no comments).
|
||||
export const SITE_EXPORT_VERSION = 2;
|
||||
|
||||
const slugValue = z.string().trim().min(1).max(120);
|
||||
const statusValue = z.enum(["draft", "published"]);
|
||||
|
|
@ -22,7 +34,7 @@ const timestampValue = z.coerce.date();
|
|||
export const siteExportSchema = z
|
||||
.object({
|
||||
format: z.literal(SITE_EXPORT_FORMAT),
|
||||
version: z.literal(SITE_EXPORT_VERSION),
|
||||
version: z.union([z.literal(1), z.literal(2)]),
|
||||
exportedAt: timestampValue,
|
||||
settings: z.object({
|
||||
siteTitle: z.string().trim().min(1).max(120),
|
||||
|
|
@ -77,6 +89,23 @@ export const siteExportSchema = z
|
|||
}),
|
||||
)
|
||||
.max(50_000),
|
||||
comments: z
|
||||
.array(
|
||||
z.object({
|
||||
/** Id local to this file (the exporter's DB id); remapped on import. */
|
||||
id: z.number().int().positive(),
|
||||
postSlug: slugValue,
|
||||
parentId: z.number().int().positive().nullable(),
|
||||
authorName: z.string().trim().min(1).max(120),
|
||||
authorEmail: z.string().trim().min(1).max(254),
|
||||
emailPublic: z.boolean(),
|
||||
body: z.string().min(1).max(5_000),
|
||||
status: z.enum(["pending", "approved"]),
|
||||
createdAt: timestampValue,
|
||||
}),
|
||||
)
|
||||
.max(100_000)
|
||||
.default([]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const duplicate = (values: string[]): string | undefined => {
|
||||
|
|
@ -150,6 +179,55 @@ export const siteExportSchema = z
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
const postSlugs = new Set(data.posts.map((p) => p.slug));
|
||||
const commentById = new Map(data.comments.map((c) => [c.id, c]));
|
||||
if (commentById.size !== data.comments.length) {
|
||||
ctx.addIssue({ code: "custom", message: "Duplicate comment ids in the export." });
|
||||
return; // parent-chain checks below assume unique ids
|
||||
}
|
||||
for (const comment of data.comments) {
|
||||
if (!postSlugs.has(comment.postSlug)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Comment ${comment.id} references unknown post “${comment.postSlug}”.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (comment.parentId === null) continue;
|
||||
const parent = commentById.get(comment.parentId);
|
||||
if (!parent) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Comment ${comment.id} replies to unknown comment ${comment.parentId}.`,
|
||||
});
|
||||
} else if (parent.postSlug !== comment.postSlug) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Comment ${comment.id} replies to a comment on a different post.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Reject parent cycles (impossible via the app, possible in a crafted
|
||||
// file) — the importer's parents-first insertion would never terminate.
|
||||
const commentDepthCache = new Map<number, boolean>();
|
||||
for (const comment of data.comments) {
|
||||
const seen = new Set<number>();
|
||||
let current: typeof comment | undefined = comment;
|
||||
while (current && current.parentId !== null) {
|
||||
if (commentDepthCache.get(current.id)) break; // known-good chain
|
||||
if (seen.has(current.id)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Comment ${comment.id} is part of a reply cycle.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seen.add(current.id);
|
||||
current = commentById.get(current.parentId);
|
||||
}
|
||||
for (const id of seen) commentDepthCache.set(id, true);
|
||||
}
|
||||
});
|
||||
|
||||
export type SiteExport = z.infer<typeof siteExportSchema>;
|
||||
|
|
@ -176,7 +254,8 @@ export function parseSiteExportJson(
|
|||
|
||||
/** Snapshot of everything the admin can edit: settings, nav, tags, pages, posts. */
|
||||
export async function buildSiteExport(): Promise<SiteExport> {
|
||||
const [settingsRow, navRows, tagRows, pageRows, postRows, postTagRows] = await Promise.all([
|
||||
const [settingsRow, navRows, tagRows, pageRows, postRows, postTagRows, commentRows] =
|
||||
await Promise.all([
|
||||
getSettings(),
|
||||
db
|
||||
.select({ item: navItems, pageSlug: pages.slug })
|
||||
|
|
@ -191,6 +270,11 @@ export async function buildSiteExport(): Promise<SiteExport> {
|
|||
.from(postTags)
|
||||
.innerJoin(tags, eq(tags.id, postTags.tagId))
|
||||
.orderBy(asc(tags.slug)),
|
||||
db
|
||||
.select({ comment: comments, postSlug: posts.slug })
|
||||
.from(comments)
|
||||
.innerJoin(posts, eq(posts.id, comments.postId))
|
||||
.orderBy(asc(comments.id)),
|
||||
]);
|
||||
|
||||
const tagSlugsByPost = new Map<number, string[]>();
|
||||
|
|
@ -243,6 +327,17 @@ export async function buildSiteExport(): Promise<SiteExport> {
|
|||
publishedAt: p.publishedAt,
|
||||
tagSlugs: tagSlugsByPost.get(p.id) ?? [],
|
||||
})),
|
||||
comments: commentRows.map(({ comment, postSlug }) => ({
|
||||
id: comment.id,
|
||||
postSlug,
|
||||
parentId: comment.parentId,
|
||||
authorName: comment.authorName,
|
||||
authorEmail: comment.authorEmail,
|
||||
emailPublic: comment.emailPublic,
|
||||
body: comment.body,
|
||||
status: comment.status,
|
||||
createdAt: comment.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -260,8 +355,9 @@ function chunk<T>(items: T[], size: number): T[][] {
|
|||
*/
|
||||
export async function importSiteExport(data: SiteExport): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
// Deleting posts/pages/tags cascades post_tags and page nav items away;
|
||||
// the old settings row's home references become NULL via ON DELETE SET NULL.
|
||||
// Deleting posts/pages/tags cascades post_tags, comments, and page nav
|
||||
// items away; the old settings row's home references become NULL via
|
||||
// ON DELETE SET NULL.
|
||||
await tx.delete(navItems);
|
||||
await tx.delete(posts);
|
||||
await tx.delete(pages);
|
||||
|
|
@ -296,6 +392,7 @@ export async function importSiteExport(data: SiteExport): Promise<void> {
|
|||
for (const row of inserted) pageIdBySlug.set(row.slug, row.id);
|
||||
}
|
||||
|
||||
const postIdBySlug = new Map<string, number>();
|
||||
const links: Array<{ postId: number; tagId: number }> = [];
|
||||
for (const batch of chunk(data.posts, 1000)) {
|
||||
const inserted = await tx
|
||||
|
|
@ -315,9 +412,9 @@ export async function importSiteExport(data: SiteExport): Promise<void> {
|
|||
})),
|
||||
)
|
||||
.returning({ id: posts.id, slug: posts.slug });
|
||||
const idBySlug = new Map(inserted.map((row) => [row.slug, row.id]));
|
||||
for (const row of inserted) postIdBySlug.set(row.slug, row.id);
|
||||
for (const post of batch) {
|
||||
const postId = idBySlug.get(post.slug);
|
||||
const postId = postIdBySlug.get(post.slug);
|
||||
if (postId === undefined) continue;
|
||||
for (const tagSlug of post.tagSlugs) {
|
||||
const tagId = tagIdBySlug.get(tagSlug);
|
||||
|
|
@ -329,6 +426,43 @@ export async function importSiteExport(data: SiteExport): Promise<void> {
|
|||
await tx.insert(postTags).values(batch);
|
||||
}
|
||||
|
||||
// Comments insert parents-first so replies can point at fresh ids;
|
||||
// the schema validation above guarantees the parent graph is acyclic,
|
||||
// so every pass makes progress. Comment bodies are plain text (rendered
|
||||
// escaped), so no HTML sanitizing is needed.
|
||||
const commentIdByLocal = new Map<number, number>();
|
||||
let pendingComments = data.comments;
|
||||
while (pendingComments.length > 0) {
|
||||
const ready = pendingComments.filter(
|
||||
(c) => c.parentId === null || commentIdByLocal.has(c.parentId),
|
||||
);
|
||||
if (ready.length === 0) {
|
||||
// Unreachable after schema validation; guards the loop all the same.
|
||||
throw new Error("Comment import stalled on an unresolvable parent reference.");
|
||||
}
|
||||
for (const batch of chunk(ready, 1000)) {
|
||||
const inserted = await tx
|
||||
.insert(comments)
|
||||
.values(
|
||||
batch.map((c) => ({
|
||||
postId: postIdBySlug.get(c.postSlug)!,
|
||||
parentId: c.parentId === null ? null : commentIdByLocal.get(c.parentId)!,
|
||||
authorName: c.authorName,
|
||||
authorEmail: c.authorEmail,
|
||||
emailPublic: c.emailPublic,
|
||||
body: c.body,
|
||||
status: c.status,
|
||||
createdAt: c.createdAt,
|
||||
})),
|
||||
)
|
||||
// Postgres returns multi-row INSERT ... RETURNING rows in values
|
||||
// order, which is what lets us zip local ids to new ids here.
|
||||
.returning({ id: comments.id });
|
||||
batch.forEach((c, i) => commentIdByLocal.set(c.id, inserted[i].id));
|
||||
}
|
||||
pendingComments = pendingComments.filter((c) => !commentIdByLocal.has(c.id));
|
||||
}
|
||||
|
||||
if (data.navItems.length > 0) {
|
||||
await tx.insert(navItems).values(
|
||||
data.navItems.map((item, index) => ({
|
||||
|
|
|
|||
|
|
@ -54,6 +54,29 @@ export const pageFormSchema = z.object({
|
|||
});
|
||||
export type PageFormData = z.infer<typeof pageFormSchema>;
|
||||
|
||||
export const commentFormSchema = z.object({
|
||||
postId: z.coerce.number().int().positive(),
|
||||
parentId: z.preprocess(
|
||||
(v) => (v === "" || v === null || v === undefined ? null : v),
|
||||
z.coerce.number().int().positive().nullable(),
|
||||
),
|
||||
authorName: z.string().trim().min(1, "Name is required.").max(120, "Name is too long."),
|
||||
authorEmail: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Email is required.")
|
||||
.max(254, "Email is too long.")
|
||||
.pipe(z.email("Enter a valid email address.")),
|
||||
// Checkbox: present ("on") when ticked, absent otherwise.
|
||||
emailPublic: z.preprocess((v) => v === "on" || v === "true" || v === true, z.boolean()),
|
||||
body: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Comment cannot be empty.")
|
||||
.max(5000, "Comments are limited to 5000 characters."),
|
||||
});
|
||||
export type CommentFormData = z.infer<typeof commentFormSchema>;
|
||||
|
||||
export const loginFormSchema = z.object({
|
||||
username: z.string().trim().min(1, "Username is required.").max(120),
|
||||
password: z.string().min(1, "Password is required.").max(200),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ import { db } from "@/db";
|
|||
/** Wipes every table between tests; identities restart at 1. */
|
||||
export async function resetDb(): Promise<void> {
|
||||
await db.execute(
|
||||
sql`TRUNCATE users, sessions, posts, tags, post_tags, pages, nav_items, settings RESTART IDENTITY CASCADE`,
|
||||
sql`TRUNCATE users, sessions, posts, tags, post_tags, pages, nav_items, settings, comments RESTART IDENTITY CASCADE`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
163
tests/integration/comments.test.ts
Normal file
163
tests/integration/comments.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CommentInput,
|
||||
countCommentsByStatus,
|
||||
createComment,
|
||||
deleteComment,
|
||||
listApprovedComments,
|
||||
listCommentsForAdmin,
|
||||
setCommentStatus,
|
||||
} from "@/lib/services/comments";
|
||||
import { createPost, type PostInput } from "@/lib/services/posts";
|
||||
import { resetDb } from "../helpers/db";
|
||||
|
||||
const postInput = (overrides: Partial<PostInput> = {}): PostInput => ({
|
||||
title: `Post ${Math.random().toString(36).slice(2, 8)}`,
|
||||
slug: "",
|
||||
body: "body",
|
||||
authorName: "Tester",
|
||||
featuredImageUrl: null,
|
||||
featuredImageAlt: null,
|
||||
status: "published",
|
||||
tagIds: [],
|
||||
newTagNames: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const commentInput = (
|
||||
postId: number,
|
||||
overrides: Partial<CommentInput> = {},
|
||||
): CommentInput => ({
|
||||
postId,
|
||||
parentId: null,
|
||||
authorName: "Alice",
|
||||
authorEmail: "alice@example.com",
|
||||
emailPublic: false,
|
||||
body: "Nice post!",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(resetDb);
|
||||
|
||||
describe("comment moderation", () => {
|
||||
it("stores new comments as pending and hides them publicly", async () => {
|
||||
const post = await createPost(postInput());
|
||||
const result = await createComment(commentInput(post.id));
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
expect(await listApprovedComments(post.id)).toEqual([]);
|
||||
const counts = await countCommentsByStatus();
|
||||
expect(counts).toEqual({ pending: 1, approved: 0 });
|
||||
});
|
||||
|
||||
it("shows comments publicly only after approval", async () => {
|
||||
const post = await createPost(postInput());
|
||||
const created = await createComment(commentInput(post.id));
|
||||
if (!created.ok) throw new Error("expected ok");
|
||||
|
||||
await setCommentStatus(created.comment.id, "approved");
|
||||
const visible = await listApprovedComments(post.id);
|
||||
expect(visible).toHaveLength(1);
|
||||
expect(visible[0].authorName).toBe("Alice");
|
||||
});
|
||||
|
||||
it("rejects comments on drafts and unknown posts", async () => {
|
||||
const draft = await createPost(postInput({ status: "draft" }));
|
||||
expect((await createComment(commentInput(draft.id))).ok).toBe(false);
|
||||
expect((await createComment(commentInput(999_999))).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("hides private emails and shows public ones", async () => {
|
||||
const post = await createPost(postInput());
|
||||
const priv = await createComment(commentInput(post.id));
|
||||
const pub = await createComment(
|
||||
commentInput(post.id, { authorName: "Bob", authorEmail: "bob@example.com", emailPublic: true }),
|
||||
);
|
||||
if (!priv.ok || !pub.ok) throw new Error("expected ok");
|
||||
await setCommentStatus(priv.comment.id, "approved");
|
||||
await setCommentStatus(pub.comment.id, "approved");
|
||||
|
||||
const visible = await listApprovedComments(post.id);
|
||||
expect(visible.find((c) => c.authorName === "Alice")?.authorEmail).toBeNull();
|
||||
expect(visible.find((c) => c.authorName === "Bob")?.authorEmail).toBe("bob@example.com");
|
||||
|
||||
// The admin list always carries the address.
|
||||
const admin = await listCommentsForAdmin();
|
||||
expect(admin.map((c) => c.authorEmail).sort()).toEqual([
|
||||
"alice@example.com",
|
||||
"bob@example.com",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("threading", () => {
|
||||
it("only allows replies to approved comments on the same post", async () => {
|
||||
const post = await createPost(postInput());
|
||||
const other = await createPost(postInput());
|
||||
const parent = await createComment(commentInput(post.id));
|
||||
if (!parent.ok) throw new Error("expected ok");
|
||||
|
||||
// Parent still pending → no replies.
|
||||
const early = await createComment(
|
||||
commentInput(post.id, { parentId: parent.comment.id, body: "too early" }),
|
||||
);
|
||||
expect(early.ok).toBe(false);
|
||||
|
||||
await setCommentStatus(parent.comment.id, "approved");
|
||||
const reply = await createComment(
|
||||
commentInput(post.id, { parentId: parent.comment.id, authorName: "Bob", body: "A reply" }),
|
||||
);
|
||||
expect(reply.ok).toBe(true);
|
||||
|
||||
// Approved parent, but wrong post.
|
||||
const crossPost = await createComment(
|
||||
commentInput(other.id, { parentId: parent.comment.id }),
|
||||
);
|
||||
expect(crossPost.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("builds a nested tree and hides replies under pending parents", async () => {
|
||||
const post = await createPost(postInput());
|
||||
const a = await createComment(commentInput(post.id, { body: "root A" }));
|
||||
if (!a.ok) throw new Error();
|
||||
await setCommentStatus(a.comment.id, "approved");
|
||||
const b = await createComment(
|
||||
commentInput(post.id, { parentId: a.comment.id, authorName: "Bob", body: "reply B" }),
|
||||
);
|
||||
if (!b.ok) throw new Error();
|
||||
await setCommentStatus(b.comment.id, "approved");
|
||||
const c = await createComment(
|
||||
commentInput(post.id, { parentId: b.comment.id, authorName: "Cleo", body: "reply C" }),
|
||||
);
|
||||
if (!c.ok) throw new Error();
|
||||
|
||||
// C not yet approved → depth-2 tree shows A > B only.
|
||||
let tree = await listApprovedComments(post.id);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].replies).toHaveLength(1);
|
||||
expect(tree[0].replies[0].replies).toHaveLength(0);
|
||||
|
||||
// Approve C → full chain visible.
|
||||
await setCommentStatus(c.comment.id, "approved");
|
||||
tree = await listApprovedComments(post.id);
|
||||
expect(tree[0].replies[0].replies[0].body).toBe("reply C");
|
||||
|
||||
// Unapprove B → C disappears with it (thread context preserved).
|
||||
await setCommentStatus(b.comment.id, "pending");
|
||||
tree = await listApprovedComments(post.id);
|
||||
expect(tree[0].replies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deleting a comment removes its whole subtree", async () => {
|
||||
const post = await createPost(postInput());
|
||||
const a = await createComment(commentInput(post.id));
|
||||
if (!a.ok) throw new Error();
|
||||
await setCommentStatus(a.comment.id, "approved");
|
||||
const b = await createComment(commentInput(post.id, { parentId: a.comment.id }));
|
||||
if (!b.ok) throw new Error();
|
||||
|
||||
await deleteComment(a.comment.id);
|
||||
const counts = await countCommentsByStatus();
|
||||
expect(counts).toEqual({ pending: 0, approved: 0 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { db } from "@/db";
|
||||
import { navItems, posts, tags } from "@/db/schema";
|
||||
import {
|
||||
createComment,
|
||||
listApprovedComments,
|
||||
setCommentStatus,
|
||||
} from "@/lib/services/comments";
|
||||
import {
|
||||
buildSiteExport,
|
||||
importSiteExport,
|
||||
|
|
@ -96,6 +101,59 @@ describe("export/import round trip", () => {
|
|||
expect(nav[1].url).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("round-trips threaded comments with remapped ids and statuses", async () => {
|
||||
const post = await createPost(input({ title: "Discussed" }));
|
||||
const parent = await createComment({
|
||||
postId: post.id,
|
||||
parentId: null,
|
||||
authorName: "Alice",
|
||||
authorEmail: "alice@example.com",
|
||||
emailPublic: true,
|
||||
body: "First!",
|
||||
});
|
||||
if (!parent.ok) throw new Error();
|
||||
await setCommentStatus(parent.comment.id, "approved");
|
||||
const reply = await createComment({
|
||||
postId: post.id,
|
||||
parentId: parent.comment.id,
|
||||
authorName: "Bob",
|
||||
authorEmail: "bob@example.com",
|
||||
emailPublic: false,
|
||||
body: "Replying to Alice",
|
||||
});
|
||||
if (!reply.ok) throw new Error();
|
||||
await setCommentStatus(reply.comment.id, "approved");
|
||||
await createComment({
|
||||
postId: post.id,
|
||||
parentId: parent.comment.id,
|
||||
authorName: "Spammer",
|
||||
authorEmail: "spam@example.com",
|
||||
emailPublic: false,
|
||||
body: "pending reply",
|
||||
});
|
||||
|
||||
const snapshot = await exportViaJson();
|
||||
expect(snapshot.comments).toHaveLength(3);
|
||||
await importSiteExport(snapshot);
|
||||
|
||||
const restored = await getPublishedPostBySlug(post.slug);
|
||||
const tree = await listApprovedComments(restored!.id);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].authorEmail).toBe("alice@example.com"); // opted-in email survives
|
||||
expect(tree[0].replies).toHaveLength(1); // pending reply stays hidden
|
||||
expect(tree[0].replies[0].body).toBe("Replying to Alice");
|
||||
expect(tree[0].replies[0].authorEmail).toBeNull(); // private stays private
|
||||
});
|
||||
|
||||
it("accepts version-1 exports (no comments field)", async () => {
|
||||
const snapshot = await exportViaJson();
|
||||
const v1 = { ...JSON.parse(JSON.stringify(snapshot)), version: 1 };
|
||||
delete v1.comments;
|
||||
const parsed = parseSiteExportJson(JSON.stringify(v1));
|
||||
expect(parsed).not.toHaveProperty("error");
|
||||
if ("data" in parsed) expect(parsed.data.comments).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps draft posts and unpublished state intact", async () => {
|
||||
await createPost(input({ title: "Draft", status: "draft" }));
|
||||
const snapshot = await exportViaJson();
|
||||
|
|
@ -163,6 +221,39 @@ describe("parseSiteExportJson", () => {
|
|||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("rejects reply cycles in a crafted file", async () => {
|
||||
const snapshot = await exportViaJson();
|
||||
snapshot.posts.push({
|
||||
title: "P",
|
||||
slug: "p",
|
||||
body: "",
|
||||
authorName: "A",
|
||||
featuredImageUrl: null,
|
||||
featuredImageAlt: null,
|
||||
status: "published",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
publishedAt: new Date(),
|
||||
tagSlugs: [],
|
||||
});
|
||||
const commentBase = {
|
||||
postSlug: "p",
|
||||
authorName: "X",
|
||||
authorEmail: "x@example.com",
|
||||
emailPublic: false,
|
||||
body: "hi",
|
||||
status: "approved" as const,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
snapshot.comments = [
|
||||
{ id: 1, parentId: 2, ...commentBase },
|
||||
{ id: 2, parentId: 1, ...commentBase },
|
||||
];
|
||||
const result = parseSiteExportJson(JSON.stringify(snapshot));
|
||||
expect(result).toHaveProperty("error");
|
||||
if ("error" in result) expect(result.error).toContain("cycle");
|
||||
});
|
||||
|
||||
it("rejects a home tag that is not part of the export", async () => {
|
||||
const snapshot = await exportViaJson();
|
||||
snapshot.settings.homeMode = "tag";
|
||||
|
|
|
|||
Loading…
Reference in a new issue