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>
195 lines
6.7 KiB
TypeScript
195 lines
6.7 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import {
|
|
type AnyPgColumn,
|
|
boolean,
|
|
check,
|
|
index,
|
|
integer,
|
|
pgEnum,
|
|
pgTable,
|
|
primaryKey,
|
|
text,
|
|
timestamp,
|
|
} 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",
|
|
"solarized-light",
|
|
"dracula",
|
|
"nord",
|
|
"gruvbox-dark",
|
|
"mono",
|
|
"mono-dark",
|
|
"catppuccin-mocha",
|
|
"catppuccin-latte",
|
|
"tokyo-night",
|
|
"one-dark",
|
|
"rose-pine",
|
|
"everforest-dark",
|
|
"monokai",
|
|
"github-light",
|
|
]);
|
|
export const fontEnum = pgEnum("font", [
|
|
"geist",
|
|
"inter",
|
|
"lora",
|
|
"merriweather",
|
|
"jetbrains-mono",
|
|
"source-serif",
|
|
"eb-garamond",
|
|
"playfair-display",
|
|
"open-sans",
|
|
"work-sans",
|
|
"atkinson-hyperlegible",
|
|
"space-grotesk",
|
|
]);
|
|
|
|
export const users = pgTable("users", {
|
|
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
username: text("username").notNull().unique(),
|
|
passwordHash: text("password_hash").notNull(),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export const sessions = pgTable("sessions", {
|
|
// sha-256 hex digest of the bearer token; the raw token never touches the DB.
|
|
id: text("id").primaryKey(),
|
|
userId: integer("user_id")
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: "cascade" }),
|
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export const posts = pgTable(
|
|
"posts",
|
|
{
|
|
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
title: text("title").notNull(),
|
|
slug: text("slug").notNull().unique(),
|
|
body: text("body").notNull().default(""),
|
|
authorName: text("author_name").notNull(),
|
|
featuredImageUrl: text("featured_image_url"),
|
|
featuredImageAlt: text("featured_image_alt"),
|
|
status: contentStatusEnum("status").notNull().default("draft"),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
publishedAt: timestamp("published_at", { withTimezone: true }),
|
|
},
|
|
(t) => [index("posts_status_published_at_idx").on(t.status, t.publishedAt)],
|
|
);
|
|
|
|
export const tags = pgTable("tags", {
|
|
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
name: text("name").notNull().unique(),
|
|
slug: text("slug").notNull().unique(),
|
|
});
|
|
|
|
export const postTags = pgTable(
|
|
"post_tags",
|
|
{
|
|
postId: integer("post_id")
|
|
.notNull()
|
|
.references(() => posts.id, { onDelete: "cascade" }),
|
|
tagId: integer("tag_id")
|
|
.notNull()
|
|
.references(() => tags.id, { onDelete: "cascade" }),
|
|
},
|
|
(t) => [
|
|
primaryKey({ columns: [t.postId, t.tagId] }),
|
|
index("post_tags_tag_id_idx").on(t.tagId),
|
|
],
|
|
);
|
|
|
|
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(),
|
|
slug: text("slug").notNull().unique(),
|
|
body: text("body").notNull().default(""),
|
|
status: contentStatusEnum("status").notNull().default("draft"),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export const settings = pgTable(
|
|
"settings",
|
|
{
|
|
// Single-row table; the row always has id = 1 (enforced below).
|
|
id: integer("id").primaryKey(),
|
|
siteTitle: text("site_title").notNull().default("My Blog"),
|
|
headerText: text("header_text").notNull().default(""),
|
|
footerText: text("footer_text").notNull().default(""),
|
|
postsPerPage: integer("posts_per_page").notNull().default(10),
|
|
excerptWords: integer("excerpt_words").notNull().default(40),
|
|
homeMode: homeModeEnum("home_mode").notNull().default("posts"),
|
|
homeTagId: integer("home_tag_id").references(() => tags.id, { onDelete: "set null" }),
|
|
homePageId: integer("home_page_id").references(() => pages.id, { onDelete: "set null" }),
|
|
theme: themeEnum("theme").notNull().default("solarized-dark"),
|
|
font: fontEnum("font").notNull().default("geist"),
|
|
},
|
|
(t) => [
|
|
check("settings_single_row_check", sql`${t.id} = 1`),
|
|
check("settings_posts_per_page_check", sql`${t.postsPerPage} BETWEEN 1 AND 50`),
|
|
check("settings_excerpt_words_check", sql`${t.excerptWords} BETWEEN 5 AND 200`),
|
|
],
|
|
);
|
|
|
|
export const navItems = pgTable(
|
|
"nav_items",
|
|
{
|
|
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
label: text("label").notNull(),
|
|
// A nav item points at exactly one target: an external/internal URL...
|
|
url: text("url"),
|
|
// ...or a static page (removed automatically when the page is deleted).
|
|
pageId: integer("page_id").references(() => pages.id, { onDelete: "cascade" }),
|
|
sortOrder: integer("sort_order").notNull().default(0),
|
|
},
|
|
(t) => [check("nav_items_target_check", sql`(${t.url} IS NULL) <> (${t.pageId} IS NULL)`)],
|
|
);
|
|
|
|
export type User = typeof users.$inferSelect;
|
|
export type Session = typeof sessions.$inferSelect;
|
|
export type Post = typeof posts.$inferSelect;
|
|
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"];
|