Add Zod validation layer and AI blog generation
Introduce a schema-first validation layer and an AI blog generation feature, plus one-command Cloudflare provisioning. - src/schemas/ holds Zod input schemas for post, content, case, settings, AI, and keywords; parseForm() in src/lib/form.ts validates FormData and returns per-field errors. - Migrate all admin POST handlers to parseForm, showing field-level errors and only redirecting once validation passes. - Add blog_keywords table and posts.focus_keyword (migration 0002); uniqueSlug() centralised in src/data/content.ts. - Add src/lib/ai.ts (OpenAI-compatible chat completions + optional Tavily research) and /admin/ai for AI settings, keyword queue, and draft generation. - Add scripts/setup.mjs (npm run setup) to provision D1/KV, set secrets, and optionally migrate, seed, and deploy. - Document AI secrets, Workers Builds deploy flow, and new schemas across README and AGENTS docs.
This commit is contained in:
+38
-24
@@ -4,11 +4,17 @@ import AdminLayout from "../../layouts/AdminLayout.astro";
|
||||
import { cases } from "../../db/schema";
|
||||
import { getDb } from "../../lib/db";
|
||||
import { getEnv } from "../../lib/env";
|
||||
import { parseForm } from "../../lib/form";
|
||||
import { caseInput } from "../../schemas";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const db = getDb(getEnv().DB);
|
||||
|
||||
let errors: Record<string, string> = {};
|
||||
let failedId = "";
|
||||
let failedAdd = false;
|
||||
|
||||
function str(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
}
|
||||
@@ -17,36 +23,40 @@ if (Astro.request.method === "POST") {
|
||||
const form = await Astro.request.formData();
|
||||
const action = str(form, "action");
|
||||
|
||||
if (action === "add") {
|
||||
const title = str(form, "title");
|
||||
if (title) {
|
||||
if (action === "add" || action === "save") {
|
||||
const parsed = parseForm(form, caseInput);
|
||||
if (!parsed.ok) {
|
||||
errors = parsed.errors;
|
||||
failedId = str(form, "id");
|
||||
failedAdd = action === "add";
|
||||
} else if (action === "add") {
|
||||
const [row] = await db.select({ m: max(cases.sortOrder) }).from(cases);
|
||||
await db.insert(cases).values({
|
||||
id: crypto.randomUUID(),
|
||||
title,
|
||||
location: str(form, "location") || null,
|
||||
completedAt: str(form, "completedAt") || null,
|
||||
description: str(form, "description") || null,
|
||||
imageUrl: str(form, "imageUrl") || null,
|
||||
title: parsed.data.title,
|
||||
location: parsed.data.location || null,
|
||||
completedAt: parsed.data.completedAt || null,
|
||||
description: parsed.data.description || null,
|
||||
imageUrl: parsed.data.imageUrl || null,
|
||||
sortOrder: (row?.m ?? 0) + 1,
|
||||
status: str(form, "status") === "draft" ? "draft" : "published",
|
||||
status: parsed.data.status,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
const id = str(form, "id");
|
||||
await db
|
||||
.update(cases)
|
||||
.set({
|
||||
title: parsed.data.title,
|
||||
location: parsed.data.location || null,
|
||||
completedAt: parsed.data.completedAt || null,
|
||||
description: parsed.data.description || null,
|
||||
imageUrl: parsed.data.imageUrl || null,
|
||||
status: parsed.data.status,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(cases.id, id));
|
||||
}
|
||||
} else if (action === "save") {
|
||||
const id = str(form, "id");
|
||||
await db
|
||||
.update(cases)
|
||||
.set({
|
||||
title: str(form, "title"),
|
||||
location: str(form, "location") || null,
|
||||
completedAt: str(form, "completedAt") || null,
|
||||
description: str(form, "description") || null,
|
||||
imageUrl: str(form, "imageUrl") || null,
|
||||
status: str(form, "status") === "draft" ? "draft" : "published",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(cases.id, id));
|
||||
} else if (action === "delete") {
|
||||
await db.delete(cases).where(eq(cases.id, str(form, "id")));
|
||||
} else if (action === "up" || action === "down") {
|
||||
@@ -68,7 +78,9 @@ if (Astro.request.method === "POST") {
|
||||
}
|
||||
}
|
||||
|
||||
return Astro.redirect("/admin/cases");
|
||||
if (Object.keys(errors).length === 0) {
|
||||
return Astro.redirect("/admin/cases");
|
||||
}
|
||||
}
|
||||
|
||||
const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
|
||||
@@ -85,6 +97,7 @@ const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label for="a-title">標題</label>
|
||||
{failedAdd && errors.title && <span class="field-error">{errors.title}</span>}
|
||||
<input id="a-title" name="title" type="text" required placeholder="例:大埔上碗窯 — 雙玻光伏板" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -119,6 +132,7 @@ const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label>標題</label>
|
||||
{failedId === item.id && errors.title && <span class="field-error">{errors.title}</span>}
|
||||
<input name="title" type="text" value={item.title} required />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user