(getStoredTheme);
+ const resolvedTheme = theme === "system" ? getSystemTheme() : theme;
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(storageKey, theme);
+
+ const root = document.documentElement;
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+
+ const applyTheme = (next: "light" | "dark") => {
+ root.classList.toggle("dark", next === "dark");
+ };
+
+ applyTheme(theme === "system" ? getSystemTheme() : theme);
+
+ const handleChange = () => {
+ if (theme === "system") {
+ applyTheme(getSystemTheme());
+ }
+ };
+
+ mediaQuery.addEventListener("change", handleChange);
+ return () => mediaQuery.removeEventListener("change", handleChange);
+ }, [theme]);
+
+ const value = useMemo(
+ () => ({ theme, resolvedTheme, setTheme }),
+ [theme, resolvedTheme],
+ );
+
+ return (
+ {children}
+ );
+}
+
+const useTheme = () => {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error("useTheme must be used within ThemeProvider");
+ }
+ return context;
+};
+
+export { ThemeProvider, useTheme, type Theme };
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 00000000..ac680b30
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 00000000..409e6d93
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,18 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import { ThemeProvider } from "@/components/theme-provider";
+import "./index.css";
+import App from "./App.tsx";
+
+const root = document.getElementById("root");
+if (!root) throw new Error("Failed to find the root element");
+createRoot(root).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/src/projects/factor-e/index.tsx b/src/projects/factor-e/index.tsx
new file mode 100644
index 00000000..60be5637
--- /dev/null
+++ b/src/projects/factor-e/index.tsx
@@ -0,0 +1,104 @@
+import { Demo } from "@/components/Demo";
+import { ProjectPage } from "@/components/ProjectPage";
+
+export const metadata = {
+ title: "factor-e",
+ description:
+ "Isometric factory sandbox prototype in C++/raylib with procedural worlds, tile building, inventory & tools.",
+ date: "August 2025",
+ slug: "factor-e",
+ image: "/factor-e-icon.svg",
+ github: "https://github.com/hex248/factor-e",
+ hidden: false,
+ tags: ["Game", "C++", "OpenGL", "CMake", "Pixel Art"],
+ type: "personal",
+};
+
+export function FactorEProject() {
+ return (
+
+
+ "factor-e" is an isometric factory sandbox prototype I built to learn
+ C++ and{" "}
+
+ raylib
+
+ . Inspired by Minecraft and{" "}
+
+ Terrafactor
+
+ , it explores tile-based building, inventory management and procedural
+ world generation.
+
+
+
+
+
+ Key features
+
+
+ - Isometric rendering with my own pixel art
+ - Procedural world generation using Perlin noise
+ - Simple tile place/destroy loop
+ - Basic inventory and tool system
+ - Dev/debug overlay
+ - Cross-platform builds (Windows + Linux)
+ -
+ Status: active prototype
+
+
+
+
+
+
+ Technologies
+
+
+ - C++
+ - raylib (OpenGL)
+ - CMake
+ - Perlin noise generation
+ - Aseprite
+ - Engine-less game development
+
+
+
+
+
+
+ );
+}
diff --git a/src/projects/flackie/index.tsx b/src/projects/flackie/index.tsx
new file mode 100644
index 00000000..31f07fd9
--- /dev/null
+++ b/src/projects/flackie/index.tsx
@@ -0,0 +1,77 @@
+import { Demo } from "@/components/Demo";
+import { ProjectPage } from "@/components/ProjectPage";
+
+export const metadata = {
+ title: "flackie",
+ description:
+ "A portable FLAC player built with C++ and Python for Raspberry Pi. Custom UI, hardware controls, e-ink display, and a 3D printed case.",
+ date: "October 2025",
+ slug: "flackie",
+ image: "/flackie-icon.svg",
+ github: "https://github.com/hex248/flackie",
+ hidden: true,
+ tags: [
+ "Raspberry Pi",
+ "Python",
+ "C++",
+ "CMake",
+ "Electronics",
+ "Pillow",
+ "Image Generation",
+ ],
+ type: "personal",
+};
+
+export function FlackieProject() {
+ return (
+
+
+ "flackie" is a portable FLAC music player I built using a Raspberry Pi
+ Zero 2 W, a small e-ink display, and some physical buttons. The device
+ features a custom Python UI for browsing and playing FLAC files. The
+ case was designed in CAD and 3D printed to house all the components
+ neatly.
+
+
+
+
+
+ Key features
+
+
+ - Portable design with a compact form factor
+ - Custom Python UI for easy navigation
+ - Physical buttons for playback control
+ - 3D printed case
+ - Supports FLAC audio playback
+
+
+
+
+
+ Technologies
+
+
+ - C++
+ - CMake
+ - Python
+ - Pillow
+ - Raspberry Pi Zero 2 W
+ - E-ink display
+ - 3D printing
+
+
+
+
+
+
+ );
+}
diff --git a/src/projects/fonts/index.tsx b/src/projects/fonts/index.tsx
new file mode 100644
index 00000000..26038eff
--- /dev/null
+++ b/src/projects/fonts/index.tsx
@@ -0,0 +1,37 @@
+import { Demo } from "@/components/Demo";
+import { ProjectPage } from "@/components/ProjectPage";
+
+export const metadata = {
+ title: "fonts.ob248.com",
+ description: "A lightweight site for browsing and using my go-to fonts.",
+ date: "February 2026",
+ slug: "fonts",
+ image: "/fonts.svg",
+ url: "https://fonts.ob248.com",
+ hidden: false,
+ tags: ["Web", "Typography", "Hono", "HTML", "Bun"],
+ type: "personal",
+};
+
+export function FontsProject() {
+ return (
+
+
+ fonts.ob248.com is a lightweight site for browsing and using my go-to
+ fonts. It simplifies the importing processign for .ttf and .otf fonts on
+ the web.
+
+
+
+
+ );
+}
diff --git a/src/projects/glimpse/index.tsx b/src/projects/glimpse/index.tsx
new file mode 100644
index 00000000..29228b3a
--- /dev/null
+++ b/src/projects/glimpse/index.tsx
@@ -0,0 +1,104 @@
+import { Demo } from "@/components/Demo";
+import { ProjectPage } from "@/components/ProjectPage";
+
+export const metadata = {
+ title: "glimpse",
+ description: "Simple social media app inspired by early Instagram.",
+ date: "May 2025",
+ slug: "glimpse",
+ image: "/glimpse-icon.svg",
+ url: "https://glimpse.ob248.com",
+ github: "https://github.com/hex248/glimpse",
+ hidden: false,
+ tags: [
+ "Web",
+ "React",
+ "TypeScript",
+ "PostgreSQL",
+ "Blob Storage",
+ "Databases",
+ "OAuth2",
+ ],
+ type: "personal",
+};
+
+export function GlimpseProject() {
+ return (
+
+
+ "glimpse" is a full-stack social app for sharing photos with friends and
+ building real community. Early Instagram and tumblr were huge
+ inspirations, no influencers and brands, just keeping up with your
+ friends and family. Sign in with Google, and immediately access a
+ dynamic feed, view and comment on posts. Choose your profile colour, and
+ enable push notifications for new posts, comments, and friend requests.
+
+
+
+
+ Key features
+
+
+ - Photo uploads with caption and cropping function
+ - User profiles with customisable colour themes
+ - Dynamic, server-rendered feed of friends' photos
+ - Commenting on posts
+ - User search
+ - Push notifications
+
+
+
+
+
+ Technologies
+
+
+ - Next.js + TypeScript
+ - Prisma ORM + PostgreSQL
+ - Tailwind CSS
+ - Google OAuth with NextAuth.js
+ - Web Push API
+ - Next.js server-side rendering and API routes
+ - Progressive Web App (PWA)
+
+
+
+
+
+
Screenshots
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/projects/good-morning/index.tsx b/src/projects/good-morning/index.tsx
new file mode 100644
index 00000000..52a673f9
--- /dev/null
+++ b/src/projects/good-morning/index.tsx
@@ -0,0 +1,106 @@
+import { Demo } from "@/components/Demo";
+import { ProjectPage } from "@/components/ProjectPage";
+
+export const metadata = {
+ title: "good morning!",
+ description:
+ "An app for couples or friends to share daily notices with songs and photos",
+ date: "October 2025",
+ slug: "good-morning",
+ image: "/good-morning-icon.png",
+ // url: "https://gm.ob248.com",
+ github: "https://github.com/hex248/good-morning",
+ hidden: false,
+ tags: [
+ "Web",
+ "React",
+ "TypeScript",
+ "Go",
+ "PostgreSQL",
+ "AWS S3",
+ "Databases",
+ "OAuth2",
+ "Spotify API",
+ ],
+ type: "personal",
+};
+
+export function GoodMorningProject() {
+ return (
+
+
+ "good morning!" is a web app I built to help couples or friends share
+ daily notices, songs, and photos with each other. It features a simple
+ and intuitive interface for sending and receiving messages, along with
+ support for photo attachments. The app is built with React and
+ TypeScript on the frontend, and Go with PostgreSQL on the backend. Media
+ files are stored securely using Cloudflare R2 (AWS S3).
+
+
+
+
+
+ Key features
+
+
+ - Create daily notices with photos and Spotify songs
+ - Simple user interface
+ - Google OAuth2 authentication for user accounts
+
+
+
+
+
+ Technologies
+
+
+ - React
+ - TypeScript
+ - Go
+ - PostgreSQL
+ - Cloudflare R2 (AWS S3)
+ - Spotify API
+ - OAuth2 Authentication
+ - Progressive Web App (PWA)
+
+
+
+
+
+
+ );
+}
diff --git a/src/projects/index.ts b/src/projects/index.ts
new file mode 100644
index 00000000..f48ae3ad
--- /dev/null
+++ b/src/projects/index.ts
@@ -0,0 +1,85 @@
+import type { ComponentType } from "react";
+import { FactorEProject, metadata as factorEMetadata } from "./factor-e";
+import { FlackieProject, metadata as flackieMetadata } from "./flackie";
+import { FontsProject, metadata as fontsMetadata } from "./fonts";
+import { GlimpseProject, metadata as glimpseMetadata } from "./glimpse";
+import {
+ GoodMorningProject,
+ metadata as goodMorningMetadata,
+} from "./good-morning";
+import { MizuProject, metadata as mizuMetadata } from "./mizu";
+import { PrayerbudProject, metadata as prayerbudMetadata } from "./prayerbud";
+import { ShleepProject, metadata as shleepMetadata } from "./shleep";
+import { SprintProject, metadata as sprintMetadata } from "./sprint";
+import {
+ WatercoolerProject,
+ metadata as watercoolerMetadata,
+} from "./watercooler";
+import { WiskatronProject, metadata as wiskatronMetadata } from "./wiskatron";
+
+export type ProjectMetadata = {
+ title: string;
+ description: string;
+ date: string;
+ slug: string;
+ image?: string | null;
+ url?: string;
+ github?: string;
+ hidden: boolean;
+ tags?: string[];
+ type: string;
+};
+
+export type ProjectEntry = {
+ metadata: ProjectMetadata;
+ Component: ComponentType;
+};
+
+export const projects = {
+ [factorEMetadata.slug]: {
+ metadata: factorEMetadata,
+ Component: FactorEProject,
+ },
+ [fontsMetadata.slug]: {
+ metadata: fontsMetadata,
+ Component: FontsProject,
+ },
+ [flackieMetadata.slug]: {
+ metadata: flackieMetadata,
+ Component: FlackieProject,
+ },
+ [glimpseMetadata.slug]: {
+ metadata: glimpseMetadata,
+ Component: GlimpseProject,
+ },
+ [goodMorningMetadata.slug]: {
+ metadata: goodMorningMetadata,
+ Component: GoodMorningProject,
+ },
+ [mizuMetadata.slug]: {
+ metadata: mizuMetadata,
+ Component: MizuProject,
+ },
+ [prayerbudMetadata.slug]: {
+ metadata: prayerbudMetadata,
+ Component: PrayerbudProject,
+ },
+ [shleepMetadata.slug]: {
+ metadata: shleepMetadata,
+ Component: ShleepProject,
+ },
+ [sprintMetadata.slug]: {
+ metadata: sprintMetadata,
+ Component: SprintProject,
+ },
+ [watercoolerMetadata.slug]: {
+ metadata: watercoolerMetadata,
+ Component: WatercoolerProject,
+ },
+ [wiskatronMetadata.slug]: {
+ metadata: wiskatronMetadata,
+ Component: WiskatronProject,
+ },
+} satisfies Record;
+
+export const projectList = Object.values(projects);
diff --git a/src/projects/mizu/index.tsx b/src/projects/mizu/index.tsx
new file mode 100644
index 00000000..3d07cd9b
--- /dev/null
+++ b/src/projects/mizu/index.tsx
@@ -0,0 +1,134 @@
+import { Demo } from "@/components/Demo";
+import { ProjectPage } from "@/components/ProjectPage";
+
+export const metadata = {
+ title: "MIZU",
+ description:
+ "A discord bot card trading and collection game. (Currently inactive, 4000+ players) ",
+ date: "2021 - 2024",
+ slug: "mizu",
+ image: "/mizu-icon.svg",
+ hidden: false,
+ tags: [
+ "Node.js",
+ "TypeScript",
+ "PostgreSQL",
+ "AWS S3",
+ "Discord API",
+ "Database",
+ ],
+ type: "personal",
+};
+
+export function MizuProject() {
+ return (
+
+
+ I led a four-person team to create MIZU, a popular anime trading card
+ game on Discord. In this role, I was responsible for the full lifecycle
+ of the application: designing the core architecture, building the
+ application with Node.js and TypeScript, and deploying it on a
+ self-managed VPS. We successfully scaled to serve over 4,000 players.
+ Although MIZU is no longer active, it was a significant experience in
+ leading a team and scaling a live application.
+
+
+
+
+
+ Technologies
+
+
+ - Node.js
+ - TypeScript
+ - Express.js
+ - Discord.js
+ - PostgreSQL
+ - AWS S3
+
+
+
+
+
+
Gameplay
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Pre-Production
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/projects/prayerbud/index.tsx b/src/projects/prayerbud/index.tsx
new file mode 100644
index 00000000..b9d12d55
--- /dev/null
+++ b/src/projects/prayerbud/index.tsx
@@ -0,0 +1,100 @@
+import { Demo } from "@/components/Demo";
+import { ProjectPage } from "@/components/ProjectPage";
+
+export const metadata = {
+ title: "PrayerBud",
+ description:
+ "A faith-based social platform facilitating sharing of support and prayers within communities.",
+ date: "February 2025 - Present",
+ slug: "prayerbud",
+ image: "/prayerbud-icon.svg",
+ url: "https://prayerbud.co.uk",
+ hidden: false,
+ tags: ["Web", "React", "TypeScript", "PostgreSQL", "OAuth2", "Databases"],
+ type: "professional",
+};
+
+export function PrayerbudProject() {
+ return (
+
+
+
+ Pray Together and Grow Together: Join a diverse community of
+ individuals from around the world who are passionate about prayer and
+ spiritual growth. Create and share prayer requests with your PrayerBud
+ community who are ready to offer support, encouragement, and heartfelt
+ prayers.
+
+
+ For prayer teams or churches, the app offers a streamlined way to
+ manage and organise prayer requests, ensuring that no request goes
+ unnoticed.
+
+
+
+
+
+
+ Key features
+
+
+ - Create and manage prayer networks
+ - Manage prayer communities
+ - Intimate engagement with friends and family
+ - Admin dashboard for managing users and user content
+ - Responsive design for mobile and desktop
+
+
+
+
+
+ Technologies
+
+
+ - Next.js
+ - React
+ - TypeScript
+ - PostgreSQL
+ - Node.js
+
+
+
+
+
+
Screenshots
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/projects/shleep/index.tsx b/src/projects/shleep/index.tsx
new file mode 100644
index 00000000..f6d8bef0
--- /dev/null
+++ b/src/projects/shleep/index.tsx
@@ -0,0 +1,51 @@
+import { Demo } from "@/components/Demo";
+import { ProjectPage } from "@/components/ProjectPage";
+
+export const metadata = {
+ title: "Shleep",
+ description:
+ "A couch co-op base defense game where you protect a sleepign child from nightmares.",
+ date: "February - June 2023",
+ slug: "shleep",
+ image: "/shleep-icon.svg",
+ url: "https://bigbootstudio.itch.io/shleep",
+ hidden: true,
+ tags: ["Unity", "C#", "HLSL", "Shader Graph", "Visual Effects Graph"],
+ type: "personal",
+};
+
+export function ShleepProject() {
+ return (
+
+
+ Shleep is a couch co-op base defense game where you can build towers to
+ help aid you and your party to protect a sleeping child from nightmares.
+
+
+
+
+ Technologies
+
+
+ - Unity
+ - C#
+ - HLSL
+ - Shader Graph
+ - Visual Effects Graph
+
+
+
+
+
Screenshots
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/projects/watercooler/index.tsx b/src/projects/watercooler/index.tsx
new file mode 100644
index 00000000..955126d1
--- /dev/null
+++ b/src/projects/watercooler/index.tsx
@@ -0,0 +1,69 @@
+import { Demo } from "@/components/Demo";
+import { ProjectPage } from "@/components/ProjectPage";
+
+export const metadata = {
+ title: "Watercooler",
+ description:
+ "Virtual office space for remote teams allowing quick questions and spontaneous chats.",
+ date: "March 2025",
+ slug: "watercooler",
+ image: "/watercooler-icon.svg",
+ hidden: true,
+ tags: [
+ "Web",
+ "React",
+ "TypeScript",
+ "WebRTC",
+ "LiveKit",
+ "PostgreSQL",
+ "OAuth2",
+ "Databases",
+ ],
+ type: "personal",
+};
+
+export function WatercoolerProject() {
+ return (
+
+ watercooler description here
+
+
+
+
+ Key features
+
+
+ - feature1
+ -
+ Status: active prototype
+
+
+
+
+
+
+ Technologies
+
+
+ - LiveKit (WebRTC)
+ - Next.js + TypeScript
+ - Prisma ORM + PostgreSQL
+ - Tailwind CSS
+ - Google OAuth with NextAuth.js
+ - Next.js server-side rendering and API routes
+
+
+
+
+
+
Screenshots
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/projects/wiskatron/index.tsx b/src/projects/wiskatron/index.tsx
new file mode 100644
index 00000000..3ee5a2f5
--- /dev/null
+++ b/src/projects/wiskatron/index.tsx
@@ -0,0 +1,64 @@
+import { Demo } from "@/components/Demo";
+import { ProjectPage } from "@/components/ProjectPage";
+
+export const metadata = {
+ title: "Wiskatron",
+ description: "Spotify listening activity with dynamic visuals",
+ date: "February 2024",
+ slug: "wiskatron",
+ image: "/wiskatron-icon.svg",
+ github: "https://github.com/hex248/wiskatron",
+ hidden: false,
+ tags: ["Web", "React", "TypeScript", "Spotify API", "OAuth2"],
+ type: "personal",
+};
+
+export function WiskatronProject() {
+ return (
+
+
+ Spotify listening activity web app with dynamic visuals, built with
+ Next.js.
+
+
+
+
+
+ Key features
+
+
+ - Live fetch from Spotify API
+ - OAuth 2.0 authentication
+ - Dynamic colour palette extraction
+ - Smooth song transitions
+
+
+
+
+
+ Technologies
+
+
+ - Next.js + TypeScript
+ - Spotify API
+ - OAuth 2.0 with fastify
+ - Next.js server-side rendering and API routes
+ - Colour palette extraction with node-vibrant
+
+
+
+
+
+
Screenshots
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 00000000..85bbd0e4
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ /* Tailwind */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..9bc6a8b3
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 00000000..e75109e0
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}