Guestbook Build
How was the poetry guestbook implemented?
Original Poetry
Book
Above 768px, .desk-desktop shows an interactive animated 3D book volume.
At max-width: 768px, .desk-desktop is hidden and MobileReading.tsx a vertical snap scroller (.mobile-reading) takes the viewport.
Poems
Poems are Markdown files that live under poetry/:
loadPoems()reads that folder at request time, splits on##headings, and turns*emphasis*/**strong**into HTML. Each file becomes aPoem(idfrom the filename,sectionsfor every heading).pickPoemIndex()picks a random poem for the first paint, and a different one whenever the reader turns the page, ensuring no repeats in a row.- On each poem, a heart button calls
togglePoemHeartallowing visitors to like poems.
Guestbook Functionality
Supabase Backend
The schema applied by running scripts/schema.sql enables row-level security on every public table — is_admin() is true only when the JWT has app_metadata.role = 'admin' (stamped from the allow-list at signup, not from editable user_metadata).
| Table | anon | Admin (authenticated + is_admin()) |
|---|---|---|
comments | INSERT | SELECT, INSERT, UPDATE, DELETE |
guestbook_settings | SELECT | SELECT, UPDATE |
admin_allowlist | none | none |
poem_hearts | none | none |
commentshas no public SELECT, so emails stay on the private table. Visitors read thecomments_publicview instead (id,display_name,body, timestamps).guestbook_settingsis a singleton (id = 1) — no INSERT or DELETE policies. SELECT is granted toanonandauthenticated.admin_allowlisthas RLS on with no policies, soanon/authenticatedcannot read or write.service_rolebypasses RLS (and still needs table grants). Auth'sbefore-user-created hookreads it assupabase_auth_admin.poem_heartshas no table policies. Likes go through the security-definer RPCstoggle_poem_heartandpoem_heart_state. Public counts come frompoem_heart_countsandpoem_heart_total.
Database webhooks POST into two Supabase Edge Functions (verify_jwt = false in supabase/config.toml):
- Both check
NOTIFY_WEBHOOK_SECRET(x-notify-webhook-secretor Bearer), then no-op unless the Edge secretFLAG_NOTIFistrue/1. - Recipients come from
admin_allowlistvia the service role (loadAllowlist()).
| Function | Trigger |
|---|---|
notify-admins | Table webhooks on admin_allowlist, comments, guestbook_settings, and poem_hearts |
on-user-created | auth.users INSERT — the new address plus the existing admin list |
notify-admins maps webhook table and type onto Markdown templates in _shared/templates/:
| Table | Events | Recipients |
|---|---|---|
admin_allowlist | INSERT, DELETE | The added/removed address, and the admin list |
comments | INSERT, UPDATE, DELETE | Admin list |
guestbook_settings | UPDATE | Admin list |
poem_hearts | INSERT, DELETE | Admin list ( / unheart) |
- Comment UPDATE only mails when
display_name,email, orbodychanged. - Settings UPDATE skips
idandupdated_at.
Mail goes out through gmail.ts — Gmail API with a refresh token, From Poetry Guestbook <GMAIL_USER>.
Next.js Frontend
CommentBubbles.tsx powers both the desktop inline guestbook and the mobile commenting overlay. Publicly displayed comments returns the comments_public table (id, display_name, body, timestamps — no email) via getPublicCommentsPage by newest first.
When users click Submit, the submitComment server action:
- Requires a display name and comment body.
- Enforces
commentLengthError— name cap128, email cap254, andcomment_lengthfrom settings for the body (default at1000). - Runs
checkCommentProfanity(obscenityplus the admin allow-list). - Consumes
consumeCommentRateLimit(rate-limiter-flexibleoverDATABASE_URL, keyed by a hash ofclientIp()). Burst and daily caps come from settings (default at1comment per5minutes, up to2per24hours). - Inserts the submitted comment in the private
commentstable, thenrevalidatePathfor/,/guestbook, and the admin path (so a later navigation or refresh is not served from a stale cache).CommentBubblesthen callsgetPublicCommentsPageagain and writes that result into local state — the list updates in place, with no page reload.
proxy.ts (Next's request proxy) only gates /admin (or NEXT_PUBLIC_GUESTBOOK_ADMIN_PATH). On those paths it refreshes the Supabase session:
- No existing admin login session — redirects to
/admin/login. - Successful sign in but
app_metadata.roleis notadmin— user is signed out and send home with?admin_error=1. - Existing admin login session — redirects into the dashboard.
/admin/login starts Google OAuth. /auth/callback exchanges the code, then resolveAdminSession calls ensure_admin_role (allow-list stamp) and refreshes the JWT so role=admin is on the cookie.
getUniqueVisitors() runs the UTC-filtered PostHog $pageview query and passes that number into HitCounter.
Development always shows ERROR! and does not call PostHog. In production it:
- Renders the server count from the UTC-filtered HogQL query on
events. Missing or incorrect PostHog configuration and Query API failures renderERROR!. - Polls
/api/hitsimmediately, then every15seconds, re-running a fresh Query API uniq (not PostHog's default cached result) so the digits can tick up after a new unique visitor lands.
HogQL scoping (FLAG_COUNTER_URL, FLAG_COUNTER_DATE) is documented under Hit Counter in Feature Flags.
Feature Flags
Flags are built on compile:
src/lib/flags.tsreadsprocess.env.FLAG_*withenvFlag(true/1vsfalse/0— unset uses the default).FLAG_COUNTER_URLandFLAG_COUNTER_DATEare not in this file —getUniqueVisitors()reads them at request time so Next does not bake them into the client bundle.next.config.tslists the boolean flag names underenvso Next can inline them into client components.
FLAG_PUBLIC
Allows diversion of the guestbook to a public page instead of the landing with the poetry volume.
Used to deploy to the public version of the site at https://y2k-guestbook.vercel.app/:
- Homepage is the full page guestbook instead of the landing with the poetry volume.
- Favicon is dark green instead of coral.
- GitHub "Releases" and "Report Issue" links point at
y2k-guestbookinstead ofguestbook. - Documentation landing subtitle, live OpenAPI explorer, and
{.public}/{.private}fences all follow this flag. - Documentation pages with
public: falsein their YAML frontmatter (like this one) do not display in the side navigation and do not exist on the public version of the site.
FLAG_COUNTER
When set to true, enables display of the HitCounter in production builds.
FLAG_COUNTER_URL
Comma-separated paths or URLs that scope the unique-visitor query. Each value is one exact page ($host + $pathname) — subpages are not included.
FLAG_COUNTER_DATE
UTC start date for the unique-visitor query, in the same form as PostHog SQL (toDateTime): YYYY-MM-DD HH:mm:ss (for example 2026-09-18 01:57:00) or YYYY-MM-DD for midnight UTC.
FLAG_DOCS
When set to false, hides the View Docs link from view, and swaps in the Admin Login link instead. Only used for v1 of the public deployment when I was still completing the documentation so nobody stumbled upon the incomplete guides.
FLAG_DOCSONLY
Serves only the documentation instead of the full guestbook application for a lighter weight preview when authoring docs. Can be called with npm run docs which sets FLAG_DOCSONLY=true on that process via scripts/docs-dev.mjs (overrides .env.local).
Accessibility
Along with checking pages with Chrome's Lighthouse accessibility audit, as part of pull request checks, a11y lint runs a spot check on all guestbook application pages to ensure they are somewhat accessible and any errors discovered are patched before deployment.