diff --git a/_admin/guestbook.html b/_admin/guestbook.html
new file mode 100644
index 0000000..7ac0c5a
--- /dev/null
+++ b/_admin/guestbook.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+guestbook — admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/_admin/index.html b/_admin/index.html
index 621128f..327d45a 100644
--- a/_admin/index.html
+++ b/_admin/index.html
@@ -32,6 +32,7 @@
now status→
bookmarks→
photos→
+ guestbook→
diff --git a/_api/src/index.js b/_api/src/index.js
index 81432fc..70c7232 100644
--- a/_api/src/index.js
+++ b/_api/src/index.js
@@ -4,6 +4,7 @@ import { handleStatus } from './routes/status.js';
import { handleBookmarks } from './routes/bookmarks.js';
import { handlePhotos } from './routes/photos.js';
import { handleDiscord } from './routes/discord.js';
+import { handleGuestbook } from './routes/guestbook.js';
import { cors, corsHeaders } from './lib/cors.js';
export default {
@@ -27,6 +28,8 @@ export default {
response = await handlePhotos(request, env, path);
else if (path === '/discord' || path.startsWith('/discord/'))
response = await handleDiscord(request, env);
+ else if (path === '/guestbook' || path.startsWith('/guestbook/'))
+ response = await handleGuestbook(request, env, path);
else
response = new Response('not found', { status: 404 });
diff --git a/_api/src/routes/guestbook.js b/_api/src/routes/guestbook.js
new file mode 100644
index 0000000..a68811b
--- /dev/null
+++ b/_api/src/routes/guestbook.js
@@ -0,0 +1,53 @@
+import { requireAuth } from '../lib/auth.js';
+import { json } from '../lib/json.js';
+
+export async function handleGuestbook(request, env, path) {
+ const method = request.method;
+ const id = path.split('/')[2] || null;
+
+ if (method === 'GET') {
+ const wantsAll = new URL(request.url).searchParams.get('all') === '1';
+ if (wantsAll) {
+ const denied = await requireAuth(request, env);
+ if (denied) return denied;
+ }
+ const list = await env.GUESTBOOK_KV.list({ prefix: 'gb:' });
+ const items = await Promise.all(
+ list.keys
+ .sort((a, b) => b.name.localeCompare(a.name))
+ .map(k => env.GUESTBOOK_KV.get(k.name, 'json'))
+ );
+ const entries = items.filter(Boolean).filter(e => wantsAll || e.approved);
+ return json({ entries });
+ }
+
+ if (method === 'POST') {
+ const body = await request.json().catch(() => ({}));
+ const name = String(body.name || '').trim().slice(0, 60);
+ const message = String(body.message || '').trim().slice(0, 500);
+ if (!name || !message) return json({ error: 'name and message required' }, 400);
+ const ts = Date.now();
+ const entry = { id: `${ts}`, name, message, approved: false, date: new Date(ts).toISOString() };
+ await env.GUESTBOOK_KV.put(`gb:${ts}`, JSON.stringify(entry));
+ return json({ ok: true }, 201);
+ }
+
+ const denied = await requireAuth(request, env);
+ if (denied) return denied;
+
+ if (method === 'PUT' && id) {
+ const existing = await env.GUESTBOOK_KV.get(`gb:${id}`, 'json');
+ if (!existing) return json({ error: 'not found' }, 404);
+ const body = await request.json().catch(() => ({}));
+ const entry = { ...existing, ...body, id: existing.id, date: existing.date };
+ await env.GUESTBOOK_KV.put(`gb:${id}`, JSON.stringify(entry));
+ return json(entry);
+ }
+
+ if (method === 'DELETE' && id) {
+ await env.GUESTBOOK_KV.delete(`gb:${id}`);
+ return json({ ok: true });
+ }
+
+ return json({ error: 'not found' }, 404);
+}
diff --git a/_api/wrangler.toml b/_api/wrangler.toml
index 9e7b6b9..8eb9db3 100644
--- a/_api/wrangler.toml
+++ b/_api/wrangler.toml
@@ -18,6 +18,10 @@ id = "REPLACE_WITH_BOOKMARKS_KV_ID"
binding = "PHOTOS_KV"
id = "REPLACE_WITH_PHOTOS_KV_ID"
+[[kv_namespaces]]
+binding = "GUESTBOOK_KV"
+id = "REPLACE_WITH_GUESTBOOK_KV_ID"
+
[[r2_buckets]]
binding = "PHOTOS_R2"
bucket_name = "jxherc-photos"
diff --git a/guestbook.html b/guestbook.html
new file mode 100644
index 0000000..921c0c7
--- /dev/null
+++ b/guestbook.html
@@ -0,0 +1,122 @@
+
+
+
+
+
+guestbook — jxherc
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
are you absolutely motherfucking sure you wanna switch to light mode
+
+
+
+
+
+
+ jxherc
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/index.html b/index.html
index 68ad435..b689bd2 100644
--- a/index.html
+++ b/index.html
@@ -80,6 +80,10 @@
bookmarks
→
+
+ guestbook
+ →
+
diff --git a/style.css b/style.css
index 0929f94..bcabe74 100644
--- a/style.css
+++ b/style.css
@@ -383,6 +383,42 @@ a.kv-row:hover .arrow { transform: translate(3px, -3px); color: var(--muted); }
line-height: 1.55;
}
+/* =================== GUESTBOOK =================== */
+.gb-form { display: flex; flex-direction: column; gap: 1rem; }
+.gb-field { display: flex; flex-direction: column; gap: 0.35rem; }
+.gb-label { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.04em; font-weight: 500; }
+.gb-input {
+ background: none;
+ border: 1px solid var(--faint);
+ border-radius: 2px;
+ color: var(--text);
+ font-family: inherit;
+ font-size: 0.875rem;
+ padding: 0.6rem 0.75rem;
+ transition: border-color 0.2s ease;
+ outline: none;
+ width: 100%;
+}
+.gb-input::placeholder { color: var(--faint); }
+.gb-input:focus { border-color: var(--muted); }
+.gb-textarea { resize: vertical; min-height: 88px; line-height: 1.55; }
+.gb-actions { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; margin-top: 0.25rem; }
+.gb-submit {
+ background: none;
+ border: 1px solid var(--faint);
+ border-radius: 2px;
+ color: var(--muted);
+ font-family: inherit;
+ font-size: 0.72rem;
+ letter-spacing: 0.04em;
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ transition: border-color 0.2s ease, color 0.2s ease;
+}
+.gb-submit:hover:not(:disabled) { border-color: var(--muted); color: var(--text); }
+.gb-submit:disabled { opacity: 0.5; cursor: default; }
+.gb-status { font-size: 0.72rem; color: var(--faint); }
+
/* Page edit stamp */
.page-edit-stamp {
font-size: 0.62rem;