Every system on this site explained in full — no hand-waving, no buzzwords. How the database works, how data flows, why each decision was made. Nothing is hidden.
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.
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.
| 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 |
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 |
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.
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".
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.
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.
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:
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.
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.
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 (💀 🔥 👾 [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.
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.
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.
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.
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.
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).
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.
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.
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":
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.
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.
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.
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.
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.
| Feature | How 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. |
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.
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.
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 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 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.
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.
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.
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:
| Sound | How 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.
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.
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.
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.
| Service | What it hosts | Cost |
|---|---|---|
| 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 |