contents

Architecture overview

This is a fully static site — there is no server, no Node.js backend, no build step. Every HTML file is just a file. The only dynamic parts are powered by three external services: GitHub Pages for hosting, Supabase as the database, and a Cloudflare Worker as a proxy layer between them.

request flow — how your browser talks to the database
1
Your browser loads iamovi.github.io — a static HTML file served from GitHub Pages. Zero server-side code runs.
2
JavaScript in the page needs data (guestbook messages, reaction counts, visitor count). It sends a fetch() request to the Cloudflare Worker URL instead of calling Supabase directly.
3
The Cloudflare Worker receives the request, attaches the secret Supabase API key (stored securely as an environment variable), and forwards it to Supabase.
4
Supabase processes the request — reads or writes to the Postgres database — and returns a response.
5
The Worker adds CORS headers and returns the response to your browser. The page renders the data.
Why this setup? Calling Supabase directly from the browser would expose the API key in plain text in the JavaScript source. Anyone could open DevTools and grab it. The Worker hides the key completely — it lives in Cloudflare's environment variables, never in the HTML or JS.

Supabase — the database

Supabase is an open-source Firebase alternative built on top of Postgres. It provides a real database, auto-generated REST API, authentication, row-level security, and more. I'm using the free tier.

Supabase exposes every table as a REST endpoint via PostgREST — so you can query, insert, update, and delete rows using plain HTTP requests without writing any backend code. It also supports calling Postgres functions directly via /rest/v1/rpc/function_name.

Tables

Table Purpose Key columns
guestbook Stores all guestbook messages and replies id, name, message, parent_id, created_at
reactions Stores every emoji reaction on projects, blog posts, and comments id, target_type, target_id, reaction, created_at
status Single-row table holding the "ovi says —" message id, message, updated_at
visits One row per site visit session, used for the daily counter id, visited_at

Row Level Security (RLS)

Every table has RLS enabled. This is a Postgres feature that enforces access rules at the database level — even if someone bypasses the Worker and calls Supabase directly, the policies still apply.

Table Who can read Who can insert Who can update/delete
guestbook Everyone Everyone Authenticated only (admin)
reactions Everyone Everyone Not allowed
status Everyone Not allowed Authenticated only (admin)
visits Everyone Everyone Not allowed
What "authenticated" means here: Only me, logged in via the Supabase admin panel or the admin page on this site. Regular visitors are treated as "anon" — they can read and post, but cannot delete or update anything.

Cloudflare Worker — the proxy

A Cloudflare Worker is a tiny JavaScript function that runs on Cloudflare's global edge network — not on a server I own or manage. It's serverless, runs in milliseconds, and the free tier handles 100,000 requests per day.

The Worker lives at iamovi-github-io.oviren-human.workers.dev. Every API call from the site goes through /proxy on that domain, which gets stripped and forwarded to the real Supabase URL.

What the Worker actually does

cloudflare-worker/worker.js — simplified // 1. Handle CORS preflight (browsers send this before real requests) if (request.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } // 2. Strip /proxy from the path // /proxy/rest/v1/guestbook → /rest/v1/guestbook const supabasePath = url.pathname.replace('/proxy', ''); // 3. Forward to Supabase with the secret key attached // The key comes from env.SUPABASE_ANON_KEY — never hardcoded const supabaseRequest = new Request(SUPABASE_URL + supabasePath, { method: request.method, headers: { 'apikey': env.SUPABASE_ANON_KEY, 'Authorization': 'Bearer ' + env.SUPABASE_ANON_KEY, } }); // 4. Return Supabase's response + CORS headers to the browser return new Response(response.body, { headers: corsHeaders });

CORS — why it's needed

Browsers block JavaScript from fetching data from a different domain by default. This is called the Same-Origin Policy. Since the site is on iamovi.github.io and Supabase is on supabase.co, the browser would block every request without CORS headers. The Worker adds Access-Control-Allow-Origin: * to every response, telling the browser "this is fine".

Allowed methods

The Worker only allows GET, POST, OPTIONS. DELETE and PATCH are intentionally blocked — nobody can delete data from the frontend, only from the admin panel which uses Supabase's own auth.

Guestbook & threaded replies

The guestbook is a single Postgres table with a self-referential foreign key — a row can point to another row as its parent. This is how threaded replies work without a separate replies table.

guestbook table schema id bigint -- auto-incrementing primary key name text -- visitor name, defaults to 'anon' message text -- the message content parent_id bigint -- NULL = top-level, non-NULL = reply to that id created_at timestamptz -- set automatically by Postgres

How the tree is built in JavaScript

Every time the guestbook loads, the site fetches all rows at once ordered by created_at ascending. The JS then builds a tree from the flat array:

tree-building algorithm
1
Fetch all rows from Supabase in one request — SELECT * FROM guestbook ORDER BY created_at ASC
2
buildTree(all, null) — filter rows where parent_id is NULL. These are the top-level messages. Sort them newest first.
3
For each top-level message, recursively call buildTree(all, message.id) to find its children. Children are sorted oldest first (chronological reply order).
4
Each node gets a children array. The resulting tree is rendered as nested HTML — each reply level gets a left border indent.

Posting a message

When you click [ sign ], the JS sends a POST to /rest/v1/guestbook with { name, message }. No parent_id means it's a top-level post. For replies, parent_id is set to the parent message's id. Supabase inserts the row and returns a 201 status. The page then re-fetches the full guestbook to show the new message.

HTML escaping

All user content — names and messages — is run through an escapeHtml() function before being injected into the DOM. This prevents XSS attacks where someone could post a message containing <script> tags.

Deletion (admin only)

The parent_id column has ON DELETE CASCADE — if a parent message is deleted, all its replies are automatically deleted too by Postgres. Deletion is only possible via the admin panel, which requires Supabase authentication.

Reactions system

Reactions (💀 🔥 👾 [lol]) work on three things: projects, blog posts, and guestbook comments. Every reaction is a row in the reactions table with a target_type and target_id to identify what was reacted to.

reactions table schema id bigint -- auto-incrementing primary key target_type text -- 'project', 'blog', or 'comment' target_id text -- slugified project name, blog slug, or comment id reaction text -- 'skull', 'fire', 'alien', or 'lol' created_at timestamptz

Loading counts efficiently

When a page of projects or blog posts renders, all visible items are passed to loadReactionCounts() at once. It builds a single Supabase query using an OR filter to fetch reactions for all items in one request — not one request per item.

single batched query for all visible items -- equivalent SQL for 3 projects on the current page: SELECT target_type, target_id, reaction FROM reactions WHERE (target_type = 'project' AND target_id = 'genjutsu') OR (target_type = 'project' AND target_id = 'syswaifu') OR (target_type = 'project' AND target_id = 'keys-and-fingers');

The returned rows are tallied in JS into a counts object, then each reaction button's count span is updated. This means one network request renders counts for every item on screen.

Preventing duplicate reactions — localStorage

There's no login system. Instead, when you react to something, the key r:type:id:reaction is written to localStorage. Before every reaction click, the JS checks if that key exists. If it does, the button does nothing. The button also gets a reacted CSS class so it appears filled-in.

Honest limitation: localStorage is per-browser per-device. If you clear your browser storage, open a private window, or use a different device, you can react again. The system is designed to be lightweight, not airtight. There's no IP tracking or fingerprinting — that felt unnecessarily invasive for a personal site.

Optimistic UI

When you click a reaction button, the count increments immediately in the UI before the network request completes. This makes the site feel instant. If the POST fails, the count rolls back and the button un-fills. If it succeeds, the count stays as-is.

Status bar

The "ovi says —" bar at the top of the projects section is powered by a single-row Postgres table. There is exactly one row, and only I can update it (via the admin panel or Supabase dashboard).

status table schema id bigint -- always 1 message text -- whatever I'm saying right now updated_at timestamptz -- auto-updated by a Postgres trigger on every change

On page load, the JS fetches SELECT message FROM status LIMIT 1. If the message is empty or the row doesn't exist, the bar stays hidden. If there's a message, it appears. Simple.

The updated_at column is maintained by a Postgres trigger — a function that fires automatically before every UPDATE and sets updated_at = now(). I never have to remember to update it manually.

Visitor counter

The "today — N visits" counter below the status bar tracks how many sessions have visited the site today. Each visit is a row in the visits table.

visits table schema id bigint -- auto-incrementing primary key visited_at timestamptz -- timestamp set automatically by Postgres (default now())

How a visit is recorded

visit recording flow
1
Page loads. JS checks sessionStorage for the key visit_recorded.
2
If it exists — you've already been counted this session (e.g. you refreshed). Nothing happens.
3
If it doesn't exist — POST an empty body to /rest/v1/visits. Postgres inserts a row with visited_at = now() automatically. Sets visit_recorded = 1 in sessionStorage.
4
On success, call loadVisitorCount() to fetch and display the updated count.

How the count is calculated — RPC function

The count is not computed on the client. Instead, the JS calls a Postgres function via /rest/v1/rpc/get_today_visit_count. The function runs on the database server and uses Bangladesh time (Asia/Dhaka, UTC+6) to determine "today":

get_today_visit_count() — postgres function SELECT count(*) FROM visits WHERE (visited_at AT TIME ZONE 'Asia/Dhaka')::date = (now() AT TIME ZONE 'Asia/Dhaka')::date;

This means "today" resets at midnight Bangladesh time for everyone in the world, regardless of the visitor's local timezone. It returns a plain number, which the JS displays directly.

Why use an RPC function instead of a client-side date filter? If the browser computed "today's date" and sent it in the query, the result would depend on the visitor's system clock and timezone. Using a server-side function means one authoritative time source — the database — determines what counts as "today".

sessionStorage vs localStorage

sessionStorage is used here, not localStorage. The difference: sessionStorage clears when you close the tab. So if you close the site and come back, you get counted again. Each browser tab session = one visit. This is intentional — it counts real visits, not unique people forever.

Cron job — daily cleanup

The visits table would grow forever if nothing cleaned it up. A Postgres cron job runs automatically every day at midnight Bangladesh time and deletes all rows from previous days.

This is powered by the pg_cron extension — a Postgres extension that lets you schedule SQL queries to run on a schedule, like a cron job but inside the database itself. Supabase supports it on all plans.

cron job definition -- runs at 18:00 UTC every day (= 00:00 Asia/Dhaka) select cron.schedule( 'delete-old-visits', '0 18 * * *', $$ DELETE FROM visits WHERE (visited_at AT TIME ZONE 'Asia/Dhaka')::date < (now() AT TIME ZONE 'Asia/Dhaka')::date; $$ );

The cron schedule 0 18 * * * means: at minute 0 of hour 18, every day, every month, every day of the week. 18:00 UTC is exactly midnight in Bangladesh (UTC+6). The delete condition mirrors the RPC function — it uses Asia/Dhaka time so "yesterday" is calculated in the same timezone as the counter.

Result: The visits table never holds more than one day's worth of rows. The database stays tiny. No manual cleanup ever needed.

Admin panel

The admin panel lives at /admin/ and is a simple password-protected page that lets me manage the site's dynamic content. It is marked noindex so search engines don't index it.

Authentication works via Supabase Auth — I log in with email and password, which returns a JWT token. That token is included in all subsequent requests as the Authorization header. This is what makes auth.role() = 'authenticated' evaluate to true in the RLS policies — allowing delete and update operations that anonymous users can't perform.

What the admin panel can do

FeatureHow it works
Update status message PATCH to /rest/v1/status with the new message. The Postgres trigger auto-updates updated_at.
Delete guestbook messages DELETE to /rest/v1/guestbook?id=eq.{id} — cascades to all child replies automatically.
View all messages Same fetch as the public guestbook, but with auth token — no functional difference since read is public anyway.

Frontend — no frameworks

The entire frontend is vanilla HTML, CSS, and JavaScript. No React, no Vue, no build step, no node_modules. The page is a single HTML file with all sections in the DOM — sections are shown or hidden with CSS display: none / block and an active class toggled by JS.

Section switching

Navigation doesn't change the URL. Instead it calls showSection('name') which removes active from all sections and adds it to the target one. The current section is saved to sessionStorage so refreshing the page restores where you were.

Skeleton screen

Before any content loads, a skeleton screen covers the full page — a shimmering placeholder that matches the layout of whatever section is active. It's built in pure JS by buildSkeleton(), injected into a fixed overlay div. Once the page fully loads, it fades out and removes itself from the DOM.

Projects — pagination and search

Projects are loaded from a static projects.json file, reversed so newest appear first, and paginated at 3 per page. Search filters the array client-side on every keystroke — no server involved. Page transitions use a CSS translate + opacity animation timed with requestAnimationFrame.

Blog

Blog post metadata (title, date, excerpt, slug) lives in blog.json. The actual post content is a separate static HTML file at /blog/post-slug/index.html. The index just renders the list; clicking "read more" navigates to the static post page.

Lazy loading

The guestbook only fetches data when you first navigate to that section — not on page load. This is done by wrapping loadGuestbook() in a patched version of showSection(). The joke panel at the bottom of the projects section uses an IntersectionObserver — it only fetches a joke when you scroll it into view.

Music player & sound engine

The music player streams an MP3 from ImageKit CDN using the browser's native Audio API. It supports play/pause, a seek-able progress bar, and a live time display updated every 100ms via setInterval.

Sound effects — zero audio files

Every UI sound effect (hover, click, menu open/close, page load) is generated in real-time using the Web Audio API — no external audio files at all. The engine creates oscillators and noise buffers programmatically:

SoundHow it's made
Page load Sawtooth sweep + noise burst + sine thud + square pop — layered and timed with delays
Menu open Bandpass noise + sawtooth frequency ramp + sine rise
Menu close Sawtooth descending + low noise
Button click High-frequency bandpass noise + square wave decay (typewriter clack)
Hover Soft sine rise + tiny noise tap — kept very quiet (vol 0.04)
Link click Slightly heavier noise + square drop

SFX can be toggled off with the [ SFX ON/OFF ] button in the menu. The toggle state is not persisted — it resets to ON on every page load.

Hosting & deployment

The site is hosted on GitHub Pages — free static hosting directly from the repository. Pushing to the main branch deploys automatically. No build pipeline, no CI/CD config needed for a fully static site.

Keep-alive workflow

Supabase pauses inactive free-tier projects after a period of inactivity. A GitHub Actions workflow (.github/workflows/keep-supabase-alive.yml) runs on a schedule and pings the database to prevent it from going idle.

Cloudflare Worker deployment

The Worker is deployed via Wrangler — Cloudflare's CLI tool. The wrangler.toml config file defines the project name and entry point. The Supabase anon key is stored as a secret via wrangler secret put SUPABASE_ANON_KEY — it never appears in any file in the repository.

ServiceWhat it hostsCost
GitHub Pages All HTML, CSS, JS, images Free
Supabase Postgres database, REST API, Auth Free tier
Cloudflare Workers API proxy (100k req/day free) Free tier
ImageKit Music file CDN Free tier
Total monthly cost: $0. The entire site runs on free tiers. The tradeoff is Supabase's free tier pauses after inactivity (solved by the keep-alive workflow) and has limits on database size and API requests — both of which a personal site will never come close to hitting.