还没有发布文章
本地数据库初始化后,最新文章会直接出现在这里。
diff --git a/scripts/reconcile-d1-schema.mjs b/scripts/reconcile-d1-schema.mjs
index 0122e3b..7449187 100644
--- a/scripts/reconcile-d1-schema.mjs
+++ b/scripts/reconcile-d1-schema.mjs
@@ -103,6 +103,8 @@ const POST_COLUMNS = [
{ name: "series_slug", sql: "series_slug TEXT" },
{ name: "series_order", sql: "series_order INTEGER NOT NULL DEFAULT 0" },
{ name: "category", sql: "category TEXT DEFAULT ''" },
+ { name: "card_width", sql: "card_width INTEGER NOT NULL DEFAULT 100" },
+ { name: "card_height", sql: "card_height INTEGER NOT NULL DEFAULT 220" },
];
const options = parseArgs(process.argv.slice(2));
diff --git a/server/src/db/schema-pg.ts b/server/src/db/schema-pg.ts
index 627c71e..6b5c059 100644
--- a/server/src/db/schema-pg.ts
+++ b/server/src/db/schema-pg.ts
@@ -15,6 +15,8 @@ export const pgPosts = pgTable("posts", {
excerpt: text("excerpt").default(""),
coverColor: text("cover_color").default("from-gray-500/20 to-gray-600/20"),
coverImage: text("cover_image").default(""),
+ cardWidth: integer("card_width").notNull().default(100),
+ cardHeight: integer("card_height").notNull().default(220),
published: boolean("published").notNull().default(true),
listed: boolean("listed").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -87,6 +89,22 @@ export const pgComments = pgTable(
})
);
+/* ── 留言板表 ──────────────────────────────── */
+export const pgGuestbookMessages = pgTable(
+ "guestbook_messages",
+ {
+ id: serial("id").primaryKey(),
+ authorName: text("author_name").notNull(),
+ authorEmail: text("author_email").notNull().default(""),
+ content: text("content").notNull(),
+ approved: boolean("approved").notNull().default(false),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ approvedIdx: index("pg_guestbook_messages_approved_idx").on(table.approved, table.id),
+ })
+);
+
/* ── 友链表 ──────────────────────────────── */
export const pgFriendLinks = pgTable(
"friend_links",
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts
index a69baac..4c8a744 100644
--- a/server/src/db/schema.ts
+++ b/server/src/db/schema.ts
@@ -10,6 +10,8 @@ export const posts = sqliteTable("posts", {
excerpt: text("excerpt").default(""),
coverColor: text("cover_color").default("from-gray-500/20 to-gray-600/20"),
coverImage: text("cover_image").default(""),
+ cardWidth: integer("card_width").notNull().default(100),
+ cardHeight: integer("card_height").notNull().default(220),
published: integer("published", { mode: "boolean" }).notNull().default(true),
listed: integer("listed", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
@@ -86,6 +88,24 @@ export const comments = sqliteTable(
})
);
+/* ── 留言板表 ──────────────────────────────── */
+export const guestbookMessages = sqliteTable(
+ "guestbook_messages",
+ {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ authorName: text("author_name").notNull(),
+ authorEmail: text("author_email").notNull().default(""),
+ content: text("content").notNull(),
+ approved: integer("approved", { mode: "boolean" }).notNull().default(false),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`(datetime('now'))`),
+ },
+ (table) => ({
+ approvedIdx: index("guestbook_messages_approved_idx").on(table.approved, table.id),
+ })
+);
+
/* ── 友链表 ──────────────────────────────── */
export const friendLinks = sqliteTable(
"friend_links",
diff --git a/server/src/index.ts b/server/src/index.ts
index 114d2ee..087a204 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -212,6 +212,22 @@ function publicFriendLink(link: FriendLink) {
};
}
+function publicGuestbookMessage(message: {
+ id: number;
+ authorName: string;
+ content: string;
+ approved: boolean;
+ createdAt: string;
+}) {
+ return {
+ id: message.id,
+ authorName: message.authorName,
+ content: message.content,
+ approved: message.approved,
+ createdAt: message.createdAt,
+ };
+}
+
async function readObjectBody(body: ReadableStream): Promise {
const reader = body.getReader();
const chunks: Uint8Array[] = [];
@@ -593,6 +609,60 @@ app.post("/api/posts/:slug/comments", async (c) => {
}
});
+// 获取留言板留言(仅已审核,不暴露邮箱)
+app.get("/api/guestbook", async (c) => {
+ const beforeId = Number.parseInt(c.req.query("before") || "", 10);
+ const pageSize = 20;
+ const messages = await c.get("db").getApprovedGuestbookMessages(
+ pageSize + 1,
+ Number.isInteger(beforeId) && beforeId > 0 ? beforeId : undefined,
+ );
+ const hasMore = messages.length > pageSize;
+ const items = messages.slice(0, pageSize).map(publicGuestbookMessage);
+ return c.json({
+ items,
+ nextCursor: hasMore ? items.at(-1)?.id ?? null : null,
+ });
+});
+
+// 提交留言板留言(公开接口,需审核后才显示)
+app.post("/api/guestbook", async (c) => {
+ const ip = getClientIp(c);
+ if (isGuestbookRateLimited(ip, Date.now())) {
+ return c.json({ error: "提交过于频繁,请稍后再试" }, 429);
+ }
+
+ const parsed = await readJson<{
+ authorName: string;
+ authorEmail?: string;
+ content: string;
+ _hp?: string;
+ }>(c);
+ if (!parsed.ok) return parsed.response;
+ const body = parsed.body;
+
+ if (body._hp) return c.json({ success: true, message: "留言已提交,等待审核" });
+
+ const authorName = normalizeText(body.authorName, 50);
+ const authorEmail = normalizeOptionalEmail(body.authorEmail);
+ const content = normalizeText(body.content, 2000);
+
+ if (!authorName || !content) {
+ return c.json({ error: "昵称和留言内容不能为空" }, 400);
+ }
+ if (body.authorEmail && !authorEmail) {
+ return c.json({ error: "邮箱格式无效" }, 400);
+ }
+
+ const message = await c.get("db").addGuestbookMessage({
+ authorName,
+ authorEmail,
+ content,
+ });
+ await triggerWebhook(c, "guestbook_message_submitted", { id: message.id, authorName });
+ return c.json({ success: true, message: "留言已提交,等待审核" }, 201);
+});
+
// 获取文章表情反应统计
app.get("/api/posts/:slug/reactions", async (c) => {
const slug = c.req.param("slug");
@@ -864,6 +934,10 @@ const friendLinkAttempts = new Map();
+const GUESTBOOK_RATE_LIMIT = 5;
+const GUESTBOOK_RATE_WINDOW = 60 * 60 * 1000;
+const GUESTBOOK_RATE_MAX_KEYS = 1000;
function getClientIp(c: any): string {
const cfIp = c.req.header("CF-Connecting-IP")?.trim();
@@ -907,6 +981,31 @@ function isFriendLinkRateLimited(ip: string, now: number): boolean {
return false;
}
+function pruneGuestbookAttempts(now: number) {
+ for (const [ip, record] of guestbookAttempts.entries()) {
+ if ((now - record.firstAttempt) >= GUESTBOOK_RATE_WINDOW) guestbookAttempts.delete(ip);
+ }
+ while (guestbookAttempts.size > GUESTBOOK_RATE_MAX_KEYS) {
+ const oldest = guestbookAttempts.keys().next().value;
+ if (!oldest) break;
+ guestbookAttempts.delete(oldest);
+ }
+}
+
+function isGuestbookRateLimited(ip: string, now: number): boolean {
+ pruneGuestbookAttempts(now);
+ const record = guestbookAttempts.get(ip);
+ if (record && record.count >= GUESTBOOK_RATE_LIMIT && (now - record.firstAttempt) < GUESTBOOK_RATE_WINDOW) {
+ return true;
+ }
+ if (!record || (now - record.firstAttempt) >= GUESTBOOK_RATE_WINDOW) {
+ guestbookAttempts.set(ip, { count: 1, firstAttempt: now });
+ } else {
+ record.count++;
+ }
+ return false;
+}
+
function getMissingAuthSecrets(env: Partial) {
return [
["ADMIN_PASSWORD", env.ADMIN_PASSWORD],
@@ -1092,6 +1191,42 @@ app.delete("/api/admin/comments/:id", async (c) => {
return c.json({ success: true });
});
+// 获取所有留言(管理后台)
+app.get("/api/admin/guestbook", async (c) => {
+ const db = c.get("db");
+ const beforeId = Number.parseInt(c.req.query("before") || "", 10);
+ const pageSize = 50;
+ const messages = await db.getAllGuestbookMessages(
+ pageSize + 1,
+ Number.isInteger(beforeId) && beforeId > 0 ? beforeId : undefined,
+ );
+ const items = messages.slice(0, pageSize);
+ return c.json({
+ items,
+ nextCursor: messages.length > pageSize ? items.at(-1)?.id ?? null : null,
+ });
+});
+
+// 审核留言
+app.post("/api/admin/guestbook/:id/approve", async (c) => {
+ const id = parseInt(c.req.param("id"), 10);
+ if (isNaN(id)) return c.json({ error: "无效 ID" }, 400);
+ const db = c.get("db");
+ const ok = await db.approveGuestbookMessage(id);
+ if (!ok) return c.json({ error: "留言不存在" }, 404);
+ return c.json({ success: true });
+});
+
+// 删除留言
+app.delete("/api/admin/guestbook/:id", async (c) => {
+ const id = parseInt(c.req.param("id"), 10);
+ if (isNaN(id)) return c.json({ error: "无效 ID" }, 400);
+ const db = c.get("db");
+ const ok = await db.deleteGuestbookMessage(id);
+ if (!ok) return c.json({ error: "留言不存在" }, 404);
+ return c.json({ success: true });
+});
+
// 从 markdown 中提取首张图片 URL,作为封面缺省兜底
function extractFirstImage(markdown: string): string {
if (!markdown) return "";
diff --git a/server/src/migrations/0011_guestbook_messages.sql b/server/src/migrations/0011_guestbook_messages.sql
new file mode 100644
index 0000000..74fc377
--- /dev/null
+++ b/server/src/migrations/0011_guestbook_messages.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS guestbook_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ author_name TEXT NOT NULL,
+ author_email TEXT NOT NULL DEFAULT '',
+ content TEXT NOT NULL,
+ approved INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS guestbook_messages_approved_idx ON guestbook_messages(approved, id DESC);
diff --git a/server/src/migrations/0012_post_card_layout.sql b/server/src/migrations/0012_post_card_layout.sql
new file mode 100644
index 0000000..1e031fc
--- /dev/null
+++ b/server/src/migrations/0012_post_card_layout.sql
@@ -0,0 +1,2 @@
+ALTER TABLE posts ADD COLUMN card_width INTEGER NOT NULL DEFAULT 100;
+ALTER TABLE posts ADD COLUMN card_height INTEGER NOT NULL DEFAULT 220;
diff --git a/server/src/storage/db/card-layout.ts b/server/src/storage/db/card-layout.ts
new file mode 100644
index 0000000..5450f97
--- /dev/null
+++ b/server/src/storage/db/card-layout.ts
@@ -0,0 +1,20 @@
+const CARD_WIDTH_MIN = 42;
+const CARD_WIDTH_MAX = 100;
+const CARD_WIDTH_DEFAULT = 100;
+const CARD_HEIGHT_MIN = 156;
+const CARD_HEIGHT_MAX = 420;
+const CARD_HEIGHT_DEFAULT = 220;
+
+export function normalizeCardWidth(value: number | undefined | null): number {
+ const next = typeof value === "number" && Number.isFinite(value)
+ ? Math.round(value)
+ : CARD_WIDTH_DEFAULT;
+ return Math.min(CARD_WIDTH_MAX, Math.max(CARD_WIDTH_MIN, next));
+}
+
+export function normalizeCardHeight(value: number | undefined | null): number {
+ const next = typeof value === "number" && Number.isFinite(value)
+ ? Math.round(value)
+ : CARD_HEIGHT_DEFAULT;
+ return Math.min(CARD_HEIGHT_MAX, Math.max(CARD_HEIGHT_MIN, next));
+}
diff --git a/server/src/storage/db/d1.ts b/server/src/storage/db/d1.ts
index c47b8a2..8f5d74f 100644
--- a/server/src/storage/db/d1.ts
+++ b/server/src/storage/db/d1.ts
@@ -4,13 +4,14 @@
────────────────────────────────────────────── */
import { drizzle } from "drizzle-orm/d1";
-import { eq, desc, sql, inArray } from "drizzle-orm";
-import { posts, tags, postTags, pages, comments, friendLinks, reactions, visits, postVersions } from "../../db/schema";
+import { and, eq, desc, inArray, lt, sql } from "drizzle-orm";
+import { posts, tags, postTags, pages, comments, guestbookMessages, friendLinks, reactions, visits, postVersions } from "../../db/schema";
import type {
IDatabase, Post, PostSummary, Tag, Page, PageSummary,
CreatePostInput, UpdatePostInput, UpsertPageInput,
- BackupData, ImportResult, ViewStats, Comment, CreateCommentInput, FriendLink, CreateFriendLinkInput, UpdateFriendLinkInput, PostVersion,
+ BackupData, ImportResult, ViewStats, Comment, CreateCommentInput, GuestbookMessage, CreateGuestbookMessageInput, FriendLink, CreateFriendLinkInput, UpdateFriendLinkInput, PostVersion,
} from "../interfaces";
+import { normalizeCardHeight, normalizeCardWidth } from "./card-layout";
type DrizzleD1 = ReturnType;
@@ -34,12 +35,14 @@ export class D1Adapter implements IDatabase {
await this.ensureSettingsTable();
await this.ensurePagesTable();
await this.ensureCommentsTable();
+ await this.ensureGuestbookMessagesTable();
await this.ensureFriendLinksTable();
await this.ensureSeriesColumns();
await this.ensureReactionsTable();
await this.ensureVisitsTable();
await this.ensureCategoryColumn();
await this.ensureCoverImageColumn();
+ await this.ensureCardLayoutColumns();
})();
}
await this.schemaReady;
@@ -65,10 +68,13 @@ export class D1Adapter implements IDatabase {
"series_order",
"category",
"cover_image",
+ "card_width",
+ "card_height",
],
settings: ["key", "value"],
pages: ["id", "slug", "title", "content", "sort_order", "published", "show_in_nav", "created_at", "updated_at"],
comments: ["id", "post_id", "author_name", "author_email", "content", "approved", "created_at"],
+ guestbook_messages: ["id", "author_name", "author_email", "content", "approved", "created_at"],
friend_links: ["id", "name", "url", "description", "avatar_url", "owner_name", "owner_email", "status", "source", "sort_order", "created_at", "updated_at", "reviewed_at"],
reactions: ["id", "post_slug", "type", "ip_hash", "created_at"],
visits: ["id", "path", "country", "referer_domain", "device_type", "created_at"],
@@ -204,6 +210,15 @@ export class D1Adapter implements IDatabase {
} catch { /* 已存在 */ }
}
+ private async ensureCardLayoutColumns(): Promise {
+ try {
+ await this.db.run(sql`ALTER TABLE posts ADD COLUMN card_width INTEGER NOT NULL DEFAULT 100`);
+ } catch { /* 已存在 */ }
+ try {
+ await this.db.run(sql`ALTER TABLE posts ADD COLUMN card_height INTEGER NOT NULL DEFAULT 220`);
+ } catch { /* 已存在 */ }
+ }
+
private async ensurePagesTable(): Promise {
await this.db.run(sql`CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -231,6 +246,18 @@ export class D1Adapter implements IDatabase {
await this.db.run(sql`CREATE INDEX IF NOT EXISTS comments_post_id_idx ON comments(post_id)`);
}
+ private async ensureGuestbookMessagesTable(): Promise {
+ await this.db.run(sql`CREATE TABLE IF NOT EXISTS guestbook_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ author_name TEXT NOT NULL,
+ author_email TEXT NOT NULL DEFAULT '',
+ content TEXT NOT NULL,
+ approved INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )`);
+ await this.db.run(sql`CREATE INDEX IF NOT EXISTS guestbook_messages_approved_idx ON guestbook_messages(approved, id DESC)`);
+ }
+
private async ensureFriendLinksTable(): Promise {
await this.db.run(sql`CREATE TABLE IF NOT EXISTS friend_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -261,6 +288,8 @@ export class D1Adapter implements IDatabase {
excerpt: posts.excerpt,
coverColor: posts.coverColor,
coverImage: posts.coverImage,
+ cardWidth: posts.cardWidth,
+ cardHeight: posts.cardHeight,
createdAt: posts.createdAt,
pinned: posts.pinned,
publishAt: posts.publishAt,
@@ -282,6 +311,8 @@ export class D1Adapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
createdAt: post.createdAt,
tags: tagMap.get(post.id) || [],
pinned: post.pinned,
@@ -307,6 +338,8 @@ export class D1Adapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published,
listed: post.listed,
createdAt: post.createdAt,
@@ -338,6 +371,8 @@ export class D1Adapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published,
listed: post.listed,
createdAt: post.createdAt,
@@ -362,6 +397,8 @@ export class D1Adapter implements IDatabase {
excerpt: data.excerpt || "",
coverColor: data.coverColor || "from-gray-500/20 to-gray-600/20",
coverImage: data.coverImage || "",
+ cardWidth: normalizeCardWidth(data.cardWidth),
+ cardHeight: normalizeCardHeight(data.cardHeight),
published: data.published ?? true,
listed: data.listed ?? true,
pinned: data.pinned ?? false,
@@ -384,6 +421,8 @@ export class D1Adapter implements IDatabase {
excerpt: newPost.excerpt || "",
coverColor: newPost.coverColor || "",
coverImage: newPost.coverImage || "",
+ cardWidth: normalizeCardWidth(newPost.cardWidth),
+ cardHeight: normalizeCardHeight(newPost.cardHeight),
published: newPost.published,
listed: newPost.listed,
createdAt: newPost.createdAt,
@@ -415,6 +454,8 @@ export class D1Adapter implements IDatabase {
...(data.excerpt !== undefined && { excerpt: data.excerpt }),
...(data.coverColor !== undefined && { coverColor: data.coverColor }),
...(data.coverImage !== undefined && { coverImage: data.coverImage }),
+ ...(data.cardWidth !== undefined && { cardWidth: normalizeCardWidth(data.cardWidth) }),
+ ...(data.cardHeight !== undefined && { cardHeight: normalizeCardHeight(data.cardHeight) }),
...(data.published !== undefined && { published: data.published }),
...(data.listed !== undefined && { listed: data.listed }),
...(data.pinned !== undefined && { pinned: data.pinned }),
@@ -439,6 +480,8 @@ export class D1Adapter implements IDatabase {
excerpt: updated.excerpt || "",
coverColor: updated.coverColor || "",
coverImage: updated.coverImage || "",
+ cardWidth: normalizeCardWidth(updated.cardWidth),
+ cardHeight: normalizeCardHeight(updated.cardHeight),
published: updated.published,
listed: updated.listed,
createdAt: updated.createdAt,
@@ -713,6 +756,8 @@ export class D1Adapter implements IDatabase {
excerpt: p.excerpt || "",
coverColor: p.coverColor || "",
coverImage: p.coverImage || "",
+ cardWidth: normalizeCardWidth(p.cardWidth),
+ cardHeight: normalizeCardHeight(p.cardHeight),
category: p.category || "",
seriesSlug: p.seriesSlug || null,
seriesOrder: p.seriesOrder ?? 0,
@@ -762,6 +807,8 @@ export class D1Adapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published ?? true,
listed: post.listed ?? true,
pinned: post.pinned ?? false,
@@ -784,6 +831,8 @@ export class D1Adapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published ?? true,
listed: post.listed ?? true,
pinned: post.pinned ?? false,
@@ -828,6 +877,8 @@ export class D1Adapter implements IDatabase {
excerpt: posts.excerpt,
coverColor: posts.coverColor,
coverImage: posts.coverImage,
+ cardWidth: posts.cardWidth,
+ cardHeight: posts.cardHeight,
createdAt: posts.createdAt,
pinned: posts.pinned,
publishAt: posts.publishAt,
@@ -851,6 +902,8 @@ export class D1Adapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
createdAt: post.createdAt,
tags: await this.getPostTags(post.id),
pinned: post.pinned,
@@ -1025,6 +1078,72 @@ export class D1Adapter implements IDatabase {
return (result.results?.[0] as { count: number } | undefined)?.count ?? 0;
}
+ /* ── 留言板 ─────────────────── */
+
+ private toGuestbookMessage(row: typeof guestbookMessages.$inferSelect): GuestbookMessage {
+ return {
+ id: row.id,
+ authorName: row.authorName,
+ authorEmail: row.authorEmail,
+ content: row.content,
+ approved: row.approved,
+ createdAt: row.createdAt,
+ };
+ }
+
+ async getApprovedGuestbookMessages(limit = 21, beforeId?: number): Promise {
+ const rows = await this.db
+ .select()
+ .from(guestbookMessages)
+ .where(and(
+ eq(guestbookMessages.approved, true),
+ beforeId === undefined ? undefined : lt(guestbookMessages.id, beforeId),
+ ))
+ .orderBy(desc(guestbookMessages.id))
+ .limit(limit);
+ return rows.map((row) => this.toGuestbookMessage(row));
+ }
+
+ async addGuestbookMessage(input: CreateGuestbookMessageInput): Promise {
+ const [created] = await this.db
+ .insert(guestbookMessages)
+ .values({
+ authorName: input.authorName,
+ authorEmail: input.authorEmail || "",
+ content: input.content,
+ approved: false,
+ })
+ .returning();
+ return this.toGuestbookMessage(created);
+ }
+
+ async getAllGuestbookMessages(limit = 51, beforeId?: number): Promise {
+ const rows = await this.db
+ .select()
+ .from(guestbookMessages)
+ .where(beforeId === undefined ? undefined : lt(guestbookMessages.id, beforeId))
+ .orderBy(desc(guestbookMessages.id))
+ .limit(limit);
+ return rows.map((row) => this.toGuestbookMessage(row));
+ }
+
+ async approveGuestbookMessage(id: number): Promise {
+ const result = await this.db
+ .update(guestbookMessages)
+ .set({ approved: true })
+ .where(eq(guestbookMessages.id, id))
+ .returning();
+ return result.length > 0;
+ }
+
+ async deleteGuestbookMessage(id: number): Promise {
+ const result = await this.db
+ .delete(guestbookMessages)
+ .where(eq(guestbookMessages.id, id))
+ .returning();
+ return result.length > 0;
+ }
+
/* ── 友链 ─────────────────── */
private toFriendLink(row: typeof friendLinks.$inferSelect): FriendLink {
diff --git a/server/src/storage/db/postgres.ts b/server/src/storage/db/postgres.ts
index 2a56eca..7a1d5ec 100644
--- a/server/src/storage/db/postgres.ts
+++ b/server/src/storage/db/postgres.ts
@@ -6,13 +6,14 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
-import { eq, desc, sql, inArray } from "drizzle-orm";
-import { pgPosts, pgTags, pgPostTags, pgPages, pgSettings, pgComments, pgFriendLinks, pgReactions, pgVisits, pgPostVersions } from "../../db/schema-pg";
+import { and, eq, desc, inArray, lt, sql } from "drizzle-orm";
+import { pgPosts, pgTags, pgPostTags, pgPages, pgSettings, pgComments, pgGuestbookMessages, pgFriendLinks, pgReactions, pgVisits, pgPostVersions } from "../../db/schema-pg";
import type {
IDatabase, Post, PostSummary, Tag, Page, PageSummary,
CreatePostInput, UpdatePostInput, UpsertPageInput,
- BackupData, ImportResult, ViewStats, Comment, CreateCommentInput, FriendLink, CreateFriendLinkInput, UpdateFriendLinkInput, PostVersion
+ BackupData, ImportResult, ViewStats, Comment, CreateCommentInput, GuestbookMessage, CreateGuestbookMessageInput, FriendLink, CreateFriendLinkInput, UpdateFriendLinkInput, PostVersion
} from "../interfaces";
+import { normalizeCardHeight, normalizeCardWidth } from "./card-layout";
type DrizzlePG = ReturnType;
@@ -42,6 +43,8 @@ export class PostgresAdapter implements IDatabase {
excerpt TEXT DEFAULT '',
cover_color TEXT DEFAULT 'from-gray-500/20 to-gray-600/20',
cover_image TEXT DEFAULT '',
+ card_width INTEGER NOT NULL DEFAULT 100,
+ card_height INTEGER NOT NULL DEFAULT 220,
published BOOLEAN NOT NULL DEFAULT true,
listed BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -71,6 +74,12 @@ export class PostgresAdapter implements IDatabase {
await this.client`
ALTER TABLE posts ADD COLUMN IF NOT EXISTS cover_image TEXT DEFAULT ''
`.catch(() => {});
+ await this.client`
+ ALTER TABLE posts ADD COLUMN IF NOT EXISTS card_width INTEGER NOT NULL DEFAULT 100
+ `.catch(() => {});
+ await this.client`
+ ALTER TABLE posts ADD COLUMN IF NOT EXISTS card_height INTEGER NOT NULL DEFAULT 220
+ `.catch(() => {});
await this.client`
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
@@ -115,6 +124,17 @@ export class PostgresAdapter implements IDatabase {
)
`;
await this.client`CREATE INDEX IF NOT EXISTS pg_comments_post_id_idx ON comments(post_id)`;
+ await this.client`
+ CREATE TABLE IF NOT EXISTS guestbook_messages (
+ id SERIAL PRIMARY KEY,
+ author_name TEXT NOT NULL,
+ author_email TEXT NOT NULL DEFAULT '',
+ content TEXT NOT NULL,
+ approved BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ `;
+ await this.client`CREATE INDEX IF NOT EXISTS pg_guestbook_messages_approved_idx ON guestbook_messages(approved, id DESC)`;
await this.client`
CREATE TABLE IF NOT EXISTS friend_links (
id SERIAL PRIMARY KEY,
@@ -235,6 +255,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: pgPosts.excerpt,
coverColor: pgPosts.coverColor,
coverImage: pgPosts.coverImage,
+ cardWidth: pgPosts.cardWidth,
+ cardHeight: pgPosts.cardHeight,
createdAt: pgPosts.createdAt,
pinned: pgPosts.pinned,
publishAt: pgPosts.publishAt,
@@ -256,6 +278,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
createdAt: this.ts(post.createdAt),
tags: tagMap.get(post.id) || [],
pinned: post.pinned,
@@ -281,6 +305,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published,
listed: post.listed,
createdAt: this.ts(post.createdAt),
@@ -312,6 +338,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published,
listed: post.listed,
createdAt: this.ts(post.createdAt),
@@ -336,6 +364,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: data.excerpt || "",
coverColor: data.coverColor || "from-gray-500/20 to-gray-600/20",
coverImage: data.coverImage || "",
+ cardWidth: normalizeCardWidth(data.cardWidth),
+ cardHeight: normalizeCardHeight(data.cardHeight),
published: data.published ?? true,
listed: data.listed ?? true,
pinned: data.pinned ?? false,
@@ -358,6 +388,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: newPost.excerpt || "",
coverColor: newPost.coverColor || "",
coverImage: newPost.coverImage || "",
+ cardWidth: normalizeCardWidth(newPost.cardWidth),
+ cardHeight: normalizeCardHeight(newPost.cardHeight),
published: newPost.published,
listed: newPost.listed,
createdAt: this.ts(newPost.createdAt),
@@ -389,6 +421,8 @@ export class PostgresAdapter implements IDatabase {
...(data.excerpt !== undefined && { excerpt: data.excerpt }),
...(data.coverColor !== undefined && { coverColor: data.coverColor }),
...(data.coverImage !== undefined && { coverImage: data.coverImage }),
+ ...(data.cardWidth !== undefined && { cardWidth: normalizeCardWidth(data.cardWidth) }),
+ ...(data.cardHeight !== undefined && { cardHeight: normalizeCardHeight(data.cardHeight) }),
...(data.published !== undefined && { published: data.published }),
...(data.listed !== undefined && { listed: data.listed }),
...(data.pinned !== undefined && { pinned: data.pinned }),
@@ -413,6 +447,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: updated.excerpt || "",
coverColor: updated.coverColor || "",
coverImage: updated.coverImage || "",
+ cardWidth: normalizeCardWidth(updated.cardWidth),
+ cardHeight: normalizeCardHeight(updated.cardHeight),
published: updated.published,
listed: updated.listed,
createdAt: this.ts(updated.createdAt),
@@ -688,6 +724,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: p.excerpt || "",
coverColor: p.coverColor || "",
coverImage: p.coverImage || "",
+ cardWidth: normalizeCardWidth(p.cardWidth),
+ cardHeight: normalizeCardHeight(p.cardHeight),
published: p.published,
listed: p.listed,
createdAt: this.ts(p.createdAt),
@@ -742,6 +780,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published ?? true,
listed: post.listed ?? true,
pinned: post.pinned ?? false,
@@ -764,6 +804,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published ?? true,
listed: post.listed ?? true,
pinned: post.pinned ?? false,
@@ -800,6 +842,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: pgPosts.excerpt,
coverColor: pgPosts.coverColor,
coverImage: pgPosts.coverImage,
+ cardWidth: pgPosts.cardWidth,
+ cardHeight: pgPosts.cardHeight,
createdAt: pgPosts.createdAt,
pinned: pgPosts.pinned,
publishAt: pgPosts.publishAt,
@@ -821,6 +865,8 @@ export class PostgresAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
createdAt: this.ts(post.createdAt),
tags: await this.getPostTags(post.id),
pinned: post.pinned,
@@ -992,6 +1038,72 @@ export class PostgresAdapter implements IDatabase {
return row?.count ?? 0;
}
+ /* ── 留言板 ─────────────────── */
+
+ private toGuestbookMessage(row: typeof pgGuestbookMessages.$inferSelect): GuestbookMessage {
+ return {
+ id: row.id,
+ authorName: row.authorName,
+ authorEmail: row.authorEmail,
+ content: row.content,
+ approved: row.approved,
+ createdAt: this.ts(row.createdAt),
+ };
+ }
+
+ async getApprovedGuestbookMessages(limit = 21, beforeId?: number): Promise {
+ const rows = await this.db
+ .select()
+ .from(pgGuestbookMessages)
+ .where(and(
+ eq(pgGuestbookMessages.approved, true),
+ beforeId === undefined ? undefined : lt(pgGuestbookMessages.id, beforeId),
+ ))
+ .orderBy(desc(pgGuestbookMessages.id))
+ .limit(limit);
+ return rows.map((row) => this.toGuestbookMessage(row));
+ }
+
+ async addGuestbookMessage(input: CreateGuestbookMessageInput): Promise {
+ const [created] = await this.db
+ .insert(pgGuestbookMessages)
+ .values({
+ authorName: input.authorName,
+ authorEmail: input.authorEmail || "",
+ content: input.content,
+ approved: false,
+ })
+ .returning();
+ return this.toGuestbookMessage(created);
+ }
+
+ async getAllGuestbookMessages(limit = 51, beforeId?: number): Promise {
+ const rows = await this.db
+ .select()
+ .from(pgGuestbookMessages)
+ .where(beforeId === undefined ? undefined : lt(pgGuestbookMessages.id, beforeId))
+ .orderBy(desc(pgGuestbookMessages.id))
+ .limit(limit);
+ return rows.map((row) => this.toGuestbookMessage(row));
+ }
+
+ async approveGuestbookMessage(id: number): Promise {
+ const result = await this.db
+ .update(pgGuestbookMessages)
+ .set({ approved: true })
+ .where(eq(pgGuestbookMessages.id, id))
+ .returning();
+ return result.length > 0;
+ }
+
+ async deleteGuestbookMessage(id: number): Promise {
+ const result = await this.db
+ .delete(pgGuestbookMessages)
+ .where(eq(pgGuestbookMessages.id, id))
+ .returning();
+ return result.length > 0;
+ }
+
/* ── 友链 ─────────────────── */
private toFriendLink(row: typeof pgFriendLinks.$inferSelect): FriendLink {
diff --git a/server/src/storage/db/turso.ts b/server/src/storage/db/turso.ts
index acc828b..91e3a57 100644
--- a/server/src/storage/db/turso.ts
+++ b/server/src/storage/db/turso.ts
@@ -6,13 +6,14 @@
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
-import { eq, desc, sql, inArray } from "drizzle-orm";
-import { posts, tags, postTags, pages, comments, friendLinks, reactions, visits, postVersions } from "../../db/schema";
+import { and, eq, desc, inArray, lt, sql } from "drizzle-orm";
+import { posts, tags, postTags, pages, comments, guestbookMessages, friendLinks, reactions, visits, postVersions } from "../../db/schema";
import type {
IDatabase, Post, PostSummary, Tag, Page, PageSummary,
CreatePostInput, UpdatePostInput, UpsertPageInput,
- BackupData, ImportResult, ViewStats, Comment, CreateCommentInput, FriendLink, CreateFriendLinkInput, UpdateFriendLinkInput, PostVersion
+ BackupData, ImportResult, ViewStats, Comment, CreateCommentInput, GuestbookMessage, CreateGuestbookMessageInput, FriendLink, CreateFriendLinkInput, UpdateFriendLinkInput, PostVersion
} from "../interfaces";
+import { normalizeCardHeight, normalizeCardWidth } from "./card-layout";
type DrizzleLibSQL = ReturnType;
@@ -113,6 +114,8 @@ export class TursoAdapter implements IDatabase {
excerpt TEXT DEFAULT '',
cover_color TEXT DEFAULT 'from-gray-500/20 to-gray-600/20',
cover_image TEXT DEFAULT '',
+ card_width INTEGER NOT NULL DEFAULT 100,
+ card_height INTEGER NOT NULL DEFAULT 220,
published INTEGER NOT NULL DEFAULT 1,
listed INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -133,6 +136,8 @@ export class TursoAdapter implements IDatabase {
await this.ensureViewCountColumn();
await this.ensurePinnedColumn();
await this.ensureCommentsTable();
+ await this.ensureGuestbookMessagesTable();
+ await this.ensureFriendLinksTable();
await this.db.run(sql`CREATE TABLE IF NOT EXISTS post_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -168,6 +173,8 @@ export class TursoAdapter implements IDatabase {
try { await this.db.run(sql`ALTER TABLE posts ADD COLUMN category TEXT DEFAULT ''`); } catch {}
try { await this.db.run(sql`ALTER TABLE posts ADD COLUMN series_order INTEGER DEFAULT 0`); } catch {}
try { await this.db.run(sql`ALTER TABLE posts ADD COLUMN cover_image TEXT DEFAULT ''`); } catch {}
+ try { await this.db.run(sql`ALTER TABLE posts ADD COLUMN card_width INTEGER NOT NULL DEFAULT 100`); } catch {}
+ try { await this.db.run(sql`ALTER TABLE posts ADD COLUMN card_height INTEGER NOT NULL DEFAULT 220`); } catch {}
}
private async ensureCommentsTable(): Promise {
@@ -183,6 +190,18 @@ export class TursoAdapter implements IDatabase {
await this.db.run(sql`CREATE INDEX IF NOT EXISTS comments_post_id_idx ON comments(post_id)`);
}
+ private async ensureGuestbookMessagesTable(): Promise {
+ await this.db.run(sql`CREATE TABLE IF NOT EXISTS guestbook_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ author_name TEXT NOT NULL,
+ author_email TEXT NOT NULL DEFAULT '',
+ content TEXT NOT NULL,
+ approved INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )`);
+ await this.db.run(sql`CREATE INDEX IF NOT EXISTS guestbook_messages_approved_idx ON guestbook_messages(approved, id DESC)`);
+ }
+
private async ensureFriendLinksTable(): Promise {
await this.db.run(sql`CREATE TABLE IF NOT EXISTS friend_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -213,6 +232,8 @@ export class TursoAdapter implements IDatabase {
excerpt: posts.excerpt,
coverColor: posts.coverColor,
coverImage: posts.coverImage,
+ cardWidth: posts.cardWidth,
+ cardHeight: posts.cardHeight,
createdAt: posts.createdAt,
pinned: posts.pinned,
publishAt: posts.publishAt,
@@ -234,6 +255,8 @@ export class TursoAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
createdAt: post.createdAt,
tags: tagMap.get(post.id) || [],
pinned: post.pinned,
@@ -259,6 +282,8 @@ export class TursoAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published,
listed: post.listed,
createdAt: post.createdAt,
@@ -290,6 +315,8 @@ export class TursoAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published,
listed: post.listed,
createdAt: post.createdAt,
@@ -314,6 +341,8 @@ export class TursoAdapter implements IDatabase {
excerpt: data.excerpt || "",
coverColor: data.coverColor || "from-gray-500/20 to-gray-600/20",
coverImage: data.coverImage || "",
+ cardWidth: normalizeCardWidth(data.cardWidth),
+ cardHeight: normalizeCardHeight(data.cardHeight),
published: data.published ?? true,
listed: data.listed ?? true,
pinned: data.pinned ?? false,
@@ -336,6 +365,8 @@ export class TursoAdapter implements IDatabase {
excerpt: newPost.excerpt || "",
coverColor: newPost.coverColor || "",
coverImage: newPost.coverImage || "",
+ cardWidth: normalizeCardWidth(newPost.cardWidth),
+ cardHeight: normalizeCardHeight(newPost.cardHeight),
published: newPost.published,
listed: newPost.listed,
createdAt: newPost.createdAt,
@@ -367,6 +398,8 @@ export class TursoAdapter implements IDatabase {
...(data.excerpt !== undefined && { excerpt: data.excerpt }),
...(data.coverColor !== undefined && { coverColor: data.coverColor }),
...(data.coverImage !== undefined && { coverImage: data.coverImage }),
+ ...(data.cardWidth !== undefined && { cardWidth: normalizeCardWidth(data.cardWidth) }),
+ ...(data.cardHeight !== undefined && { cardHeight: normalizeCardHeight(data.cardHeight) }),
...(data.published !== undefined && { published: data.published }),
...(data.listed !== undefined && { listed: data.listed }),
...(data.pinned !== undefined && { pinned: data.pinned }),
@@ -391,6 +424,8 @@ export class TursoAdapter implements IDatabase {
excerpt: updated.excerpt || "",
coverColor: updated.coverColor || "",
coverImage: updated.coverImage || "",
+ cardWidth: normalizeCardWidth(updated.cardWidth),
+ cardHeight: normalizeCardHeight(updated.cardHeight),
published: updated.published,
listed: updated.listed,
createdAt: updated.createdAt,
@@ -666,6 +701,8 @@ export class TursoAdapter implements IDatabase {
excerpt: p.excerpt || "",
coverColor: p.coverColor || "",
coverImage: p.coverImage || "",
+ cardWidth: normalizeCardWidth(p.cardWidth),
+ cardHeight: normalizeCardHeight(p.cardHeight),
category: p.category || "",
seriesSlug: p.seriesSlug || null,
seriesOrder: p.seriesOrder ?? 0,
@@ -713,6 +750,8 @@ export class TursoAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published ?? true,
listed: post.listed ?? true,
pinned: post.pinned ?? false,
@@ -735,6 +774,8 @@ export class TursoAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
published: post.published ?? true,
listed: post.listed ?? true,
pinned: post.pinned ?? false,
@@ -773,6 +814,8 @@ export class TursoAdapter implements IDatabase {
excerpt: posts.excerpt,
coverColor: posts.coverColor,
coverImage: posts.coverImage,
+ cardWidth: posts.cardWidth,
+ cardHeight: posts.cardHeight,
createdAt: posts.createdAt,
pinned: posts.pinned,
publishAt: posts.publishAt,
@@ -794,6 +837,8 @@ export class TursoAdapter implements IDatabase {
excerpt: post.excerpt || "",
coverColor: post.coverColor || "",
coverImage: post.coverImage || "",
+ cardWidth: normalizeCardWidth(post.cardWidth),
+ cardHeight: normalizeCardHeight(post.cardHeight),
createdAt: post.createdAt,
tags: await this.getPostTags(post.id),
pinned: post.pinned,
@@ -965,6 +1010,72 @@ export class TursoAdapter implements IDatabase {
return (result.rows?.[0] as unknown as { count: number } | undefined)?.count ?? 0;
}
+ /* ── 留言板 ─────────────────── */
+
+ private toGuestbookMessage(row: typeof guestbookMessages.$inferSelect): GuestbookMessage {
+ return {
+ id: row.id,
+ authorName: row.authorName,
+ authorEmail: row.authorEmail,
+ content: row.content,
+ approved: row.approved,
+ createdAt: row.createdAt,
+ };
+ }
+
+ async getApprovedGuestbookMessages(limit = 21, beforeId?: number): Promise {
+ const rows = await this.db
+ .select()
+ .from(guestbookMessages)
+ .where(and(
+ eq(guestbookMessages.approved, true),
+ beforeId === undefined ? undefined : lt(guestbookMessages.id, beforeId),
+ ))
+ .orderBy(desc(guestbookMessages.id))
+ .limit(limit);
+ return rows.map((row) => this.toGuestbookMessage(row));
+ }
+
+ async addGuestbookMessage(input: CreateGuestbookMessageInput): Promise {
+ const [created] = await this.db
+ .insert(guestbookMessages)
+ .values({
+ authorName: input.authorName,
+ authorEmail: input.authorEmail || "",
+ content: input.content,
+ approved: false,
+ })
+ .returning();
+ return this.toGuestbookMessage(created);
+ }
+
+ async getAllGuestbookMessages(limit = 51, beforeId?: number): Promise {
+ const rows = await this.db
+ .select()
+ .from(guestbookMessages)
+ .where(beforeId === undefined ? undefined : lt(guestbookMessages.id, beforeId))
+ .orderBy(desc(guestbookMessages.id))
+ .limit(limit);
+ return rows.map((row) => this.toGuestbookMessage(row));
+ }
+
+ async approveGuestbookMessage(id: number): Promise {
+ const result = await this.db
+ .update(guestbookMessages)
+ .set({ approved: true })
+ .where(eq(guestbookMessages.id, id))
+ .returning();
+ return result.length > 0;
+ }
+
+ async deleteGuestbookMessage(id: number): Promise {
+ const result = await this.db
+ .delete(guestbookMessages)
+ .where(eq(guestbookMessages.id, id))
+ .returning();
+ return result.length > 0;
+ }
+
/* ── 友链 ─────────────────── */
private toFriendLink(row: typeof friendLinks.$inferSelect): FriendLink {
diff --git a/server/src/storage/interfaces.ts b/server/src/storage/interfaces.ts
index f0a2713..4528df4 100644
--- a/server/src/storage/interfaces.ts
+++ b/server/src/storage/interfaces.ts
@@ -14,6 +14,8 @@ export type Post = {
excerpt: string;
coverColor: string;
coverImage: string;
+ cardWidth: number;
+ cardHeight: number;
published: boolean;
listed: boolean;
createdAt: string;
@@ -33,6 +35,8 @@ export type PostSummary = {
excerpt: string;
coverColor: string;
coverImage: string;
+ cardWidth: number;
+ cardHeight: number;
createdAt: string;
tags: string[];
pinned: boolean;
@@ -72,6 +76,8 @@ export type CreatePostInput = {
excerpt?: string;
coverColor?: string;
coverImage?: string;
+ cardWidth?: number;
+ cardHeight?: number;
published?: boolean;
listed?: boolean;
tags?: string[];
@@ -89,6 +95,8 @@ export type UpdatePostInput = {
excerpt?: string;
coverColor?: string;
coverImage?: string;
+ cardWidth?: number;
+ cardHeight?: number;
published?: boolean;
listed?: boolean;
tags?: string[];
@@ -149,6 +157,21 @@ export type CreateCommentInput = {
content: string;
};
+export type GuestbookMessage = {
+ id: number;
+ authorName: string;
+ authorEmail: string;
+ content: string;
+ approved: boolean;
+ createdAt: string;
+};
+
+export type CreateGuestbookMessageInput = {
+ authorName: string;
+ authorEmail?: string;
+ content: string;
+};
+
export type FriendLinkStatus = "pending" | "approved" | "rejected";
export type FriendLinkSource = "manual" | "submission" | "imported";
@@ -257,6 +280,13 @@ export interface IDatabase {
deleteComment(id: number): Promise;
getCommentCount(postSlug: string): Promise;
+ /* 留言板 */
+ getApprovedGuestbookMessages(limit?: number, beforeId?: number): Promise;
+ addGuestbookMessage(input: CreateGuestbookMessageInput): Promise;
+ getAllGuestbookMessages(limit?: number, beforeId?: number): Promise;
+ approveGuestbookMessage(id: number): Promise;
+ deleteGuestbookMessage(id: number): Promise;
+
/* 友链 */
getApprovedFriendLinks(): Promise;
getAllFriendLinks(): Promise;