feat: switch to Astro DB for guestbook

- Add @astrojs/db integration
- Define Guestbook schema in db/config.ts
- Add seed data for development
- Update db.ts to use astro:db
- Add guestbook section to homepage with form
- Update env vars to use ASTRO_DB_REMOTE_URL
This commit is contained in:
Lewis Wynne 2026-01-23 04:10:51 +00:00
parent 4e2c09b770
commit 79c7aff48b
9 changed files with 365 additions and 37 deletions

View file

@ -1,5 +1,5 @@
TURSO_DATABASE_URL=libsql://your-db.turso.io ASTRO_DB_REMOTE_URL=libsql://your-db.turso.io
TURSO_AUTH_TOKEN=your-token ASTRO_DB_APP_TOKEN=your-token
GITHUB_CLIENT_ID=your-client-id GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret GITHUB_CLIENT_SECRET=your-client-secret
ADMIN_GITHUB_ID=your-github-user-id ADMIN_GITHUB_ID=your-github-user-id

View file

@ -1,5 +1,6 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel'; import vercel from '@astrojs/vercel';
import db from '@astrojs/db';
import auth from 'auth-astro'; import auth from 'auth-astro';
import remarkDirective from 'remark-directive'; import remarkDirective from 'remark-directive';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@ -10,7 +11,7 @@ import remarkAside from './src/plugins/remark-aside.ts';
export default defineConfig({ export default defineConfig({
output: 'static', output: 'static',
adapter: vercel(), adapter: vercel(),
integrations: [auth()], integrations: [db(), auth()],
markdown: { markdown: {
remarkPlugins: [ remarkPlugins: [
remarkGfm, remarkGfm,

16
apps/blog/db/config.ts Normal file
View file

@ -0,0 +1,16 @@
import { defineDb, defineTable, column } from 'astro:db';
const Guestbook = defineTable({
columns: {
id: column.number({ primaryKey: true }),
name: column.text(),
message: column.text(),
url: column.text({ optional: true }),
createdAt: column.date({ default: new Date() }),
approved: column.boolean({ default: false }),
},
});
export default defineDb({
tables: { Guestbook },
});

22
apps/blog/db/seed.ts Normal file
View file

@ -0,0 +1,22 @@
import { db, Guestbook } from 'astro:db';
export default async function seed() {
await db.insert(Guestbook).values([
{
id: 1,
name: 'alice',
message: 'love the site!',
url: 'https://example.com',
createdAt: new Date('2026-01-20'),
approved: true,
},
{
id: 2,
name: 'bob',
message: 'great blog posts',
url: null,
createdAt: new Date('2026-01-18'),
approved: true,
},
]);
}

View file

@ -6,6 +6,7 @@
"": { "": {
"name": "@ily/blog", "name": "@ily/blog",
"dependencies": { "dependencies": {
"@astrojs/db": "^0.19.0",
"@astrojs/vercel": "^9.0.4", "@astrojs/vercel": "^9.0.4",
"@auth/core": "^0.37.4", "@auth/core": "^0.37.4",
"@libsql/client": "^0.17.0", "@libsql/client": "^0.17.0",
@ -318,6 +319,22 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/@astrojs/db": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@astrojs/db/-/db-0.19.0.tgz",
"integrity": "sha512-YrVsqxwODr6Bid4nRgzGsF9K8K8xSoFd7j8bAU+4CxN3tSBx/1kmTE3BClwfVH2xO74wFVsyr7ucBzw/yEsEBw==",
"license": "MIT",
"dependencies": {
"@libsql/client": "^0.17.0",
"deep-diff": "^1.0.2",
"drizzle-orm": "^0.42.0",
"nanoid": "^5.1.6",
"piccolore": "^0.1.3",
"prompts": "^2.4.2",
"yargs-parser": "^21.1.1",
"zod": "^3.25.76"
}
},
"node_modules/@astrojs/internal-helpers": { "node_modules/@astrojs/internal-helpers": {
"version": "0.7.5", "version": "0.7.5",
"resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.5.tgz", "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.5.tgz",
@ -1444,6 +1461,13 @@
} }
} }
}, },
"node_modules/deep-diff": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -1453,6 +1477,127 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/drizzle-orm": {
"version": "0.42.0",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.42.0.tgz",
"integrity": "sha512-pS8nNJm2kBNZwrOjTHJfdKkaU+KuUQmV/vk5D57NojDq4FG+0uAYGMulXtYT///HfgsMF0hnFFvu1ezI3OwOkg==",
"license": "Apache-2.0",
"peerDependencies": {
"@aws-sdk/client-rds-data": ">=3",
"@cloudflare/workers-types": ">=4",
"@electric-sql/pglite": ">=0.2.0",
"@libsql/client": ">=0.10.0",
"@libsql/client-wasm": ">=0.10.0",
"@neondatabase/serverless": ">=0.10.0",
"@op-engineering/op-sqlite": ">=2",
"@opentelemetry/api": "^1.4.1",
"@planetscale/database": ">=1.13",
"@prisma/client": "*",
"@tidbcloud/serverless": "*",
"@types/better-sqlite3": "*",
"@types/pg": "*",
"@types/sql.js": "*",
"@vercel/postgres": ">=0.8.0",
"@xata.io/client": "*",
"better-sqlite3": ">=7",
"bun-types": "*",
"expo-sqlite": ">=14.0.0",
"gel": ">=2",
"knex": "*",
"kysely": "*",
"mysql2": ">=2",
"pg": ">=8",
"postgres": ">=3",
"sql.js": ">=1",
"sqlite3": ">=5"
},
"peerDependenciesMeta": {
"@aws-sdk/client-rds-data": {
"optional": true
},
"@cloudflare/workers-types": {
"optional": true
},
"@electric-sql/pglite": {
"optional": true
},
"@libsql/client": {
"optional": true
},
"@libsql/client-wasm": {
"optional": true
},
"@neondatabase/serverless": {
"optional": true
},
"@op-engineering/op-sqlite": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@planetscale/database": {
"optional": true
},
"@prisma/client": {
"optional": true
},
"@tidbcloud/serverless": {
"optional": true
},
"@types/better-sqlite3": {
"optional": true
},
"@types/pg": {
"optional": true
},
"@types/sql.js": {
"optional": true
},
"@vercel/postgres": {
"optional": true
},
"@xata.io/client": {
"optional": true
},
"better-sqlite3": {
"optional": true
},
"bun-types": {
"optional": true
},
"expo-sqlite": {
"optional": true
},
"gel": {
"optional": true
},
"knex": {
"optional": true
},
"kysely": {
"optional": true
},
"mysql2": {
"optional": true
},
"pg": {
"optional": true
},
"postgres": {
"optional": true
},
"prisma": {
"optional": true
},
"sql.js": {
"optional": true
},
"sqlite3": {
"optional": true
}
}
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -1695,6 +1840,15 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/libsql": { "node_modules/libsql": {
"version": "0.5.22", "version": "0.5.22",
"resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.22.tgz", "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.22.tgz",
@ -1784,6 +1938,24 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/node-domexception": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@ -1901,6 +2073,12 @@
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/piccolore": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
"integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==",
"license": "ISC"
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
@ -1938,6 +2116,19 @@
"integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
"integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
"license": "MIT",
"dependencies": {
"kleur": "^3.0.3",
"sisteransi": "^1.0.5"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -2024,6 +2215,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -2338,6 +2535,24 @@
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }

View file

@ -7,9 +7,9 @@
"preview": "astro preview" "preview": "astro preview"
}, },
"dependencies": { "dependencies": {
"@astrojs/db": "^0.19.0",
"@astrojs/vercel": "^9.0.4", "@astrojs/vercel": "^9.0.4",
"@auth/core": "^0.37.4", "@auth/core": "^0.37.4",
"@libsql/client": "^0.17.0",
"astro": "^5.16.13", "astro": "^5.16.13",
"auth-astro": "^4.2.0", "auth-astro": "^4.2.0",
"js-yaml": "^4.1.1" "js-yaml": "^4.1.1"

View file

@ -1,50 +1,29 @@
import { createClient } from '@libsql/client'; import { db, Guestbook, eq, desc } from 'astro:db';
export const db = createClient({ export type GuestbookEntry = typeof Guestbook.$inferSelect;
url: import.meta.env.TURSO_DATABASE_URL,
authToken: import.meta.env.TURSO_AUTH_TOKEN,
});
export interface GuestbookEntry {
id: number;
name: string;
message: string;
url: string | null;
created_at: string;
approved: number;
}
export async function getApprovedEntries(): Promise<GuestbookEntry[]> { export async function getApprovedEntries(): Promise<GuestbookEntry[]> {
const result = await db.execute( return db.select().from(Guestbook).where(eq(Guestbook.approved, true)).orderBy(desc(Guestbook.createdAt));
'SELECT * FROM guestbook WHERE approved = 1 ORDER BY created_at DESC'
);
return result.rows as unknown as GuestbookEntry[];
} }
export async function getPendingEntries(): Promise<GuestbookEntry[]> { export async function getPendingEntries(): Promise<GuestbookEntry[]> {
const result = await db.execute( return db.select().from(Guestbook).where(eq(Guestbook.approved, false)).orderBy(desc(Guestbook.createdAt));
'SELECT * FROM guestbook WHERE approved = 0 ORDER BY created_at DESC'
);
return result.rows as unknown as GuestbookEntry[];
} }
export async function createEntry(name: string, message: string, url: string | null): Promise<void> { export async function createEntry(name: string, message: string, url: string | null): Promise<void> {
await db.execute({ await db.insert(Guestbook).values({
sql: 'INSERT INTO guestbook (name, message, url) VALUES (?, ?, ?)', name,
args: [name, message, url], message,
url,
createdAt: new Date(),
approved: false,
}); });
} }
export async function approveEntry(id: number): Promise<void> { export async function approveEntry(id: number): Promise<void> {
await db.execute({ await db.update(Guestbook).set({ approved: true }).where(eq(Guestbook.id, id));
sql: 'UPDATE guestbook SET approved = 1 WHERE id = ?',
args: [id],
});
} }
export async function deleteEntry(id: number): Promise<void> { export async function deleteEntry(id: number): Promise<void> {
await db.execute({ await db.delete(Guestbook).where(eq(Guestbook.id, id));
sql: 'DELETE FROM guestbook WHERE id = ?',
args: [id],
});
} }

View file

@ -5,6 +5,7 @@ import yaml from 'js-yaml';
import bookmarksRaw from '../data/bookmarks.yaml?raw'; import bookmarksRaw from '../data/bookmarks.yaml?raw';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
interface Bookmark { interface Bookmark {
date: string; date: string;
@ -35,6 +36,13 @@ const txtFiles: TxtFile[] = fs.existsSync(txtDir)
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
: []; : [];
let guestbookEntries: GuestbookEntry[] = [];
try {
guestbookEntries = await getApprovedEntries();
} catch {
// DB not available during dev without env vars
}
function formatDate(date: Date): string { function formatDate(date: Date): string {
const d = String(date.getDate()).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0');
const m = String(date.getMonth() + 1).padStart(2, '0'); const m = String(date.getMonth() + 1).padStart(2, '0');
@ -78,5 +86,47 @@ function extractDomain(url: string): string {
<summary>bookmarks</summary> <summary>bookmarks</summary>
<pre set:html={bookmarks.map(b => `<span class="muted">${formatBookmarkDate(b.date)}</span> <a href="${b.url}">${b.title}</a> <span class="muted">(${extractDomain(b.url)})</span>`).join('\n')} /> <pre set:html={bookmarks.map(b => `<span class="muted">${formatBookmarkDate(b.date)}</span> <a href="${b.url}">${b.title}</a> <span class="muted">(${extractDomain(b.url)})</span>`).join('\n')} />
</details> </details>
<details open>
<summary>guestbook</summary>
<pre set:html={guestbookEntries.length > 0
? guestbookEntries.map(e => `<span class="muted">${formatDate(e.createdAt)}</span> ${e.url ? `<a href="${e.url}">${e.name}</a>` : e.name}: ${e.message}`).join('\n')
: '<span class="muted">no entries yet</span>'} />
<form id="guestbook-form">
<input type="text" name="name" placeholder="name" required maxlength="100" />
<input type="text" name="url" placeholder="url (optional)" maxlength="200" />
<textarea name="message" placeholder="message" required maxlength="500"></textarea>
<button type="submit">sign</button>
</form>
<div id="guestbook-status"></div>
</details>
<script>
const form = document.getElementById('guestbook-form') as HTMLFormElement;
const status = document.getElementById('guestbook-status')!;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(form));
try {
const res = await fetch('/api/guestbook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.ok) {
status.textContent = 'thanks! your message is pending approval.';
form.reset();
} else {
const err = await res.json();
status.textContent = err.error || 'something went wrong';
}
} catch {
status.textContent = 'failed to submit';
}
});
</script>
</body> </body>
</html> </html>

View file

@ -46,3 +46,48 @@ summary::-webkit-details-marker {
details pre { details pre {
margin: 0; margin: 0;
} }
#guestbook-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-left: 1rem;
margin-top: 0.5rem;
}
#guestbook-form input,
#guestbook-form textarea {
font-family: monospace;
font-size: 1rem;
padding: 0.25rem;
border: 1px solid #888;
background: transparent;
color: inherit;
}
#guestbook-form textarea {
min-height: 3rem;
resize: vertical;
}
#guestbook-form button {
font-family: monospace;
font-size: 1rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
background: transparent;
border: 1px solid #888;
color: inherit;
align-self: flex-start;
}
#guestbook-form button:hover {
background: #888;
color: #000;
}
#guestbook-status {
padding-left: 1rem;
color: #888;
font-family: monospace;
}