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

View file

@ -5,6 +5,7 @@ import yaml from 'js-yaml';
import bookmarksRaw from '../data/bookmarks.yaml?raw';
import fs from 'node:fs';
import path from 'node:path';
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
interface Bookmark {
date: string;
@ -35,6 +36,13 @@ const txtFiles: TxtFile[] = fs.existsSync(txtDir)
.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 {
const d = String(date.getDate()).padStart(2, '0');
const m = String(date.getMonth() + 1).padStart(2, '0');
@ -78,5 +86,47 @@ function extractDomain(url: string): string {
<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')} />
</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>
</html>

View file

@ -46,3 +46,48 @@ summary::-webkit-details-marker {
details pre {
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;
}