wipe master before merging new

This commit is contained in:
2026-02-05 17:31:10 +00:00
parent eb371bcb01
commit 177dbcc25e
117 changed files with 0 additions and 2944 deletions

View File

@@ -1,130 +0,0 @@
import { useEffect, useState } from "react";
import { Link, Route, Routes, useParams } from "react-router-dom";
import { ProjectListItem } from "@/components/ProjectListItem";
import { type ProjectEntry, projectList, projects } from "@/projects";
import { ThemeToggle } from "./components/theme-toggle";
const asciiFiles = [
"cat-sleep.txt",
"polar-bear.txt",
"sleep-cat-ascii-art.txt",
];
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/projects/:slug" element={<ProjectRoute />} />
<Route path="*" element={<NotFound />} />
</Routes>
);
}
export default App;
function Home() {
const isDevMode = import.meta.env.VITE_PUBLIC_DEV === "1";
const [asciiArt, setAsciiArt] = useState("");
const [asciiFile] = useState(
() => asciiFiles[Math.floor(Math.random() * asciiFiles.length)],
);
const sortedProjects: ProjectEntry[] = [...projectList].sort(
(a, b) =>
parseDate(b.metadata.date).getTime() -
parseDate(a.metadata.date).getTime(),
);
useEffect(() => {
let isActive = true;
fetch(`/ascii/${asciiFile}`)
.then((response) => response.text())
.then((text) => {
if (isActive) setAsciiArt(text);
});
return () => {
isActive = false;
};
}, [asciiFile]);
return (
<div className="min-h-dvh flex flex-col items-center justify-center gap-8 text-2xl px-6 py-10">
<div className="flex flex-col items-center gap-4">
{/* {asciiArt ? (
<pre className="leading-2.25">
<code className="commitmono text-[11px]">{asciiArt}</code>
</pre>
) : null} */}
<h1 className="picnic text-8xl text-balance">Oliver Bryan</h1>
</div>
<div className="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 gap-4">
{sortedProjects.map((project) => (
<ProjectListItem
key={project.metadata.slug}
metadata={project.metadata}
isDevMode={isDevMode}
/>
))}
</div>
<ThemeToggle />
</div>
);
}
function ProjectRoute() {
const { slug } = useParams();
if (!slug || !projects[slug]) return <NotFound />;
const { Component } = projects[slug];
return <Component />;
}
function NotFound() {
return (
<div className="min-h-dvh flex flex-col items-center justify-center gap-4 text-2xl">
<h1 className="text-4xl text-accent text-balance">Not found</h1>
<Link className="text-accent underline" to="/">
Go home
</Link>
</div>
);
}
function parseDate(dateStr: string): Date {
const lower = dateStr.toLowerCase();
if (lower.includes("q1")) return new Date("2023-01-01");
if (lower.includes("q2")) return new Date("2023-04-01");
if (lower.includes("q3")) return new Date("2023-07-01");
if (lower.includes("q4")) return new Date("2023-10-01");
const months: Record<string, number> = {
january: 0,
february: 1,
march: 2,
april: 3,
may: 4,
june: 5,
july: 6,
august: 7,
september: 8,
october: 9,
november: 10,
december: 11,
};
for (const [monthName, monthIndex] of Object.entries(months)) {
if (lower.includes(monthName)) {
const yearMatch = dateStr.match(/\b(20\d{2})\b/);
if (yearMatch) {
return new Date(Number.parseInt(yearMatch[1], 10), monthIndex, 1);
}
}
}
const yearMatch = dateStr.match(/\b(20\d{2})\b/);
if (yearMatch) {
return new Date(Number.parseInt(yearMatch[1], 10), 0, 1);
}
return new Date(0);
}

View File

@@ -1,30 +0,0 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
type DemoProps = {
image: string;
title: string;
type?: "boxed" | "plain";
children?: ReactNode;
};
export function Demo({ image, title, type = "plain", children }: DemoProps) {
return (
<figure
className={cn(
"w-full",
type === "boxed" && "border rounded bg-muted p-2",
)}
>
<img
src={image}
alt={title}
className={cn("w-full", type === "boxed" ? "rounded" : "rounded-md")}
/>
<figcaption className="mt-2 text-sm text-pretty">
{title}
{children}
</figcaption>
</figure>
);
}

View File

@@ -1,60 +0,0 @@
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
import type { ProjectMetadata } from "@/projects";
export function ProjectListItem({
metadata,
isDevMode = false,
}: {
metadata: ProjectMetadata;
isDevMode?: boolean;
}) {
const tags = metadata.tags ? [...metadata.tags].sort() : [];
if (metadata.hidden && !isDevMode) return null;
return (
<Link
to={`/projects/${metadata.slug}`}
className={cn(
"group block flex flex-col justify-between transition-colors duration-200 border-2 hover:border-accent",
isDevMode && metadata.hidden && "border-dashed border-accent",
)}
data-tags={tags.join(",")}
>
<div className="flex gap-4 p-4 pb-0">
<div className="w-16 h-16 flex-shrink-0">
{metadata.image ? (
<img
src={metadata.image}
alt={`${metadata.title} icon`}
className="w-full h-full object-cover rounded"
/>
) : (
<div className="w-full h-full border rounded" />
)}
</div>
<div className="flex flex-col gap-2">
<h3 className="text-lg font-500 -mb-2 -mt-1 text-accent text-balance">
{metadata.title}
</h3>
<p className="text-sm text-fg text-pretty">{metadata.description}</p>
{tags.length > 0 ? (
<div className="flex gap-1.5 text-xs flex-wrap leading-3 items-center mb-1 no-select">
{tags.map((tag) => (
<span
key={tag}
className="flex items-center text-fg font-500 rounded-sm border px-1.5 py-0.5"
>
{tag}
</span>
))}
</div>
) : null}
</div>
</div>
<div className="w-full flex justify-end p-2 pt-1">
<p className="text-xs group-hover:text-accent">{metadata.date}</p>
</div>
</Link>
);
}

View File

@@ -1,102 +0,0 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import type { ProjectMetadata } from "@/projects";
type ProjectPageProps = {
metadata: ProjectMetadata;
children: ReactNode;
};
export function ProjectPage({ metadata, children }: ProjectPageProps) {
const tags = metadata.tags ? [...metadata.tags].sort() : [];
return (
<div className="mx-auto w-full max-w-4xl px-6 py-10 text-md">
<Link
to="/"
className="inline-flex items-center text-sm text-green-500 hover:underline mb-4"
>
Home
</Link>
<div className="flex flex-wrap items-start justify-between gap-6 mb-4">
<div className="flex flex-col gap-2">
<h1 className="text-2xl text-accent text-balance">
{metadata.title}
</h1>
{metadata.image ? (
<img
src={metadata.image}
alt={`${metadata.title} project icon`}
className="w-24 h-24 rounded mb-2"
/>
) : (
<div className="w-24 h-24 mb-2 border rounded" />
)}
</div>
<div className="flex flex-col items-end gap-2">
{metadata.url ? (
<a
href={metadata.url}
target="_blank"
rel="noopener noreferrer"
className="link-project-page inline-block text-sm"
>
Try {metadata.title}
</a>
) : null}
</div>
</div>
<p className="text-sm mb-2">
{metadata.date}
{metadata.github ? (
<>
{" "}
-{" "}
<a
href={metadata.github}
target="_blank"
rel="noopener noreferrer"
className="text-green-500 hover:underline"
>
Source Code
</a>
</>
) : null}
</p>
{tags.length > 0 ? (
<div className="flex gap-1.5 text-sm flex-wrap leading-3 items-center mb-2 no-select">
{tags.map((tag: string) => (
<span
key={tag}
className="flex items-center font-500 rounded-sm border px-1.5 py-1"
>
{tag}
</span>
))}
</div>
) : null}
<div className="text-pretty">{children}</div>
<p className="text-center text-md mt-8 mb-4">
Oliver Bryan - {metadata.date}
{metadata.github ? (
<>
{" "}
-{" "}
<a
href={metadata.github}
target="_blank"
rel="noopener noreferrer"
className="text-green-500 hover:underline"
>
Source Code
</a>
</>
) : null}
</p>
</div>
);
}

View File

@@ -1,76 +0,0 @@
import { createContext, useContext, useEffect, useMemo, useState } from "react";
type Theme = "light" | "dark" | "system";
type ThemeContextValue = {
theme: Theme;
resolvedTheme: "light" | "dark";
setTheme: (theme: Theme) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
const storageKey = "theme";
const getStoredTheme = (): Theme => {
if (typeof window === "undefined") return "system";
const stored = window.localStorage.getItem(storageKey);
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
return "system";
};
const getSystemTheme = (): "light" | "dark" => {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(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 (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
};
export { ThemeProvider, useTheme, type Theme };

View File

@@ -1,18 +0,0 @@
import { Moon, Sun } from "@nsmr/pixelart-react";
import { useTheme } from "@/components/theme-provider";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
return (
<Button
variant="outline"
size="sm"
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? <Sun /> : <Moon />}
</Button>
);
}

View File

@@ -1,64 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-accent",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -1,155 +0,0 @@
@import url("https://fonts.ob248.com/commitmono");
@import url("https://fonts.ob248.com/ft88");
@import url("https://fonts.ob248.com/picnic");
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: #ded6c4;
--foreground: #2e2b23;
--card: #efe6d4;
--card-foreground: #2e2b23;
--popover: #f5ecdb;
--popover-foreground: #2e2b23;
--primary: #5f4a33;
--primary-foreground: #f6efe3;
--secondary: #e5dccb;
--secondary-foreground: #3a3329;
--muted: #dbd0bd;
--muted-foreground: #6e6457;
--accent: #df7126;
--accent-foreground: #5f361b;
--destructive: #b8482b;
--border: #cbbfae;
--input: #d8cfbd;
--ring: #8f7b60;
--chart-1: #c6662a;
--chart-2: #7a6a4b;
--chart-3: #8f4c2a;
--chart-4: #a4813a;
--chart-5: #4f3f2b;
--sidebar: #e8dfcf;
--sidebar-foreground: #2e2b23;
--sidebar-primary: #5f4a33;
--sidebar-primary-foreground: #f6efe3;
--sidebar-accent: #e1c9a8;
--sidebar-accent-foreground: #3a3329;
--sidebar-border: #cbbfae;
--sidebar-ring: #8f7b60;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
--background: #1b1813;
--foreground: #f6efe3;
--card: #242019;
--card-foreground: #f6efe3;
--popover: #221e17;
--popover-foreground: #f6efe3;
--primary: #e1c9a8;
--primary-foreground: #2a241b;
--secondary: #2f2a21;
--secondary-foreground: #f1e9dc;
--muted: #2b261e;
--muted-foreground: #b8ad9d;
--accent: #df7126;
--accent-foreground: #2a241b;
--destructive: #cc5a3a;
--border: #3b3328;
--input: #3f372c;
--ring: #9d8568;
--chart-1: #df7126;
--chart-2: #c39a55;
--chart-3: #a5633a;
--chart-4: #8a6a3a;
--chart-5: #f0c27b;
--sidebar: #211d17;
--sidebar-foreground: #f6efe3;
--sidebar-primary: #e1c9a8;
--sidebar-primary-foreground: #2a241b;
--sidebar-accent: #2f291f;
--sidebar-accent-foreground: #f1e9dc;
--sidebar-border: #3b3328;
--sidebar-ring: #9d8568;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
body {
font-family: "Commit Mono", monospace;
font-weight: 400;
background-color: var(--background);
color: var(--foreground);
}
.commitmono {
font-family: "Commit Mono", monospace;
font-weight: 400;
}
.ft88 {
font-family: "FT88", serif;
font-weight: 80;
}
.ft88-bold {
font-family: "FT88", serif;
font-weight: 200;
}
.picnic {
font-family: "PicNic", serif;
font-weight: 400;
}

View File

@@ -1,6 +0,0 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,18 +0,0 @@
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(
<StrictMode>
<ThemeProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</StrictMode>,
);

View File

@@ -1,104 +0,0 @@
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 (
<ProjectPage metadata={metadata}>
<p className="text-pretty">
"factor-e" is an isometric factory sandbox prototype I built to learn
C++ and{" "}
<a
href="https://www.raylib.com/"
target="_blank"
rel="noopener noreferrer"
className="link-project-page"
>
raylib
</a>
. Inspired by Minecraft and{" "}
<a
href="https://store.steampowered.com/app/3433610/Terrafactor/"
target="_blank"
rel="noopener noreferrer"
className="link-project-page"
>
Terrafactor
</a>
, it explores tile-based building, inventory management and procedural
world generation.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Key features
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Isometric rendering with my own pixel art</li>
<li>Procedural world generation using Perlin noise</li>
<li>Simple tile place/destroy loop</li>
<li>Basic inventory and tool system</li>
<li>Dev/debug overlay</li>
<li>Cross-platform builds (Windows + Linux)</li>
<li>
<span className="text-green-500">Status:</span> active prototype
</li>
</ul>
</div>
<div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>C++</li>
<li>raylib (OpenGL)</li>
<li>CMake</li>
<li>Perlin noise generation</li>
<li>Aseprite</li>
<li>Engine-less game development</li>
</ul>
</div>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Demo</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Demo
image="/images/factor-e/world-gen.gif"
title="World generation"
type="boxed"
/>
<Demo
image="/images/factor-e/pixel-art.png"
title="Pixel art"
type="boxed"
/>
<Demo
image="/images/factor-e/place-destroy.gif"
title="Place/destroy loop"
type="boxed"
/>
<Demo
image="/images/factor-e/debug-overlay.gif"
title="Dev/debug overlay"
type="boxed"
/>
</div>
</div>
</ProjectPage>
);
}

View File

@@ -1,77 +0,0 @@
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 (
<ProjectPage metadata={metadata}>
<p className="text-pretty">
"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.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Key features
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Portable design with a compact form factor</li>
<li>Custom Python UI for easy navigation</li>
<li>Physical buttons for playback control</li>
<li>3D printed case</li>
<li>Supports FLAC audio playback</li>
</ul>
</div>
<div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>C++</li>
<li>CMake</li>
<li>Python</li>
<li>Pillow</li>
<li>Raspberry Pi Zero 2 W</li>
<li>E-ink display</li>
<li>3D printing</li>
</ul>
</div>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Pictures</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Demo image="/images/flackie/1.png" title="1" type="boxed" />
<Demo image="/images/flackie/2.png" title="2" type="boxed" />
<Demo image="/images/flackie/3.png" title="3" type="boxed" />
<Demo image="/images/flackie/4.png" title="4" type="boxed" />
</div>
</div>
</ProjectPage>
);
}

View File

@@ -1,37 +0,0 @@
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 (
<ProjectPage metadata={metadata}>
<p className="mb-4 text-pretty">
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.
</p>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2>
<div className="grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-1 gap-4">
<Demo
image="/images/fonts/page.png"
title="Fonts page"
type="boxed"
/>
</div>
</div>
</ProjectPage>
);
}

View File

@@ -1,104 +0,0 @@
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 (
<ProjectPage metadata={metadata}>
<p className="mb-4 text-pretty">
"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.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Key features
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Photo uploads with caption and cropping function</li>
<li>User profiles with customisable colour themes</li>
<li>Dynamic, server-rendered feed of friends' photos</li>
<li>Commenting on posts</li>
<li>User search</li>
<li>Push notifications</li>
</ul>
</div>
<div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Next.js + TypeScript</li>
<li>Prisma ORM + PostgreSQL</li>
<li>Tailwind CSS</li>
<li>Google OAuth with NextAuth.js</li>
<li>Web Push API</li>
<li>Next.js server-side rendering and API routes</li>
<li>Progressive Web App (PWA)</li>
</ul>
</div>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<Demo
image="/images/glimpse/feed.png"
title="Feed view"
type="boxed"
/>
<Demo
image="/images/glimpse/crop.png"
title="Share - write a caption + crop"
type="boxed"
/>
<Demo
image="/images/glimpse/comments.png"
title="Comments and interactions"
type="boxed"
/>
<Demo
image="/images/glimpse/profile.png"
title="Profile (custom colours)"
type="boxed"
/>
<Demo
image="/images/glimpse/settings.png"
title="Settings"
type="boxed"
/>
<Demo
image="/images/glimpse/search.png"
title="User search and discovery"
type="boxed"
/>
</div>
</div>
</ProjectPage>
);
}

View File

@@ -1,106 +0,0 @@
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 (
<ProjectPage metadata={metadata}>
<p className="text-pretty">
"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).
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Key features
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Create daily notices with photos and Spotify songs</li>
<li>Simple user interface</li>
<li>Google OAuth2 authentication for user accounts</li>
</ul>
</div>
<div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>React</li>
<li>TypeScript</li>
<li>Go</li>
<li>PostgreSQL</li>
<li>Cloudflare R2 (AWS S3)</li>
<li>Spotify API</li>
<li>OAuth2 Authentication</li>
<li>Progressive Web App (PWA)</li>
</ul>
</div>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Demo</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Demo
image="/images/good-morning/notice.png"
title="Notice from partner"
type="boxed"
/>
<Demo
image="/images/good-morning/no-notice.png"
title="No notice from partner"
type="boxed"
/>
<Demo
image="/images/good-morning/create-notice.png"
title="Create notice"
type="boxed"
/>
<Demo
image="/images/good-morning/login-with-google.png"
title="Login with Google"
type="boxed"
/>
<Demo
image="/images/good-morning/partner-pairing.png"
title="Partner pairing"
type="boxed"
/>
<Demo
image="/images/good-morning/me.png"
title="'Me' page"
type="boxed"
/>
</div>
</div>
</ProjectPage>
);
}

View File

@@ -1,85 +0,0 @@
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<string, ProjectEntry>;
export const projectList = Object.values(projects);

View File

@@ -1,134 +0,0 @@
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 (
<ProjectPage metadata={metadata}>
<p className="text-pretty">
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.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Node.js</li>
<li>TypeScript</li>
<li>Express.js</li>
<li>Discord.js</li>
<li>PostgreSQL</li>
<li>AWS S3</li>
</ul>
</div>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Gameplay</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Demo
image="/images/mizu/card.png"
title="Card (Large image)"
type="boxed"
/>
<Demo
image="/images/mizu/card-fighter.png"
title="Card (Fighter)"
type="boxed"
/>
<Demo
image="/images/mizu/card-details.png"
title="Card (Details)"
type="boxed"
/>
<Demo
image="/images/mizu/collection1.png"
title="Collection"
type="boxed"
/>
<Demo
image="/images/mizu/collection2.png"
title="Collection with sorting and filtering"
type="boxed"
/>
<Demo
image="/images/mizu/current-trade.png"
title="Ongoing Trade"
type="boxed"
/>
<Demo
image="/images/mizu/complete-trade.png"
title="Completed Trade"
type="boxed"
/>
<Demo image="/images/mizu/forage.png" title="Forage" type="boxed" />
<Demo
image="/images/mizu/inventory.png"
title="Inventory"
type="boxed"
/>
<Demo image="/images/mizu/quests.png" title="Quests" type="boxed" />
</div>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">
Pre-Production
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<Demo
image="/images/mizu/forage-design.png"
title="Forage Design"
type="boxed"
/>
<Demo
image="/images/mizu/forage-locations.png"
title="Forage Locations"
type="boxed"
/>
<Demo
image="/images/mizu/quests-planning.png"
title="Quests Planning"
type="boxed"
/>
<Demo
image="/images/mizu/update-planning.png"
title="Update Management"
type="boxed"
/>
<Demo
image="/images/mizu/pack-planning.png"
title="Pack System"
type="boxed"
/>
</div>
</div>
</ProjectPage>
);
}

View File

@@ -1,100 +0,0 @@
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 (
<ProjectPage metadata={metadata}>
<div className="space-y-4 mb-4 text-pretty">
<p>
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.
</p>
<p>
For prayer teams or churches, the app offers a streamlined way to
manage and organise prayer requests, ensuring that no request goes
unnoticed.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Key features
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Create and manage prayer networks</li>
<li>Manage prayer communities</li>
<li>Intimate engagement with friends and family</li>
<li>Admin dashboard for managing users and user content</li>
<li>Responsive design for mobile and desktop</li>
</ul>
</div>
<div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Next.js</li>
<li>React</li>
<li>TypeScript</li>
<li>PostgreSQL</li>
<li>Node.js</li>
</ul>
</div>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<Demo
image="/images/prayerbud/pre-login.png"
title="Front page / pre-login"
type="boxed"
/>
<Demo
image="/images/prayerbud/post-login.png"
title="Post-login"
type="boxed"
/>
<Demo
image="/images/prayerbud/create-network.png"
title="Create Network"
type="boxed"
/>
<Demo
image="/images/prayerbud/welcome-to-network.png"
title="Welcome to your Network"
type="boxed"
/>
<Demo
image="/images/prayerbud/prayer-card.png"
title="Create Prayer Card"
type="boxed"
/>
<Demo
image="/images/prayerbud/dashboard.png"
title="Admin Dashboard"
type="boxed"
/>
</div>
</div>
</ProjectPage>
);
}

View File

@@ -1,51 +0,0 @@
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 (
<ProjectPage metadata={metadata}>
<p className="text-pretty">
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.
</p>
<div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Unity</li>
<li>C#</li>
<li>HLSL</li>
<li>Shader Graph</li>
<li>Visual Effects Graph</li>
</ul>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<Demo image="/images/shleep/1.png" title="1" type="boxed" />
<Demo image="/images/shleep/2.png" title="2" type="boxed" />
<Demo image="/images/shleep/3.png" title="3" type="boxed" />
<Demo image="/images/shleep/4.png" title="4" type="boxed" />
<Demo image="/images/shleep/5.png" title="5" type="boxed" />
<Demo image="/images/shleep/6.png" title="6" type="boxed" />
</div>
</div>
</ProjectPage>
);
}

View File

@@ -1,144 +0,0 @@
import { Demo } from "@/components/Demo";
import { ProjectPage } from "@/components/ProjectPage";
export const metadata = {
title: "Sprint",
description:
"A simple project management tool for developers. Born out of frustration with Jira.",
date: "December 2025 - Present",
slug: "sprint",
image: "/sprint-icon.svg",
url: "https://sprintpm.org",
github: "https://github.com/hex248/sprint",
hidden: false,
tags: [
"Web",
"React",
"TypeScript",
"Tauri",
"PostgreSQL",
"Databases",
"Bun",
],
type: "personal",
};
export function SprintProject() {
return (
<ProjectPage metadata={metadata}>
<p className="mb-4 text-pretty">
Sprint is a lightweight, self-hostable project management tool built for
developers who want simplicity over complexity. Frustrated with bloated
tools like Jira, I created Sprint to focus on what matters: tracking
tasks within organisations and projects without the overhead. Deploy it
on your own infrastructure for full control over your data, and access
it via the web or as a native desktop application via Tauri.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Key features
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Organisation and project management</li>
<li>Issue creation with titles and descriptions</li>
<li>Issue assignment to team members</li>
<li>Time tracking with start, pause, and resume timers</li>
<li>Sprint management with date ranges</li>
<li>Customizable issue statuses per organisation</li>
<li>Resizable split-pane interface</li>
<li>Role-based access: owner, admin, member</li>
<li>Avatar uploads with S3 storage</li>
<li>Native desktop app via Tauri</li>
</ul>
</div>
<div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>React + TypeScript (frontend)</li>
<li>Bun.serve + Drizzle ORM (backend)</li>
<li>PostgreSQL</li>
<li>Tailwind + shadcn/ui</li>
<li>Tauri (desktop)</li>
<li>S3 file storage (avatars)</li>
</ul>
</div>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Architecture</h2>
<p className="mb-4 text-pretty">
Sprint uses a monorepo structure with three packages: a shared package
containing database schemas and types, a Bun.serve API with Drizzle
ORM and auth middleware, and a React frontend that runs as a web app
or is bundled as a native desktop application with Tauri.
</p>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2>
<div className="grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-1 gap-4">
<Demo
image="/images/sprint/landing-1.png"
title="Landing - 1"
type="boxed"
/>
<Demo
image="/images/sprint/landing-light-1.png"
title="Landing (light) - 1"
type="boxed"
/>
<Demo
image="/images/sprint/selected-issue.png"
title="Main interface - issues list and detail pane"
type="boxed"
/>
<Demo
image="/images/sprint/filter-status.png"
title="Filter status"
type="boxed"
/>
<Demo
image="/images/sprint/create-issue.png"
title="Create issue"
type="boxed"
/>
<Demo
image="/images/sprint/sprints.png"
title="Sprints"
type="boxed"
/>
<Demo
image="/images/sprint/account-settings.png"
title="Account settings"
type="boxed"
/>
<Demo
image="/images/sprint/organisations-edit.png"
title="Organisation edit"
type="boxed"
/>
<Demo
image="/images/sprint/organisations-projects-settings.png"
title="Organisation projects settings"
type="boxed"
/>
<Demo
image="/images/sprint/organisations-issues-settings.png"
title="Organisation issues settings"
type="boxed"
/>
<Demo
image="/images/sprint/organisations-features-settings.png"
title="Organisation features settings"
type="boxed"
/>
</div>
</div>
</ProjectPage>
);
}

View File

@@ -1,69 +0,0 @@
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 (
<ProjectPage metadata={metadata}>
<p className="text-pretty">watercooler description here</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Key features
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>feature1</li>
<li>
<span className="text-green-500">Status:</span> active prototype
</li>
</ul>
</div>
<div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>LiveKit (WebRTC)</li>
<li>Next.js + TypeScript</li>
<li>Prisma ORM + PostgreSQL</li>
<li>Tailwind CSS</li>
<li>Google OAuth with NextAuth.js</li>
<li>Next.js server-side rendering and API routes</li>
</ul>
</div>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Demo image="/images/watercooler/office.png" title="Office space" />
<Demo image="/images/watercooler/idk.png" title="idk" />
<Demo image="/images/watercooler/idk.png" title="idk" />
<Demo image="/images/watercooler/idk.png" title="idk" />
</div>
</div>
</ProjectPage>
);
}

View File

@@ -1,64 +0,0 @@
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 (
<ProjectPage metadata={metadata}>
<p className="mb-4 text-pretty">
Spotify listening activity web app with dynamic visuals, built with
Next.js.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Key features
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Live fetch from Spotify API</li>
<li>OAuth 2.0 authentication</li>
<li>Dynamic colour palette extraction</li>
<li>Smooth song transitions</li>
</ul>
</div>
<div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies
</h2>
<ul className="list-disc list-inside space-y-1 text-pretty">
<li>Next.js + TypeScript</li>
<li>Spotify API</li>
<li>OAuth 2.0 with fastify</li>
<li>Next.js server-side rendering and API routes</li>
<li>Colour palette extraction with node-vibrant</li>
</ul>
</div>
</div>
<div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Demo image="/images/wiskatron/1.png" title="Example 1" />
<Demo image="/images/wiskatron/2.png" title="Example 2" />
<Demo image="/images/wiskatron/3.png" title="Example 3" />
<Demo image="/images/wiskatron/4.png" title="Example 4" />
<Demo image="/images/wiskatron/5.png" title="Example 5" />
<Demo image="/images/wiskatron/6.png" title="Example 6" />
</div>
</div>
</ProjectPage>
);
}