biome: new formatting settings

This commit is contained in:
2026-02-08 07:23:36 +00:00
parent 6ef9e429dd
commit 036343a5cd
38 changed files with 2174 additions and 2170 deletions

View File

@@ -1,7 +1,11 @@
{
"css": {
"parser": {
"tailwindDirectives": true
}
}
"formatter": {
"indentStyle": "space",
"indentWidth": 2
},
"css": {
"parser": {
"tailwindDirectives": true
}
}
}

View File

@@ -1,23 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -1,42 +1,42 @@
{
"name": "ob248.com",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@nsmr/pixelart-react": "^2.0.0",
"@paper-design/shaders-react": "0.0.71",
"@tailwindcss/vite": "^4.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^6.30.1",
"simple-icons": "^16.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^25.2.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
"name": "ob248.com",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@nsmr/pixelart-react": "^2.0.0",
"@paper-design/shaders-react": "0.0.71",
"@tailwindcss/vite": "^4.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^6.30.1",
"simple-icons": "^16.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^25.2.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -1,11 +1,11 @@
import {
Downasaur,
Github,
Home as HomeIcon,
Image,
ImageDelete,
Mail,
Notes,
Downasaur,
Github,
Home as HomeIcon,
Image,
ImageDelete,
Mail,
Notes,
} from "@nsmr/pixelart-react";
import { useEffect, useState } from "react";
import { Link, Route, Routes, useNavigate, useParams } from "react-router-dom";
@@ -14,374 +14,374 @@ import { ProjectListItem } from "@/components/ProjectListItem";
import { TimeSince } from "@/components/time-since";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { type ProjectEntry, projectList, projects } from "@/projects";
import { locations, locationPhotos } from "@/travel";
import { locationPhotos, locations } from "@/travel";
import { TravelListItem } from "./components/TravelListItem";
import { ThemeToggle } from "./components/theme-toggle";
import { Button } from "./components/ui/button";
import { cn } from "./lib/utils";
const asciiFiles = [
"cat-sleep.txt",
"polar-bear.txt",
"penguin-surfboard.txt",
"cat-shock.txt",
"exclamation.txt",
"fat-cat-head.txt",
"grumpy-dog.txt",
"cat-peek.txt",
"cat-loaf.txt",
"cat-sleep.txt",
"polar-bear.txt",
"penguin-surfboard.txt",
"cat-shock.txt",
"exclamation.txt",
"fat-cat-head.txt",
"grumpy-dog.txt",
"cat-peek.txt",
"cat-loaf.txt",
];
const homeTabs = ["work", "travel"] as const;
type HomeTab = (typeof homeTabs)[number];
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/projects/:slug" element={<ProjectRoute />} />
<Route path="*" element={<NotFound />} />
</Routes>
);
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 isTabsEnabled = import.meta.env.VITE_TABS === "1";
const navigate = useNavigate();
const [asciiArt, setAsciiArt] = useState("");
const [activeTab, setActiveTab] = useState<HomeTab>("work");
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [activeLocation, setActiveLocation] = useState<number | null>(null);
const [activePhoto, setActivePhoto] = useState<string | null>(null);
const [hasPointerInteraction, setHasPointerInteraction] = useState(false);
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(),
);
const visibleProjects = sortedProjects.filter(
(project) => isDevMode || !project.metadata.hidden,
);
const isDevMode = import.meta.env.VITE_PUBLIC_DEV === "1";
const isTabsEnabled = import.meta.env.VITE_TABS === "1";
const navigate = useNavigate();
const [asciiArt, setAsciiArt] = useState("");
const [activeTab, setActiveTab] = useState<HomeTab>("work");
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [activeLocation, setActiveLocation] = useState<number | null>(null);
const [activePhoto, setActivePhoto] = useState<string | null>(null);
const [hasPointerInteraction, setHasPointerInteraction] = useState(false);
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(),
);
const visibleProjects = sortedProjects.filter(
(project) => isDevMode || !project.metadata.hidden,
);
useEffect(() => {
let isActive = true;
fetch(`/ascii/${asciiFile}`)
.then((response) => response.text())
.then((text) => {
if (isActive) setAsciiArt(text);
});
return () => {
isActive = false;
};
}, [asciiFile]);
useEffect(() => {
let isActive = true;
fetch(`/ascii/${asciiFile}`)
.then((response) => response.text())
.then((text) => {
if (isActive) setAsciiArt(text);
});
return () => {
isActive = false;
};
}, [asciiFile]);
useEffect(() => {
setActiveIndex((prev) => {
if (visibleProjects.length === 0) return null;
if (prev === null) return null;
return Math.min(prev, visibleProjects.length - 1);
});
}, [visibleProjects.length]);
useEffect(() => {
setActiveIndex((prev) => {
if (visibleProjects.length === 0) return null;
if (prev === null) return null;
return Math.min(prev, visibleProjects.length - 1);
});
}, [visibleProjects.length]);
useEffect(() => {
if (visibleProjects.length === 0) return;
useEffect(() => {
if (visibleProjects.length === 0) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isTabsEnabled && event.key === "Tab") {
event.preventDefault();
setActiveTab((prev) => {
const currentIndex = homeTabs.indexOf(prev);
const safeIndex = currentIndex === -1 ? 0 : currentIndex;
const nextIndex = (safeIndex + 1) % homeTabs.length;
return homeTabs[nextIndex];
});
return;
}
if (isTabsEnabled && event.key === "Tab") {
event.preventDefault();
setActiveTab((prev) => {
const currentIndex = homeTabs.indexOf(prev);
const safeIndex = currentIndex === -1 ? 0 : currentIndex;
const nextIndex = (safeIndex + 1) % homeTabs.length;
return homeTabs[nextIndex];
});
return;
}
if (isInteractiveTarget(event.target)) return;
if (isInteractiveTarget(event.target)) return;
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
const isDesktop = window.matchMedia("(min-width: 768px)").matches;
const columns = isDesktop ? 2 : 1;
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
const isDesktop = window.matchMedia("(min-width: 768px)").matches;
const columns = isDesktop ? 2 : 1;
let delta = 0;
if (key === "ArrowLeft" || key === "h") delta = -1;
if (key === "ArrowRight" || key === "l") delta = 1;
if (key === "ArrowUp" || key === "k") delta = -columns;
if (key === "ArrowDown" || key === "j") delta = columns;
let delta = 0;
if (key === "ArrowLeft" || key === "h") delta = -1;
if (key === "ArrowRight" || key === "l") delta = 1;
if (key === "ArrowUp" || key === "k") delta = -columns;
if (key === "ArrowDown" || key === "j") delta = columns;
if (delta !== 0) {
event.preventDefault();
setActiveIndex((prev) => {
if (prev === null) return 0;
const next = Math.max(
0,
Math.min(visibleProjects.length - 1, prev + delta),
);
return next;
});
return;
}
if (delta !== 0) {
event.preventDefault();
setActiveIndex((prev) => {
if (prev === null) return 0;
const next = Math.max(
0,
Math.min(visibleProjects.length - 1, prev + delta),
);
return next;
});
return;
}
if (key === "Enter") {
if (activeIndex === null) return;
event.preventDefault();
const target = visibleProjects[activeIndex];
if (!target) return;
navigate(`/projects/${target.metadata.slug}`);
}
};
if (key === "Enter") {
if (activeIndex === null) return;
event.preventDefault();
const target = visibleProjects[activeIndex];
if (!target) return;
navigate(`/projects/${target.metadata.slug}`);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [activeIndex, navigate, visibleProjects]);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [activeIndex, navigate, visibleProjects]);
useEffect(() => {
const enablePointerInteraction = () => {
setHasPointerInteraction(true);
};
useEffect(() => {
const enablePointerInteraction = () => {
setHasPointerInteraction(true);
};
window.addEventListener("pointermove", enablePointerInteraction, {
once: true,
});
window.addEventListener("pointerdown", enablePointerInteraction, {
once: true,
});
window.addEventListener("pointermove", enablePointerInteraction, {
once: true,
});
window.addEventListener("pointerdown", enablePointerInteraction, {
once: true,
});
return () => {
window.removeEventListener("pointermove", enablePointerInteraction);
window.removeEventListener("pointerdown", enablePointerInteraction);
};
}, []);
return () => {
window.removeEventListener("pointermove", enablePointerInteraction);
window.removeEventListener("pointerdown", enablePointerInteraction);
};
}, []);
return (
<div className="min-h-dvh flex flex-col items-center gap-2 text-2xl px-6 py-10">
<div className="flex flex-col items-center gap-4 mb-4">
{asciiArt ? (
<pre className="text-[#000000] dark:text-[#ffffff] leading-1.75 tracking-[-1.75px]">
<code className="commitmono text-[11px]">{asciiArt}</code>
</pre>
) : null}
<h1 className="text-center picnic text-8xl text-balance">
Oliver Bryan
</h1>
<div className="flex flex-wrap items-center justify-center gap-3 text-base text-fg">
<a
href="https://github.com/hex248"
rel="noreferrer"
target="_blank"
className="inline-flex items-center gap-2 hover:text-accent"
>
<Github className="size-6" />
hex248
</a>
<span className="text-fg/60">/</span>
<a
href="mailto:ob248@proton.me"
className="inline-flex items-center gap-2 hover:text-accent"
>
<Mail className="size-6" />
ob248@proton.me
</a>
<span className="text-fg/60">/</span>
<a
href="/cv.pdf"
className="inline-flex items-center gap-2 hover:text-accent"
>
<Notes className="size-6" /> CV
</a>
</div>
<div className="text-base text-fg">
Age: <TimeSince date={new Date(2004, 10, 4, 11, 47, 0)} />
</div>
</div>
{isTabsEnabled ? (
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as HomeTab)}
className="w-full max-w-5xl gap-0"
>
<TabsList
variant="line"
className="relative z-0 h-auto w-full gap-0 p-0"
>
<TabsTrigger
value={homeTabs[0]}
className="border-border -mr-[1px] after:hidden data-[state=active]:text-accent"
>
Work
</TabsTrigger>
<TabsTrigger
value={homeTabs[1]}
className="border-border after:hidden data-[state=active]:text-accent"
>
Travel
</TabsTrigger>
</TabsList>
<TabsContent value={homeTabs[0]} className="relative z-10">
<div className="-mt-[1.5px] border p-2 grid grid-cols-1 gap-2 md:grid-cols-2">
{visibleProjects.map((project, index) => (
<ProjectListItem
key={project.metadata.slug}
metadata={project.metadata}
isDevMode={isDevMode}
isActive={activeIndex !== null && index === activeIndex}
enableHover={hasPointerInteraction}
/>
))}
</div>
</TabsContent>
<TabsContent value={homeTabs[1]} className="relative z-10">
<div className="-mt-[1px] grid grid-cols-1">
{locations.map((location, index) => (
<>
<TravelListItem
key={`${location.city} ${location.date}`}
metadata={location}
onClick={(_e) => {
setActivePhoto(null);
setActiveLocation((prev) =>
prev === index ? null : index,
);
}}
/>
{activeLocation === index &&
(locationPhotos[location.id].length === 0 ? (
<div className="flex">
<div className="flex flex-col flex-1 ml-8">
<Button
disabled
className={cn(
"flex text-sm border cursor-pointer hover:border-accent items-center justify-start p-0 pl-2 ",
)}
variant="dummy"
size="sm"
>
<ImageDelete size={16} /> No photos available
</Button>
</div>
</div>
) : (
<div className="flex">
<div className="flex flex-col flex-1 ml-8">
{locationPhotos[location.id].map((photo) => (
<Button
key={photo}
onClick={() => {
const path = `/travel/${location.city} ${location.country} ${location.date}/${photo}`;
setActivePhoto((prev) =>
prev === path ? null : path,
);
}}
className={cn(
"flex text-sm border cursor-pointer hover:border-accent items-center justify-start p-0 pl-2 ",
)}
variant="dummy"
size="sm"
>
<Image size={22} />
{photo}
</Button>
))}
</div>
{activePhoto ? (
<img
className={"flex-1 max-w-sm"}
src={activePhoto}
alt={"active-photo"}
/>
) : (
<div
className={
"flex-1 max-w-sm border flex items-center justify-center text-sm gap-4"
}
>
<ImageDelete />
No photo selected
</div>
)}
</div>
))}
</>
))}
</div>
</TabsContent>
</Tabs>
) : (
<div className="w-full max-w-5xl grid grid-cols-1 gap-2 md:grid-cols-2">
{visibleProjects.map((project, index) => (
<ProjectListItem
key={project.metadata.slug}
metadata={project.metadata}
isDevMode={isDevMode}
isActive={activeIndex !== null && index === activeIndex}
enableHover={hasPointerInteraction}
/>
))}
</div>
)}
<div className="w-full max-w-5xl grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] items-center gap-3 md:gap-4">
<div className="flex items-center gap-6">
<AskAI name="me" inline />
</div>
<p className="text-xs text-fg/80 text-center text-pretty">
arrows or hjkl, then enter
</p>
<div className="justify-self-center md:justify-self-end">
<ThemeToggle />
</div>
</div>
</div>
);
return (
<div className="min-h-dvh flex flex-col items-center gap-2 text-2xl px-6 py-10">
<div className="flex flex-col items-center gap-4 mb-4">
{asciiArt ? (
<pre className="text-[#000000] dark:text-[#ffffff] leading-1.75 tracking-[-1.75px]">
<code className="commitmono text-[11px]">{asciiArt}</code>
</pre>
) : null}
<h1 className="text-center picnic text-8xl text-balance">
Oliver Bryan
</h1>
<div className="flex flex-wrap items-center justify-center gap-3 text-base text-fg">
<a
href="https://github.com/hex248"
rel="noreferrer"
target="_blank"
className="inline-flex items-center gap-2 hover:text-accent"
>
<Github className="size-6" />
hex248
</a>
<span className="text-fg/60">/</span>
<a
href="mailto:ob248@proton.me"
className="inline-flex items-center gap-2 hover:text-accent"
>
<Mail className="size-6" />
ob248@proton.me
</a>
<span className="text-fg/60">/</span>
<a
href="/cv.pdf"
className="inline-flex items-center gap-2 hover:text-accent"
>
<Notes className="size-6" /> CV
</a>
</div>
<div className="text-base text-fg">
Age: <TimeSince date={new Date(2004, 10, 4, 11, 47, 0)} />
</div>
</div>
{isTabsEnabled ? (
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as HomeTab)}
className="w-full max-w-5xl gap-0"
>
<TabsList
variant="line"
className="relative z-0 h-auto w-full gap-0 p-0"
>
<TabsTrigger
value={homeTabs[0]}
className="border-border -mr-[1px] after:hidden data-[state=active]:text-accent"
>
Work
</TabsTrigger>
<TabsTrigger
value={homeTabs[1]}
className="border-border after:hidden data-[state=active]:text-accent"
>
Travel
</TabsTrigger>
</TabsList>
<TabsContent value={homeTabs[0]} className="relative z-10">
<div className="-mt-[1.5px] border p-2 grid grid-cols-1 gap-2 md:grid-cols-2">
{visibleProjects.map((project, index) => (
<ProjectListItem
key={project.metadata.slug}
metadata={project.metadata}
isDevMode={isDevMode}
isActive={activeIndex !== null && index === activeIndex}
enableHover={hasPointerInteraction}
/>
))}
</div>
</TabsContent>
<TabsContent value={homeTabs[1]} className="relative z-10">
<div className="-mt-[1px] grid grid-cols-1">
{locations.map((location, index) => (
<>
<TravelListItem
key={`${location.city} ${location.date}`}
metadata={location}
onClick={(_e) => {
setActivePhoto(null);
setActiveLocation((prev) =>
prev === index ? null : index,
);
}}
/>
{activeLocation === index &&
(locationPhotos[location.id].length === 0 ? (
<div className="flex">
<div className="flex flex-col flex-1 ml-8">
<Button
disabled
className={cn(
"flex text-sm border cursor-pointer hover:border-accent items-center justify-start p-0 pl-2 ",
)}
variant="dummy"
size="sm"
>
<ImageDelete size={16} /> No photos available
</Button>
</div>
</div>
) : (
<div className="flex">
<div className="flex flex-col flex-1 ml-8">
{locationPhotos[location.id].map((photo) => (
<Button
key={photo}
onClick={() => {
const path = `/travel/${location.city} ${location.country} ${location.date}/${photo}`;
setActivePhoto((prev) =>
prev === path ? null : path,
);
}}
className={cn(
"flex text-sm border cursor-pointer hover:border-accent items-center justify-start p-0 pl-2 ",
)}
variant="dummy"
size="sm"
>
<Image size={22} />
{photo}
</Button>
))}
</div>
{activePhoto ? (
<img
className={"flex-1 max-w-sm"}
src={activePhoto}
alt={"active-photo"}
/>
) : (
<div
className={
"flex-1 max-w-sm border flex items-center justify-center text-sm gap-4"
}
>
<ImageDelete />
No photo selected
</div>
)}
</div>
))}
</>
))}
</div>
</TabsContent>
</Tabs>
) : (
<div className="w-full max-w-5xl grid grid-cols-1 gap-2 md:grid-cols-2">
{visibleProjects.map((project, index) => (
<ProjectListItem
key={project.metadata.slug}
metadata={project.metadata}
isDevMode={isDevMode}
isActive={activeIndex !== null && index === activeIndex}
enableHover={hasPointerInteraction}
/>
))}
</div>
)}
<div className="w-full max-w-5xl grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] items-center gap-3 md:gap-4">
<div className="flex items-center gap-6">
<AskAI name="me" inline />
</div>
<p className="text-xs text-fg/80 text-center text-pretty">
arrows or hjkl, then enter
</p>
<div className="justify-self-center md:justify-self-end">
<ThemeToggle />
</div>
</div>
</div>
);
}
function isInteractiveTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tagName = target.tagName;
return (
tagName === "INPUT" ||
tagName === "TEXTAREA" ||
tagName === "SELECT" ||
tagName === "BUTTON" ||
tagName === "A"
);
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tagName = target.tagName;
return (
tagName === "INPUT" ||
tagName === "TEXTAREA" ||
tagName === "SELECT" ||
tagName === "BUTTON" ||
tagName === "A"
);
}
function ProjectRoute() {
const { slug } = useParams();
if (!slug || !projects[slug]) return <NotFound />;
const { slug } = useParams();
if (!slug || !projects[slug]) return <NotFound />;
const { Component } = projects[slug];
return <Component />;
const { Component } = projects[slug];
return <Component />;
}
function NotFound() {
return (
<div
className={`w-full h-[100vh] flex flex-col items-center justify-center gap-4`}
>
<span className="-ml-14 -mb-7 -rotate-20 text-xl text-accent">?</span>
<Downasaur size={72} className="text-accent" />
<span className="text-7xl">404</span>
<span className="text-2xl">Not Found</span>
return (
<div
className={`w-full h-[100vh] flex flex-col items-center justify-center gap-4`}
>
<span className="-ml-14 -mb-7 -rotate-20 text-xl text-accent">?</span>
<Downasaur size={72} className="text-accent" />
<span className="text-7xl">404</span>
<span className="text-2xl">Not Found</span>
<Link to="/">
<HomeIcon className="size-12 hover:text-accent" />
</Link>
</div>
);
<Link to="/">
<HomeIcon className="size-12 hover:text-accent" />
</Link>
</div>
);
}
// function NotFound() {
@@ -396,41 +396,41 @@ function NotFound() {
// }
function parseDate(dateStr: string): Date {
const lower = dateStr.toLowerCase();
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");
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,
};
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);
}
}
}
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);
}
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);
return new Date(0);
}

View File

@@ -2,29 +2,29 @@ import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
type DemoProps = {
image: string;
title: string;
type?: "boxed" | "plain";
children?: ReactNode;
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>
);
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

@@ -3,69 +3,69 @@ import { cn } from "@/lib/utils";
import type { ProjectMetadata } from "@/projects";
export function ProjectListItem({
metadata,
isDevMode = false,
isActive = false,
enableHover = true,
metadata,
isDevMode = false,
isActive = false,
enableHover = true,
}: {
metadata: ProjectMetadata;
isDevMode?: boolean;
isActive?: boolean;
enableHover?: boolean;
metadata: ProjectMetadata;
isDevMode?: boolean;
isActive?: boolean;
enableHover?: boolean;
}) {
const tags = metadata.tags ? [...metadata.tags].sort() : [];
if (metadata.hidden && !isDevMode) return null;
const tags = metadata.tags ? [...metadata.tags].sort() : [];
if (metadata.hidden && !isDevMode) return null;
return (
<Link
to={`/projects/${metadata.slug}`}
className={cn(
"group relative block flex flex-col justify-between transition-colors duration-200 border-2",
enableHover && "hover:border-accent",
isActive && "border-accent",
isDevMode && metadata.hidden && "border-dashed border-accent",
)}
data-tags={tags.join(",")}
>
{metadata.type === "professional" ? (
<span className="absolute -right-0.5 -top-0.5 bg-accent px-1.5 py-0.5 text-xs font-500 text-background">
Professional
</span>
) : null}
<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>
);
return (
<Link
to={`/projects/${metadata.slug}`}
className={cn(
"group relative block flex flex-col justify-between transition-colors duration-200 border-2",
enableHover && "hover:border-accent",
isActive && "border-accent",
isDevMode && metadata.hidden && "border-dashed border-accent",
)}
data-tags={tags.join(",")}
>
{metadata.type === "professional" ? (
<span className="absolute -right-0.5 -top-0.5 bg-accent px-1.5 py-0.5 text-xs font-500 text-background">
Professional
</span>
) : null}
<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

@@ -6,150 +6,150 @@ import type { ProjectMetadata } from "@/projects";
import { AskAI } from "./ask-ai";
export function ProjectPage({
metadata,
children,
metadata,
children,
}: {
metadata: ProjectMetadata;
children: ReactNode;
metadata: ProjectMetadata;
children: ReactNode;
}) {
const navigate = useNavigate();
const tags = metadata.tags ? [...metadata.tags].sort() : [];
const navigate = useNavigate();
const tags = metadata.tags ? [...metadata.tags].sort() : [];
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isInteractiveTarget(event.target)) return;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isInteractiveTarget(event.target)) return;
if (
event.key === "Escape" ||
event.key === "Backspace" ||
event.key === "q"
) {
event.preventDefault();
navigate("/");
}
};
if (
event.key === "Escape" ||
event.key === "Backspace" ||
event.key === "q"
) {
event.preventDefault();
navigate("/");
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [navigate]);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [navigate]);
return (
<div className="relative mx-auto w-full max-w-4xl px-6 py-4 text-md border my-8">
<p className="absolute top-4 right-6 text-xs text-fg/75">
esc or backspace to go back
</p>
<Link
to="/"
className="inline-flex items-center text-sm hover:text-accent 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="ml-auto flex flex-col items-end text-right">
<AskAI
name={metadata.title}
prompt={getProjectPrompt(
metadata.title,
metadata.description,
metadata.slug,
)}
/>
</div>
</div>
{metadata.url ? (
<div className="flex flex-col mb-2">
<a
href={metadata.url}
target="_blank"
rel="noopener noreferrer"
className="link-project-page inline-block text-accent hover:underline underline-offset-2 text-sm"
>
Try {metadata.title}
</a>
</div>
) : null}
return (
<div className="relative mx-auto w-full max-w-4xl px-6 py-4 text-md border my-8">
<p className="absolute top-4 right-6 text-xs text-fg/75">
esc or backspace to go back
</p>
<Link
to="/"
className="inline-flex items-center text-sm hover:text-accent 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="ml-auto flex flex-col items-end text-right">
<AskAI
name={metadata.title}
prompt={getProjectPrompt(
metadata.title,
metadata.description,
metadata.slug,
)}
/>
</div>
</div>
{metadata.url ? (
<div className="flex flex-col mb-2">
<a
href={metadata.url}
target="_blank"
rel="noopener noreferrer"
className="link-project-page inline-block text-accent hover:underline underline-offset-2 text-sm"
>
Try {metadata.title}
</a>
</div>
) : null}
<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>
<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}
{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>
<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>
);
<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>
);
}
function isInteractiveTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tagName = target.tagName;
return (
tagName === "INPUT" ||
tagName === "TEXTAREA" ||
tagName === "SELECT" ||
tagName === "BUTTON" ||
tagName === "A"
);
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tagName = target.tagName;
return (
tagName === "INPUT" ||
tagName === "TEXTAREA" ||
tagName === "SELECT" ||
tagName === "BUTTON" ||
tagName === "A"
);
}

View File

@@ -1,25 +1,25 @@
import type { TravelMetadata } from "@/travel";
import { Button } from "./ui/button";
import type { MouseEventHandler } from "react";
import { cn } from "@/lib/utils";
import type { TravelMetadata } from "@/travel";
import { Button } from "./ui/button";
export function TravelListItem({
metadata,
onClick,
metadata,
onClick,
}: {
metadata: TravelMetadata;
onClick: MouseEventHandler<HTMLButtonElement>;
metadata: TravelMetadata;
onClick: MouseEventHandler<HTMLButtonElement>;
}) {
return (
<Button
className={cn(
"text-sm border cursor-pointer hover:border-accent justify-start",
)}
onClick={onClick}
variant="dummy"
size="sm"
>
{metadata.city}, {metadata.country} - {metadata.date}
</Button>
);
return (
<Button
className={cn(
"text-sm border cursor-pointer hover:border-accent justify-start",
)}
onClick={onClick}
variant="dummy"
size="sm"
>
{metadata.city}, {metadata.country} - {metadata.date}
</Button>
);
}

View File

@@ -8,81 +8,81 @@ const chatGptUrl = "https://chat.openai.com/?q=";
const claudeUrl = "https://claude.ai/new?q=";
export function AskAI({
name,
prompt = AI_SUMMARY_PROMPT,
inline = false,
name,
prompt = AI_SUMMARY_PROMPT,
inline = false,
}: {
name: string;
prompt?: string;
inline?: boolean;
name: string;
prompt?: string;
inline?: boolean;
}) {
const encodedPrompt = encodeURIComponent(prompt);
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | null>(null);
const encodedPrompt = encodeURIComponent(prompt);
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | null>(null);
const handleCopy = async () => {
if (!navigator.clipboard) return;
try {
await navigator.clipboard.writeText(prompt);
setCopied(true);
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
setCopied(false);
}, 1500);
} catch {
setCopied(false);
}
};
const handleCopy = async () => {
if (!navigator.clipboard) return;
try {
await navigator.clipboard.writeText(prompt);
setCopied(true);
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
setCopied(false);
}, 1500);
} catch {
setCopied(false);
}
};
return (
<div
className={cn(
"flex flex-col items-end gap-2",
inline && "flex-row items-center gap-4",
)}
>
<p className="text-fg text-lg text-pretty">Ask AI about {name}:</p>
<div className="flex items-center gap-4">
<a
href={chatGptUrl + encodedPrompt}
target="_blank"
rel="noopener noreferrer"
className="text-fg hover:text-accent"
title={"Ask ChatGPT"}
>
<Icon icon="simple-icons:openai" className="size-6" />
</a>
<a
href={`${claudeUrl}${encodedPrompt}`}
target="_blank"
rel="noopener noreferrer"
className="text-fg hover:text-accent"
title="Ask Claude"
>
<Icon icon="simple-icons:claude" className="size-6" />
</a>
<div className="relative flex items-center">
<button
type="button"
onClick={handleCopy}
className="text-fg hover:text-accent cursor-pointer flex items-center"
title="Copy prompt to clipboard"
aria-label="Copy prompt to clipboard"
>
<Copy className="size-6" />
</button>
<span
className={cn(
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-xs bg-background border opacity-0 pointer-events-none whitespace-nowrap",
copied && "opacity-100",
)}
>
Copied to clipboard
</span>
</div>
</div>
</div>
);
return (
<div
className={cn(
"flex flex-col items-end gap-2",
inline && "flex-row items-center gap-4",
)}
>
<p className="text-fg text-lg text-pretty">Ask AI about {name}:</p>
<div className="flex items-center gap-4">
<a
href={chatGptUrl + encodedPrompt}
target="_blank"
rel="noopener noreferrer"
className="text-fg hover:text-accent"
title={"Ask ChatGPT"}
>
<Icon icon="simple-icons:openai" className="size-6" />
</a>
<a
href={`${claudeUrl}${encodedPrompt}`}
target="_blank"
rel="noopener noreferrer"
className="text-fg hover:text-accent"
title="Ask Claude"
>
<Icon icon="simple-icons:claude" className="size-6" />
</a>
<div className="relative flex items-center">
<button
type="button"
onClick={handleCopy}
className="text-fg hover:text-accent cursor-pointer flex items-center"
title="Copy prompt to clipboard"
aria-label="Copy prompt to clipboard"
>
<Copy className="size-6" />
</button>
<span
className={cn(
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-xs bg-background border opacity-0 pointer-events-none whitespace-nowrap",
copied && "opacity-100",
)}
>
Copied to clipboard
</span>
</div>
</div>
</div>
);
}

View File

@@ -2,54 +2,54 @@ import { PaperTexture } from "@paper-design/shaders-react";
import { useTheme } from "@/components/theme-provider";
const lightTexture = {
colorFront: "#5f4a331a",
contrast: 0.3,
roughness: 0.24,
fiber: 0.1,
crumples: 0,
folds: 0,
drops: 0,
colorFront: "#5f4a331a",
contrast: 0.3,
roughness: 0.24,
fiber: 0.1,
crumples: 0,
folds: 0,
drops: 0,
};
const darkTexture = {
colorFront: "#f6efe31a",
contrast: 0.3,
roughness: 0.24,
fiber: 0.1,
crumples: 0,
folds: 0,
drops: 0,
colorFront: "#f6efe31a",
contrast: 0.3,
roughness: 0.24,
fiber: 0.1,
crumples: 0,
folds: 0,
drops: 0,
};
export function PaperTextureOverlay() {
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const texture = isDark ? darkTexture : lightTexture;
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const texture = isDark ? darkTexture : lightTexture;
return (
<PaperTexture
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-10 h-dvh w-full"
width="100%"
height="100%"
speed={0}
fit="cover"
scale={0.6}
colorBack="#00000000"
colorFront={texture.colorFront}
contrast={texture.contrast}
roughness={texture.roughness}
fiber={texture.fiber}
fiberSize={0.22}
crumples={texture.crumples}
crumpleSize={0.35}
folds={texture.folds}
foldCount={5}
drops={texture.drops}
fade={0.08}
seed={5}
minPixelRatio={1}
maxPixelCount={2304000}
/>
);
return (
<PaperTexture
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-10 h-dvh w-full"
width="100%"
height="100%"
speed={0}
fit="cover"
scale={0.6}
colorBack="#00000000"
colorFront={texture.colorFront}
contrast={texture.contrast}
roughness={texture.roughness}
fiber={texture.fiber}
fiberSize={0.22}
crumples={texture.crumples}
crumpleSize={0.35}
folds={texture.folds}
foldCount={5}
drops={texture.drops}
fade={0.08}
seed={5}
minPixelRatio={1}
maxPixelCount={2304000}
/>
);
}

View File

@@ -3,9 +3,9 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react";
type Theme = "light" | "dark" | "system";
type ThemeContextValue = {
theme: Theme;
resolvedTheme: "light" | "dark";
setTheme: (theme: Theme) => void;
theme: Theme;
resolvedTheme: "light" | "dark";
setTheme: (theme: Theme) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
@@ -13,64 +13,64 @@ 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";
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";
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;
const [theme, setTheme] = useState<Theme>(getStoredTheme);
const resolvedTheme = theme === "system" ? getSystemTheme() : theme;
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(storageKey, 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 root = document.documentElement;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const applyTheme = (next: "light" | "dark") => {
root.classList.toggle("dark", next === "dark");
};
const applyTheme = (next: "light" | "dark") => {
root.classList.toggle("dark", next === "dark");
};
applyTheme(theme === "system" ? getSystemTheme() : theme);
applyTheme(theme === "system" ? getSystemTheme() : theme);
const handleChange = () => {
if (theme === "system") {
applyTheme(getSystemTheme());
}
};
const handleChange = () => {
if (theme === "system") {
applyTheme(getSystemTheme());
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [theme]);
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [theme]);
const value = useMemo(
() => ({ theme, resolvedTheme, setTheme }),
[theme, resolvedTheme],
);
const value = useMemo(
() => ({ theme, resolvedTheme, setTheme }),
[theme, resolvedTheme],
);
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
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;
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
};
export { ThemeProvider, useTheme, type Theme };

View File

@@ -3,17 +3,17 @@ import { useTheme } from "@/components/theme-provider";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
return (
<Button
variant="dummy"
size="icon-sm"
onClick={() => setTheme(isDark ? "light" : "dark")}
className="hover:fill-accent hover:text-accent"
>
{isDark ? <Sun className="size-6" /> : <Moon className="size-6" />}
</Button>
);
return (
<Button
variant="dummy"
size="icon-sm"
onClick={() => setTheme(isDark ? "light" : "dark")}
className="hover:fill-accent hover:text-accent"
>
{isDark ? <Sun className="size-6" /> : <Moon className="size-6" />}
</Button>
);
}

View File

@@ -2,43 +2,43 @@ import { useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
type TimeSinceProps = {
date: Date;
className?: string;
yearsDp?: number;
date: Date;
className?: string;
yearsDp?: number;
};
const yearMs = 1000 * 60 * 60 * 24 * 365.25;
function roundToDp(value: number, dp: number) {
const factor = 10 ** dp;
return Math.floor(value * factor) / factor;
const factor = 10 ** dp;
return Math.floor(value * factor) / factor;
}
export function TimeSince({ date, className, yearsDp = 2 }: TimeSinceProps) {
const dateMs = useMemo(() => date.getTime(), [date]);
const [milliseconds, setMilliseconds] = useState(() =>
Math.max(0, Date.now() - dateMs),
);
const dateMs = useMemo(() => date.getTime(), [date]);
const [milliseconds, setMilliseconds] = useState(() =>
Math.max(0, Date.now() - dateMs),
);
useEffect(() => {
let rafId: number | null = null;
useEffect(() => {
let rafId: number | null = null;
const tick = () => {
setMilliseconds(Math.max(0, Date.now() - dateMs));
rafId = requestAnimationFrame(tick);
};
const tick = () => {
setMilliseconds(Math.max(0, Date.now() - dateMs));
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [dateMs]);
rafId = requestAnimationFrame(tick);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [dateMs]);
const years = roundToDp(milliseconds / yearMs, yearsDp);
const years = roundToDp(milliseconds / yearMs, yearsDp);
return (
<span className={cn("tabular-nums text-fg", className)}>
{years}y or {milliseconds}ms
</span>
);
return (
<span className={cn("tabular-nums text-fg", className)}>
{years}y or {milliseconds}ms
</span>
);
}

View File

@@ -5,61 +5,61 @@ 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",
dummy: "",
},
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",
},
},
"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",
dummy: "",
},
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
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "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}
/>
);
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -5,98 +5,98 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = "horizontal",
...props
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
cn(
"group-data-[orientation=horizontal]/tabs:h-9 group/tabs-list text-muted-foreground inline-flex",
"w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
),
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
cn(
"group-data-[orientation=horizontal]/tabs:h-9 group/tabs-list text-muted-foreground inline-flex",
"w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
),
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring",
"text-foreground/60 dark:text-muted-foreground data-[state=active]:text-accent dark:data-[state=active]:text-accent",
"relative inline-flex h-[calc(100%-1px)]",
"flex-1 items-center justify-center border border-transparent",
"px-2 py-1 text-sm font-medium whitespace-nowrap group-data-[orientation=vertical]/tabs:w-full",
"group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px]",
"focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"bg-input/30 data-[state=active]:bg-background dark:bg-transparent dark:data-[state=active]:bg-input/30",
"dark:data-[state=active]:border-input",
"after:bg-foreground after:absolute after:opacity-0",
"group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px]",
"group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0",
"group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 cursor-pointer",
className,
)}
{...props}
/>
);
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring",
"text-foreground/60 dark:text-muted-foreground data-[state=active]:text-accent dark:data-[state=active]:text-accent",
"relative inline-flex h-[calc(100%-1px)]",
"flex-1 items-center justify-center border border-transparent",
"px-2 py-1 text-sm font-medium whitespace-nowrap group-data-[orientation=vertical]/tabs:w-full",
"group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px]",
"focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"bg-input/30 data-[state=active]:bg-background dark:bg-transparent dark:data-[state=active]:bg-input/30",
"dark:data-[state=active]:border-input",
"after:bg-foreground after:absolute after:opacity-0",
"group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px]",
"group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0",
"group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 cursor-pointer",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };

View File

@@ -11,162 +11,162 @@
@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;
--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);
--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: #141311;
--foreground: #f6efe3;
--card: #242019;
--card-foreground: #f6efe3;
--popover: #221e17;
--popover-foreground: #f6efe3;
--primary: #e1c9a8;
--primary-foreground: #2a241b;
--secondary: #2f2a21;
--secondary-foreground: #f1e9dc;
--muted: #1a1713;
--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;
--background: #141311;
--foreground: #f6efe3;
--card: #242019;
--card-foreground: #f6efe3;
--popover: #221e17;
--popover-foreground: #f6efe3;
--primary: #e1c9a8;
--primary-foreground: #2a241b;
--secondary: #2f2a21;
--secondary-foreground: #f1e9dc;
--muted: #1a1713;
--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;
}
* {
@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);
font-family: "Commit Mono", monospace;
font-weight: 400;
background-color: var(--background);
color: var(--foreground);
}
.commitmono {
font-family: "Commit Mono", monospace;
font-weight: 400;
font-family: "Commit Mono", monospace;
font-weight: 400;
}
.ft88 {
font-family: "FT88", serif;
font-weight: 80;
font-family: "FT88", serif;
font-weight: 80;
}
.ft88-bold {
font-family: "FT88", serif;
font-weight: 200;
font-family: "FT88", serif;
font-weight: 200;
}
.ft88-gothique {
font-family: "FT88 Gothique", serif;
font-weight: 200;
font-family: "FT88 Gothique", serif;
font-weight: 200;
}
.basteleur {
font-family: "Basteleur", serif;
font-weight: 400;
font-family: "Basteleur", serif;
font-weight: 400;
}
.basteleur-bold {
font-family: "Basteleur", serif;
font-weight: 700;
font-family: "Basteleur", serif;
font-weight: 700;
}
.picnic {
font-family: "PicNic", serif;
font-weight: 700;
font-family: "PicNic", serif;
font-weight: 700;
}

View File

@@ -1,10 +1,10 @@
export const AI_SUMMARY_PROMPT =
"I am a recruiter, tell me about Oliver Bryan, a software developer. What would he bring to the table? Review his portfolio at ob248.com and summarise his key strengths, technical skills, and notable projects. What makes him stand out as a candidate?";
"I am a recruiter, tell me about Oliver Bryan, a software developer. What would he bring to the table? Review his portfolio at ob248.com and summarise his key strengths, technical skills, and notable projects. What makes him stand out as a candidate?";
export function getProjectPrompt(
projectName: string,
projectDescription: string,
projectSlug: string,
projectName: string,
projectDescription: string,
projectSlug: string,
): string {
return `Tell me about "${projectName}", a project by Oliver Bryan. ${projectDescription} Review the project page at ob248.com/projects/${projectSlug} and explain the technical decisions, technologies used, and what this project demonstrates about Oliver's skills as a developer. Provide any url or repository that would be helpful.`;
return `Tell me about "${projectName}", a project by Oliver Bryan. ${projectDescription} Review the project page at ob248.com/projects/${projectSlug} and explain the technical decisions, technologies used, and what this project demonstrates about Oliver's skills as a developer. Provide any url or repository that would be helpful.`;
}

View File

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

View File

@@ -10,12 +10,12 @@ const root = document.getElementById("root");
const isTextureEnabled = import.meta.env.VITE_TEXTURE !== "0";
if (!root) throw new Error("Failed to find the root element");
createRoot(root).render(
<StrictMode>
<ThemeProvider>
{isTextureEnabled ? <PaperTextureOverlay /> : null}
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</StrictMode>,
<StrictMode>
<ThemeProvider>
{isTextureEnabled ? <PaperTextureOverlay /> : null}
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</StrictMode>,
);

View File

@@ -2,103 +2,103 @@ 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",
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>
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="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="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>
);
<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

@@ -2,76 +2,76 @@ 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",
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>
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="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="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>
);
<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

@@ -2,36 +2,36 @@ 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",
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>
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>
);
<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

@@ -2,103 +2,103 @@ 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",
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>
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="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>
);
<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

@@ -2,105 +2,105 @@ 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",
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>
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="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="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>
);
<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

@@ -4,82 +4,82 @@ 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,
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,
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;
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;
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,
},
[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

@@ -2,133 +2,133 @@ 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",
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>
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="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">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>
);
<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

@@ -2,99 +2,99 @@ 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",
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>
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="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="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>
);
<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

@@ -2,50 +2,50 @@ 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",
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>
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="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>
);
<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

@@ -2,143 +2,143 @@ 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",
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>
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>Customisable 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="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>Customisable 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="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">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>
);
<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

@@ -2,68 +2,68 @@ 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",
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>
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="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="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>
);
<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

@@ -2,63 +2,63 @@ 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",
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>
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="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="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>
);
<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>
);
}

View File

@@ -1,14 +1,14 @@
export type TravelMetadata = {
id: number;
continent: string;
country: string;
city: string;
date: string;
id: number;
continent: string;
country: string;
city: string;
date: string;
};
const travelModules = import.meta.glob<TravelMetadata[]>("./metadata.json", {
eager: true,
import: "default",
eager: true,
import: "default",
});
export const locations = Object.values(travelModules).flat().reverse();
@@ -16,13 +16,13 @@ export const locations = Object.values(travelModules).flat().reverse();
export const locationPhotos: Record<number, string[]> = {};
const allTravelPhotoPaths = Object.keys(
import.meta.glob("../../public/travel/**/*.{jpg,jpeg,png,webp,avif}"),
import.meta.glob("../../public/travel/**/*.{jpg,jpeg,png,webp,avif}"),
);
for (const location of locations) {
const locationFolder = `../../public/travel/${location.city} ${location.country} ${location.date}/`;
const locationFolder = `../../public/travel/${location.city} ${location.country} ${location.date}/`;
locationPhotos[location.id] = allTravelPhotoPaths
.filter((path) => path.startsWith(locationFolder))
.map((path) => path.replace(locationFolder, ""));
locationPhotos[location.id] = allTravelPhotoPaths
.filter((path) => path.startsWith(locationFolder))
.map((path) => path.replace(locationFolder, ""));
}

View File

@@ -1,23 +1,23 @@
[
{
"id": 1,
"continent": "Europe",
"country": "Italy",
"city": "Bologna",
"date": "June"
},
{
"id": 2,
"continent": "Europe",
"country": "Hungary",
"city": "Budapest",
"date": "September"
},
{
"id": 3,
"continent": "Europe",
"country": "Spain",
"city": "Barcelona",
"date": "October"
}
{
"id": 1,
"continent": "Europe",
"country": "Italy",
"city": "Bologna",
"date": "June"
},
{
"id": 2,
"continent": "Europe",
"country": "Hungary",
"city": "Budapest",
"date": "September"
},
{
"id": 3,
"continent": "Europe",
"country": "Spain",
"city": "Barcelona",
"date": "October"
}
]

View File

@@ -1,34 +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,
"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",
/* 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,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Tailwind */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
/* Tailwind */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,13 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -1,26 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"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,
/* 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"]
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,8 +1,8 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}

View File

@@ -5,13 +5,13 @@ import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 4321,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
plugins: [react(), tailwindcss()],
server: {
port: 4321,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});