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": { "formatter": {
"parser": { "indentStyle": "space",
"tailwindDirectives": true "indentWidth": 2
} },
} "css": {
"parser": {
"tailwindDirectives": true
}
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,150 +6,150 @@ import type { ProjectMetadata } from "@/projects";
import { AskAI } from "./ask-ai"; import { AskAI } from "./ask-ai";
export function ProjectPage({ export function ProjectPage({
metadata, metadata,
children, children,
}: { }: {
metadata: ProjectMetadata; metadata: ProjectMetadata;
children: ReactNode; children: ReactNode;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const tags = metadata.tags ? [...metadata.tags].sort() : []; const tags = metadata.tags ? [...metadata.tags].sort() : [];
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) return; if (event.defaultPrevented || event.isComposing) return;
if (event.metaKey || event.ctrlKey || event.altKey) return; if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isInteractiveTarget(event.target)) return; if (isInteractiveTarget(event.target)) return;
if ( if (
event.key === "Escape" || event.key === "Escape" ||
event.key === "Backspace" || event.key === "Backspace" ||
event.key === "q" event.key === "q"
) { ) {
event.preventDefault(); event.preventDefault();
navigate("/"); navigate("/");
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
}; };
}, [navigate]); }, [navigate]);
return ( return (
<div className="relative mx-auto w-full max-w-4xl px-6 py-4 text-md border my-8"> <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"> <p className="absolute top-4 right-6 text-xs text-fg/75">
esc or backspace to go back esc or backspace to go back
</p> </p>
<Link <Link
to="/" to="/"
className="inline-flex items-center text-sm hover:text-accent mb-4" className="inline-flex items-center text-sm hover:text-accent mb-4"
> >
<Home /> <Home />
</Link> </Link>
<div className="flex flex-wrap items-start justify-between gap-6 mb-4"> <div className="flex flex-wrap items-start justify-between gap-6 mb-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-2xl text-accent text-balance"> <h1 className="text-2xl text-accent text-balance">
{metadata.title} {metadata.title}
</h1> </h1>
{metadata.image ? ( {metadata.image ? (
<img <img
src={metadata.image} src={metadata.image}
alt={`${metadata.title} project icon`} alt={`${metadata.title} project icon`}
className="w-24 h-24 rounded mb-2" className="w-24 h-24 rounded mb-2"
/> />
) : ( ) : (
<div className="w-24 h-24 mb-2 border rounded" /> <div className="w-24 h-24 mb-2 border rounded" />
)} )}
</div> </div>
<div className="ml-auto flex flex-col items-end text-right"> <div className="ml-auto flex flex-col items-end text-right">
<AskAI <AskAI
name={metadata.title} name={metadata.title}
prompt={getProjectPrompt( prompt={getProjectPrompt(
metadata.title, metadata.title,
metadata.description, metadata.description,
metadata.slug, metadata.slug,
)} )}
/> />
</div> </div>
</div> </div>
{metadata.url ? ( {metadata.url ? (
<div className="flex flex-col mb-2"> <div className="flex flex-col mb-2">
<a <a
href={metadata.url} href={metadata.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="link-project-page inline-block text-accent hover:underline underline-offset-2 text-sm" className="link-project-page inline-block text-accent hover:underline underline-offset-2 text-sm"
> >
Try {metadata.title} Try {metadata.title}
</a> </a>
</div> </div>
) : null} ) : null}
<p className="text-sm mb-2"> <p className="text-sm mb-2">
{metadata.date} {metadata.date}
{metadata.github ? ( {metadata.github ? (
<> <>
{" "} {" "}
-{" "} -{" "}
<a <a
href={metadata.github} href={metadata.github}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-green-500 hover:underline" className="text-green-500 hover:underline"
> >
Source Code Source Code
</a> </a>
</> </>
) : null} ) : null}
</p> </p>
{tags.length > 0 ? ( {tags.length > 0 ? (
<div className="flex gap-1.5 text-sm flex-wrap leading-3 items-center mb-2 no-select"> <div className="flex gap-1.5 text-sm flex-wrap leading-3 items-center mb-2 no-select">
{tags.map((tag: string) => ( {tags.map((tag: string) => (
<span <span
key={tag} key={tag}
className="flex items-center font-500 rounded-sm border px-1.5 py-1" className="flex items-center font-500 rounded-sm border px-1.5 py-1"
> >
{tag} {tag}
</span> </span>
))} ))}
</div> </div>
) : null} ) : null}
<div className="text-pretty">{children}</div> <div className="text-pretty">{children}</div>
<p className="text-center text-md mt-8 mb-4"> <p className="text-center text-md mt-8 mb-4">
Oliver Bryan - {metadata.date} Oliver Bryan - {metadata.date}
{metadata.github ? ( {metadata.github ? (
<> <>
{" "} {" "}
-{" "} -{" "}
<a <a
href={metadata.github} href={metadata.github}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-green-500 hover:underline" className="text-green-500 hover:underline"
> >
Source Code Source Code
</a> </a>
</> </>
) : null} ) : null}
</p> </p>
</div> </div>
); );
} }
function isInteractiveTarget(target: EventTarget | null): boolean { function isInteractiveTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false; if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true; if (target.isContentEditable) return true;
const tagName = target.tagName; const tagName = target.tagName;
return ( return (
tagName === "INPUT" || tagName === "INPUT" ||
tagName === "TEXTAREA" || tagName === "TEXTAREA" ||
tagName === "SELECT" || tagName === "SELECT" ||
tagName === "BUTTON" || tagName === "BUTTON" ||
tagName === "A" tagName === "A"
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,61 +5,61 @@ import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( 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", "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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
"border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-accent", "border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-accent",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
dummy: "", dummy: "",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", 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", 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", sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 px-6 has-[>svg]:px-4", lg: "h-10 px-6 has-[>svg]:px-4",
icon: "size-9", icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8", "icon-sm": "size-8",
"icon-lg": "size-10", "icon-lg": "size-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "default",
}, },
}, },
); );
function Button({ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean; asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button"; const Comp = asChild ? Slot.Root : "button";
return ( return (
<Comp <Comp
data-slot="button" data-slot="button"
data-variant={variant} data-variant={variant}
data-size={size} data-size={size}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
); );
} }
export { Button, buttonVariants }; export { Button, buttonVariants };

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
export const AI_SUMMARY_PROMPT = 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( export function getProjectPrompt(
projectName: string, projectName: string,
projectDescription: string, projectDescription: string,
projectSlug: string, projectSlug: string,
): 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"; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { 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"; const isTextureEnabled = import.meta.env.VITE_TEXTURE !== "0";
if (!root) throw new Error("Failed to find the root element"); if (!root) throw new Error("Failed to find the root element");
createRoot(root).render( createRoot(root).render(
<StrictMode> <StrictMode>
<ThemeProvider> <ThemeProvider>
{isTextureEnabled ? <PaperTextureOverlay /> : null} {isTextureEnabled ? <PaperTextureOverlay /> : null}
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</ThemeProvider> </ThemeProvider>
</StrictMode>, </StrictMode>,
); );

View File

@@ -2,103 +2,103 @@ import { Demo } from "@/components/Demo";
import { ProjectPage } from "@/components/ProjectPage"; import { ProjectPage } from "@/components/ProjectPage";
export const metadata = { export const metadata = {
title: "factor-e", title: "factor-e",
description: description:
"Isometric factory sandbox prototype in C++/raylib with procedural worlds, tile building, inventory & tools.", "Isometric factory sandbox prototype in C++/raylib with procedural worlds, tile building, inventory & tools.",
date: "August 2025", date: "August 2025",
slug: "factor-e", slug: "factor-e",
image: "/factor-e-icon.svg", image: "/factor-e-icon.svg",
github: "https://github.com/hex248/factor-e", github: "https://github.com/hex248/factor-e",
hidden: false, hidden: false,
tags: ["Game", "C++", "OpenGL", "CMake", "Pixel Art"], tags: ["Game", "C++", "OpenGL", "CMake", "Pixel Art"],
type: "personal", type: "personal",
}; };
export function FactorEProject() { export function FactorEProject() {
return ( return (
<ProjectPage metadata={metadata}> <ProjectPage metadata={metadata}>
<p className="text-pretty"> <p className="text-pretty">
"factor-e" is an isometric factory sandbox prototype I built to learn "factor-e" is an isometric factory sandbox prototype I built to learn
C++ and{" "} C++ and{" "}
<a <a
href="https://www.raylib.com/" href="https://www.raylib.com/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="link-project-page" className="link-project-page"
> >
raylib raylib
</a> </a>
. Inspired by Minecraft and{" "} . Inspired by Minecraft and{" "}
<a <a
href="https://store.steampowered.com/app/3433610/Terrafactor/" href="https://store.steampowered.com/app/3433610/Terrafactor/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="link-project-page" className="link-project-page"
> >
Terrafactor Terrafactor
</a> </a>
, it explores tile-based building, inventory management and procedural , it explores tile-based building, inventory management and procedural
world generation. world generation.
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded mt-4"> <div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Key features Key features
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>Isometric rendering with my own pixel art</li> <li>Isometric rendering with my own pixel art</li>
<li>Procedural world generation using Perlin noise</li> <li>Procedural world generation using Perlin noise</li>
<li>Simple tile place/destroy loop</li> <li>Simple tile place/destroy loop</li>
<li>Basic inventory and tool system</li> <li>Basic inventory and tool system</li>
<li>Dev/debug overlay</li> <li>Dev/debug overlay</li>
<li>Cross-platform builds (Windows + Linux)</li> <li>Cross-platform builds (Windows + Linux)</li>
<li> <li>
<span className="text-green-500">Status:</span> active prototype <span className="text-green-500">Status:</span> active prototype
</li> </li>
</ul> </ul>
</div> </div>
<div className="bg-muted p-4 rounded mt-4"> <div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies Technologies
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>C++</li> <li>C++</li>
<li>raylib (OpenGL)</li> <li>raylib (OpenGL)</li>
<li>CMake</li> <li>CMake</li>
<li>Perlin noise generation</li> <li>Perlin noise generation</li>
<li>Aseprite</li> <li>Aseprite</li>
<li>Engine-less game development</li> <li>Engine-less game development</li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Demo</h2> <h2 className="text-2xl text-accent mb-3 text-balance">Demo</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Demo <Demo
image="/images/factor-e/world-gen.gif" image="/images/factor-e/world-gen.gif"
title="World generation" title="World generation"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/factor-e/pixel-art.png" image="/images/factor-e/pixel-art.png"
title="Pixel art" title="Pixel art"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/factor-e/place-destroy.gif" image="/images/factor-e/place-destroy.gif"
title="Place/destroy loop" title="Place/destroy loop"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/factor-e/debug-overlay.gif" image="/images/factor-e/debug-overlay.gif"
title="Dev/debug overlay" title="Dev/debug overlay"
type="boxed" type="boxed"
/> />
</div> </div>
</div> </div>
</ProjectPage> </ProjectPage>
); );
} }

View File

@@ -2,76 +2,76 @@ import { Demo } from "@/components/Demo";
import { ProjectPage } from "@/components/ProjectPage"; import { ProjectPage } from "@/components/ProjectPage";
export const metadata = { export const metadata = {
title: "flackie", title: "flackie",
description: 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.", "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", date: "October 2025",
slug: "flackie", slug: "flackie",
image: "/flackie-icon.svg", image: "/flackie-icon.svg",
github: "https://github.com/hex248/flackie", github: "https://github.com/hex248/flackie",
hidden: true, hidden: true,
tags: [ tags: [
"Raspberry Pi", "Raspberry Pi",
"Python", "Python",
"C++", "C++",
"CMake", "CMake",
"Electronics", "Electronics",
"Pillow", "Pillow",
"Image Generation", "Image Generation",
], ],
type: "personal", type: "personal",
}; };
export function FlackieProject() { export function FlackieProject() {
return ( return (
<ProjectPage metadata={metadata}> <ProjectPage metadata={metadata}>
<p className="text-pretty"> <p className="text-pretty">
"flackie" is a portable FLAC music player I built using a Raspberry Pi "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 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 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 case was designed in CAD and 3D printed to house all the components
neatly. neatly.
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded mt-4"> <div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Key features Key features
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>Portable design with a compact form factor</li> <li>Portable design with a compact form factor</li>
<li>Custom Python UI for easy navigation</li> <li>Custom Python UI for easy navigation</li>
<li>Physical buttons for playback control</li> <li>Physical buttons for playback control</li>
<li>3D printed case</li> <li>3D printed case</li>
<li>Supports FLAC audio playback</li> <li>Supports FLAC audio playback</li>
</ul> </ul>
</div> </div>
<div className="bg-muted p-4 rounded mt-4"> <div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies Technologies
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>C++</li> <li>C++</li>
<li>CMake</li> <li>CMake</li>
<li>Python</li> <li>Python</li>
<li>Pillow</li> <li>Pillow</li>
<li>Raspberry Pi Zero 2 W</li> <li>Raspberry Pi Zero 2 W</li>
<li>E-ink display</li> <li>E-ink display</li>
<li>3D printing</li> <li>3D printing</li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Pictures</h2> <h2 className="text-2xl text-accent mb-3 text-balance">Pictures</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <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/1.png" title="1" type="boxed" />
<Demo image="/images/flackie/2.png" title="2" 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/3.png" title="3" type="boxed" />
<Demo image="/images/flackie/4.png" title="4" type="boxed" /> <Demo image="/images/flackie/4.png" title="4" type="boxed" />
</div> </div>
</div> </div>
</ProjectPage> </ProjectPage>
); );
} }

View File

@@ -2,36 +2,36 @@ import { Demo } from "@/components/Demo";
import { ProjectPage } from "@/components/ProjectPage"; import { ProjectPage } from "@/components/ProjectPage";
export const metadata = { export const metadata = {
title: "fonts.ob248.com", title: "fonts.ob248.com",
description: "A lightweight site for browsing and using my go-to fonts.", description: "A lightweight site for browsing and using my go-to fonts.",
date: "February 2026", date: "February 2026",
slug: "fonts", slug: "fonts",
image: "/fonts.svg", image: "/fonts.svg",
url: "https://fonts.ob248.com", url: "https://fonts.ob248.com",
hidden: false, hidden: false,
tags: ["Web", "Typography", "Hono", "HTML", "Bun"], tags: ["Web", "Typography", "Hono", "HTML", "Bun"],
type: "personal", type: "personal",
}; };
export function FontsProject() { export function FontsProject() {
return ( return (
<ProjectPage metadata={metadata}> <ProjectPage metadata={metadata}>
<p className="mb-4 text-pretty"> <p className="mb-4 text-pretty">
fonts.ob248.com is a lightweight site for browsing and using my go-to 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 fonts. It simplifies the importing processign for .ttf and .otf fonts on
the web. the web.
</p> </p>
<div className="mt-4"> <div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2> <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"> <div className="grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-1 gap-4">
<Demo <Demo
image="/images/fonts/page.png" image="/images/fonts/page.png"
title="Fonts page" title="Fonts page"
type="boxed" type="boxed"
/> />
</div> </div>
</div> </div>
</ProjectPage> </ProjectPage>
); );
} }

View File

@@ -2,103 +2,103 @@ import { Demo } from "@/components/Demo";
import { ProjectPage } from "@/components/ProjectPage"; import { ProjectPage } from "@/components/ProjectPage";
export const metadata = { export const metadata = {
title: "glimpse", title: "glimpse",
description: "Simple social media app inspired by early Instagram.", description: "Simple social media app inspired by early Instagram.",
date: "May 2025", date: "May 2025",
slug: "glimpse", slug: "glimpse",
image: "/glimpse-icon.svg", image: "/glimpse-icon.svg",
url: "https://glimpse.ob248.com", url: "https://glimpse.ob248.com",
github: "https://github.com/hex248/glimpse", github: "https://github.com/hex248/glimpse",
hidden: false, hidden: false,
tags: [ tags: [
"Web", "Web",
"React", "React",
"TypeScript", "TypeScript",
"PostgreSQL", "PostgreSQL",
"Blob Storage", "Blob Storage",
"Databases", "Databases",
"OAuth2", "OAuth2",
], ],
type: "personal", type: "personal",
}; };
export function GlimpseProject() { export function GlimpseProject() {
return ( return (
<ProjectPage metadata={metadata}> <ProjectPage metadata={metadata}>
<p className="mb-4 text-pretty"> <p className="mb-4 text-pretty">
"glimpse" is a full-stack social app for sharing photos with friends and "glimpse" is a full-stack social app for sharing photos with friends and
building real community. Early Instagram and tumblr were huge building real community. Early Instagram and tumblr were huge
inspirations, no influencers and brands, just keeping up with your inspirations, no influencers and brands, just keeping up with your
friends and family. Sign in with Google, and immediately access a friends and family. Sign in with Google, and immediately access a
dynamic feed, view and comment on posts. Choose your profile colour, and dynamic feed, view and comment on posts. Choose your profile colour, and
enable push notifications for new posts, comments, and friend requests. enable push notifications for new posts, comments, and friend requests.
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded"> <div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Key features Key features
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>Photo uploads with caption and cropping function</li> <li>Photo uploads with caption and cropping function</li>
<li>User profiles with customisable colour themes</li> <li>User profiles with customisable colour themes</li>
<li>Dynamic, server-rendered feed of friends' photos</li> <li>Dynamic, server-rendered feed of friends' photos</li>
<li>Commenting on posts</li> <li>Commenting on posts</li>
<li>User search</li> <li>User search</li>
<li>Push notifications</li> <li>Push notifications</li>
</ul> </ul>
</div> </div>
<div className="bg-muted p-4 rounded"> <div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies Technologies
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>Next.js + TypeScript</li> <li>Next.js + TypeScript</li>
<li>Prisma ORM + PostgreSQL</li> <li>Prisma ORM + PostgreSQL</li>
<li>Tailwind CSS</li> <li>Tailwind CSS</li>
<li>Google OAuth with NextAuth.js</li> <li>Google OAuth with NextAuth.js</li>
<li>Web Push API</li> <li>Web Push API</li>
<li>Next.js server-side rendering and API routes</li> <li>Next.js server-side rendering and API routes</li>
<li>Progressive Web App (PWA)</li> <li>Progressive Web App (PWA)</li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<Demo <Demo
image="/images/glimpse/feed.png" image="/images/glimpse/feed.png"
title="Feed view" title="Feed view"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/glimpse/crop.png" image="/images/glimpse/crop.png"
title="Share - write a caption + crop" title="Share - write a caption + crop"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/glimpse/comments.png" image="/images/glimpse/comments.png"
title="Comments and interactions" title="Comments and interactions"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/glimpse/profile.png" image="/images/glimpse/profile.png"
title="Profile (custom colours)" title="Profile (custom colours)"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/glimpse/settings.png" image="/images/glimpse/settings.png"
title="Settings" title="Settings"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/glimpse/search.png" image="/images/glimpse/search.png"
title="User search and discovery" title="User search and discovery"
type="boxed" type="boxed"
/> />
</div> </div>
</div> </div>
</ProjectPage> </ProjectPage>
); );
} }

View File

@@ -2,105 +2,105 @@ import { Demo } from "@/components/Demo";
import { ProjectPage } from "@/components/ProjectPage"; import { ProjectPage } from "@/components/ProjectPage";
export const metadata = { export const metadata = {
title: "good morning!", title: "good morning!",
description: description:
"An app for couples or friends to share daily notices with songs and photos", "An app for couples or friends to share daily notices with songs and photos",
date: "October 2025", date: "October 2025",
slug: "good-morning", slug: "good-morning",
image: "/good-morning-icon.png", image: "/good-morning-icon.png",
// url: "https://gm.ob248.com", // url: "https://gm.ob248.com",
github: "https://github.com/hex248/good-morning", github: "https://github.com/hex248/good-morning",
hidden: false, hidden: false,
tags: [ tags: [
"Web", "Web",
"React", "React",
"TypeScript", "TypeScript",
"Go", "Go",
"PostgreSQL", "PostgreSQL",
"AWS S3", "AWS S3",
"Databases", "Databases",
"OAuth2", "OAuth2",
"Spotify API", "Spotify API",
], ],
type: "personal", type: "personal",
}; };
export function GoodMorningProject() { export function GoodMorningProject() {
return ( return (
<ProjectPage metadata={metadata}> <ProjectPage metadata={metadata}>
<p className="text-pretty"> <p className="text-pretty">
"good morning!" is a web app I built to help couples or friends share "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 daily notices, songs, and photos with each other. It features a simple
and intuitive interface for sending and receiving messages, along with and intuitive interface for sending and receiving messages, along with
support for photo attachments. The app is built with React and support for photo attachments. The app is built with React and
TypeScript on the frontend, and Go with PostgreSQL on the backend. Media TypeScript on the frontend, and Go with PostgreSQL on the backend. Media
files are stored securely using Cloudflare R2 (AWS S3). files are stored securely using Cloudflare R2 (AWS S3).
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded mt-4"> <div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Key features Key features
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>Create daily notices with photos and Spotify songs</li> <li>Create daily notices with photos and Spotify songs</li>
<li>Simple user interface</li> <li>Simple user interface</li>
<li>Google OAuth2 authentication for user accounts</li> <li>Google OAuth2 authentication for user accounts</li>
</ul> </ul>
</div> </div>
<div className="bg-muted p-4 rounded mt-4"> <div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies Technologies
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>React</li> <li>React</li>
<li>TypeScript</li> <li>TypeScript</li>
<li>Go</li> <li>Go</li>
<li>PostgreSQL</li> <li>PostgreSQL</li>
<li>Cloudflare R2 (AWS S3)</li> <li>Cloudflare R2 (AWS S3)</li>
<li>Spotify API</li> <li>Spotify API</li>
<li>OAuth2 Authentication</li> <li>OAuth2 Authentication</li>
<li>Progressive Web App (PWA)</li> <li>Progressive Web App (PWA)</li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Demo</h2> <h2 className="text-2xl text-accent mb-3 text-balance">Demo</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Demo <Demo
image="/images/good-morning/notice.png" image="/images/good-morning/notice.png"
title="Notice from partner" title="Notice from partner"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/good-morning/no-notice.png" image="/images/good-morning/no-notice.png"
title="No notice from partner" title="No notice from partner"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/good-morning/create-notice.png" image="/images/good-morning/create-notice.png"
title="Create notice" title="Create notice"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/good-morning/login-with-google.png" image="/images/good-morning/login-with-google.png"
title="Login with Google" title="Login with Google"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/good-morning/partner-pairing.png" image="/images/good-morning/partner-pairing.png"
title="Partner pairing" title="Partner pairing"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/good-morning/me.png" image="/images/good-morning/me.png"
title="'Me' page" title="'Me' page"
type="boxed" type="boxed"
/> />
</div> </div>
</div> </div>
</ProjectPage> </ProjectPage>
); );
} }

View File

@@ -4,82 +4,82 @@ import { FlackieProject, metadata as flackieMetadata } from "./flackie";
import { FontsProject, metadata as fontsMetadata } from "./fonts"; import { FontsProject, metadata as fontsMetadata } from "./fonts";
import { GlimpseProject, metadata as glimpseMetadata } from "./glimpse"; import { GlimpseProject, metadata as glimpseMetadata } from "./glimpse";
import { import {
GoodMorningProject, GoodMorningProject,
metadata as goodMorningMetadata, metadata as goodMorningMetadata,
} from "./good-morning"; } from "./good-morning";
import { MizuProject, metadata as mizuMetadata } from "./mizu"; import { MizuProject, metadata as mizuMetadata } from "./mizu";
import { PrayerbudProject, metadata as prayerbudMetadata } from "./prayerbud"; import { PrayerbudProject, metadata as prayerbudMetadata } from "./prayerbud";
import { ShleepProject, metadata as shleepMetadata } from "./shleep"; import { ShleepProject, metadata as shleepMetadata } from "./shleep";
import { SprintProject, metadata as sprintMetadata } from "./sprint"; import { SprintProject, metadata as sprintMetadata } from "./sprint";
import { import {
WatercoolerProject, WatercoolerProject,
metadata as watercoolerMetadata, metadata as watercoolerMetadata,
} from "./watercooler"; } from "./watercooler";
import { WiskatronProject, metadata as wiskatronMetadata } from "./wiskatron"; import { WiskatronProject, metadata as wiskatronMetadata } from "./wiskatron";
export type ProjectMetadata = { export type ProjectMetadata = {
title: string; title: string;
description: string; description: string;
date: string; date: string;
slug: string; slug: string;
image?: string | null; image?: string | null;
url?: string; url?: string;
github?: string; github?: string;
hidden: boolean; hidden: boolean;
tags?: string[]; tags?: string[];
type: string; type: string;
}; };
export type ProjectEntry = { export type ProjectEntry = {
metadata: ProjectMetadata; metadata: ProjectMetadata;
Component: ComponentType; Component: ComponentType;
}; };
export const projects = { export const projects = {
[factorEMetadata.slug]: { [factorEMetadata.slug]: {
metadata: factorEMetadata, metadata: factorEMetadata,
Component: FactorEProject, Component: FactorEProject,
}, },
[fontsMetadata.slug]: { [fontsMetadata.slug]: {
metadata: fontsMetadata, metadata: fontsMetadata,
Component: FontsProject, Component: FontsProject,
}, },
[flackieMetadata.slug]: { [flackieMetadata.slug]: {
metadata: flackieMetadata, metadata: flackieMetadata,
Component: FlackieProject, Component: FlackieProject,
}, },
[glimpseMetadata.slug]: { [glimpseMetadata.slug]: {
metadata: glimpseMetadata, metadata: glimpseMetadata,
Component: GlimpseProject, Component: GlimpseProject,
}, },
[goodMorningMetadata.slug]: { [goodMorningMetadata.slug]: {
metadata: goodMorningMetadata, metadata: goodMorningMetadata,
Component: GoodMorningProject, Component: GoodMorningProject,
}, },
[mizuMetadata.slug]: { [mizuMetadata.slug]: {
metadata: mizuMetadata, metadata: mizuMetadata,
Component: MizuProject, Component: MizuProject,
}, },
[prayerbudMetadata.slug]: { [prayerbudMetadata.slug]: {
metadata: prayerbudMetadata, metadata: prayerbudMetadata,
Component: PrayerbudProject, Component: PrayerbudProject,
}, },
[shleepMetadata.slug]: { [shleepMetadata.slug]: {
metadata: shleepMetadata, metadata: shleepMetadata,
Component: ShleepProject, Component: ShleepProject,
}, },
[sprintMetadata.slug]: { [sprintMetadata.slug]: {
metadata: sprintMetadata, metadata: sprintMetadata,
Component: SprintProject, Component: SprintProject,
}, },
[watercoolerMetadata.slug]: { [watercoolerMetadata.slug]: {
metadata: watercoolerMetadata, metadata: watercoolerMetadata,
Component: WatercoolerProject, Component: WatercoolerProject,
}, },
[wiskatronMetadata.slug]: { [wiskatronMetadata.slug]: {
metadata: wiskatronMetadata, metadata: wiskatronMetadata,
Component: WiskatronProject, Component: WiskatronProject,
}, },
} satisfies Record<string, ProjectEntry>; } satisfies Record<string, ProjectEntry>;
export const projectList = Object.values(projects); export const projectList = Object.values(projects);

View File

@@ -2,133 +2,133 @@ import { Demo } from "@/components/Demo";
import { ProjectPage } from "@/components/ProjectPage"; import { ProjectPage } from "@/components/ProjectPage";
export const metadata = { export const metadata = {
title: "MIZU", title: "MIZU",
description: description:
"A discord bot card trading and collection game. (Currently inactive, 4000+ players) ", "A discord bot card trading and collection game. (Currently inactive, 4000+ players) ",
date: "2021 - 2024", date: "2021 - 2024",
slug: "mizu", slug: "mizu",
image: "/mizu-icon.svg", image: "/mizu-icon.svg",
hidden: false, hidden: false,
tags: [ tags: [
"Node.js", "Node.js",
"TypeScript", "TypeScript",
"PostgreSQL", "PostgreSQL",
"AWS S3", "AWS S3",
"Discord API", "Discord API",
"Database", "Database",
], ],
type: "personal", type: "personal",
}; };
export function MizuProject() { export function MizuProject() {
return ( return (
<ProjectPage metadata={metadata}> <ProjectPage metadata={metadata}>
<p className="text-pretty"> <p className="text-pretty">
I led a four-person team to create MIZU, a popular anime trading card 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 game on Discord. In this role, I was responsible for the full lifecycle
of the application: designing the core architecture, building the of the application: designing the core architecture, building the
application with Node.js and TypeScript, and deploying it on a application with Node.js and TypeScript, and deploying it on a
self-managed VPS. We successfully scaled to serve over 4,000 players. self-managed VPS. We successfully scaled to serve over 4,000 players.
Although MIZU is no longer active, it was a significant experience in Although MIZU is no longer active, it was a significant experience in
leading a team and scaling a live application. leading a team and scaling a live application.
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded mt-4"> <div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies Technologies
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>Node.js</li> <li>Node.js</li>
<li>TypeScript</li> <li>TypeScript</li>
<li>Express.js</li> <li>Express.js</li>
<li>Discord.js</li> <li>Discord.js</li>
<li>PostgreSQL</li> <li>PostgreSQL</li>
<li>AWS S3</li> <li>AWS S3</li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Gameplay</h2> <h2 className="text-2xl text-accent mb-3 text-balance">Gameplay</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Demo <Demo
image="/images/mizu/card.png" image="/images/mizu/card.png"
title="Card (Large image)" title="Card (Large image)"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/mizu/card-fighter.png" image="/images/mizu/card-fighter.png"
title="Card (Fighter)" title="Card (Fighter)"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/mizu/card-details.png" image="/images/mizu/card-details.png"
title="Card (Details)" title="Card (Details)"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/mizu/collection1.png" image="/images/mizu/collection1.png"
title="Collection" title="Collection"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/mizu/collection2.png" image="/images/mizu/collection2.png"
title="Collection with sorting and filtering" title="Collection with sorting and filtering"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/mizu/current-trade.png" image="/images/mizu/current-trade.png"
title="Ongoing Trade" title="Ongoing Trade"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/mizu/complete-trade.png" image="/images/mizu/complete-trade.png"
title="Completed Trade" title="Completed Trade"
type="boxed" type="boxed"
/> />
<Demo image="/images/mizu/forage.png" title="Forage" type="boxed" /> <Demo image="/images/mizu/forage.png" title="Forage" type="boxed" />
<Demo <Demo
image="/images/mizu/inventory.png" image="/images/mizu/inventory.png"
title="Inventory" title="Inventory"
type="boxed" type="boxed"
/> />
<Demo image="/images/mizu/quests.png" title="Quests" type="boxed" /> <Demo image="/images/mizu/quests.png" title="Quests" type="boxed" />
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance"> <h2 className="text-2xl text-accent mb-3 text-balance">
Pre-Production Pre-Production
</h2> </h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<Demo <Demo
image="/images/mizu/forage-design.png" image="/images/mizu/forage-design.png"
title="Forage Design" title="Forage Design"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/mizu/forage-locations.png" image="/images/mizu/forage-locations.png"
title="Forage Locations" title="Forage Locations"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/mizu/quests-planning.png" image="/images/mizu/quests-planning.png"
title="Quests Planning" title="Quests Planning"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/mizu/update-planning.png" image="/images/mizu/update-planning.png"
title="Update Management" title="Update Management"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/mizu/pack-planning.png" image="/images/mizu/pack-planning.png"
title="Pack System" title="Pack System"
type="boxed" type="boxed"
/> />
</div> </div>
</div> </div>
</ProjectPage> </ProjectPage>
); );
} }

View File

@@ -2,99 +2,99 @@ import { Demo } from "@/components/Demo";
import { ProjectPage } from "@/components/ProjectPage"; import { ProjectPage } from "@/components/ProjectPage";
export const metadata = { export const metadata = {
title: "PrayerBud", title: "PrayerBud",
description: description:
"A faith-based social platform facilitating sharing of support and prayers within communities.", "A faith-based social platform facilitating sharing of support and prayers within communities.",
date: "February 2025 - Present", date: "February 2025 - Present",
slug: "prayerbud", slug: "prayerbud",
image: "/prayerbud-icon.svg", image: "/prayerbud-icon.svg",
url: "https://prayerbud.co.uk", url: "https://prayerbud.co.uk",
hidden: false, hidden: false,
tags: ["Web", "React", "TypeScript", "PostgreSQL", "OAuth2", "Databases"], tags: ["Web", "React", "TypeScript", "PostgreSQL", "OAuth2", "Databases"],
type: "professional", type: "professional",
}; };
export function PrayerbudProject() { export function PrayerbudProject() {
return ( return (
<ProjectPage metadata={metadata}> <ProjectPage metadata={metadata}>
<div className="space-y-4 mb-4 text-pretty"> <div className="space-y-4 mb-4 text-pretty">
<p> <p>
Pray Together and Grow Together: Join a diverse community of Pray Together and Grow Together: Join a diverse community of
individuals from around the world who are passionate about prayer and individuals from around the world who are passionate about prayer and
spiritual growth. Create and share prayer requests with your PrayerBud spiritual growth. Create and share prayer requests with your PrayerBud
community who are ready to offer support, encouragement, and heartfelt community who are ready to offer support, encouragement, and heartfelt
prayers. prayers.
</p> </p>
<p> <p>
For prayer teams or churches, the app offers a streamlined way to For prayer teams or churches, the app offers a streamlined way to
manage and organise prayer requests, ensuring that no request goes manage and organise prayer requests, ensuring that no request goes
unnoticed. unnoticed.
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded"> <div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Key features Key features
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>Create and manage prayer networks</li> <li>Create and manage prayer networks</li>
<li>Manage prayer communities</li> <li>Manage prayer communities</li>
<li>Intimate engagement with friends and family</li> <li>Intimate engagement with friends and family</li>
<li>Admin dashboard for managing users and user content</li> <li>Admin dashboard for managing users and user content</li>
<li>Responsive design for mobile and desktop</li> <li>Responsive design for mobile and desktop</li>
</ul> </ul>
</div> </div>
<div className="bg-muted p-4 rounded"> <div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies Technologies
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>Next.js</li> <li>Next.js</li>
<li>React</li> <li>React</li>
<li>TypeScript</li> <li>TypeScript</li>
<li>PostgreSQL</li> <li>PostgreSQL</li>
<li>Node.js</li> <li>Node.js</li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<Demo <Demo
image="/images/prayerbud/pre-login.png" image="/images/prayerbud/pre-login.png"
title="Front page / pre-login" title="Front page / pre-login"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/prayerbud/post-login.png" image="/images/prayerbud/post-login.png"
title="Post-login" title="Post-login"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/prayerbud/create-network.png" image="/images/prayerbud/create-network.png"
title="Create Network" title="Create Network"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/prayerbud/welcome-to-network.png" image="/images/prayerbud/welcome-to-network.png"
title="Welcome to your Network" title="Welcome to your Network"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/prayerbud/prayer-card.png" image="/images/prayerbud/prayer-card.png"
title="Create Prayer Card" title="Create Prayer Card"
type="boxed" type="boxed"
/> />
<Demo <Demo
image="/images/prayerbud/dashboard.png" image="/images/prayerbud/dashboard.png"
title="Admin Dashboard" title="Admin Dashboard"
type="boxed" type="boxed"
/> />
</div> </div>
</div> </div>
</ProjectPage> </ProjectPage>
); );
} }

View File

@@ -2,50 +2,50 @@ import { Demo } from "@/components/Demo";
import { ProjectPage } from "@/components/ProjectPage"; import { ProjectPage } from "@/components/ProjectPage";
export const metadata = { export const metadata = {
title: "Shleep", title: "Shleep",
description: description:
"A couch co-op base defense game where you protect a sleepign child from nightmares.", "A couch co-op base defense game where you protect a sleepign child from nightmares.",
date: "February - June 2023", date: "February - June 2023",
slug: "shleep", slug: "shleep",
image: "/shleep-icon.svg", image: "/shleep-icon.svg",
url: "https://bigbootstudio.itch.io/shleep", url: "https://bigbootstudio.itch.io/shleep",
hidden: true, hidden: true,
tags: ["Unity", "C#", "HLSL", "Shader Graph", "Visual Effects Graph"], tags: ["Unity", "C#", "HLSL", "Shader Graph", "Visual Effects Graph"],
type: "personal", type: "personal",
}; };
export function ShleepProject() { export function ShleepProject() {
return ( return (
<ProjectPage metadata={metadata}> <ProjectPage metadata={metadata}>
<p className="text-pretty"> <p className="text-pretty">
Shleep is a couch co-op base defense game where you can build towers to 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. help aid you and your party to protect a sleeping child from nightmares.
</p> </p>
<div className="bg-muted p-4 rounded mt-4"> <div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies Technologies
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>Unity</li> <li>Unity</li>
<li>C#</li> <li>C#</li>
<li>HLSL</li> <li>HLSL</li>
<li>Shader Graph</li> <li>Shader Graph</li>
<li>Visual Effects Graph</li> <li>Visual Effects Graph</li>
</ul> </ul>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2> <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"> <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/1.png" title="1" type="boxed" />
<Demo image="/images/shleep/2.png" title="2" 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/3.png" title="3" type="boxed" />
<Demo image="/images/shleep/4.png" title="4" 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/5.png" title="5" type="boxed" />
<Demo image="/images/shleep/6.png" title="6" type="boxed" /> <Demo image="/images/shleep/6.png" title="6" type="boxed" />
</div> </div>
</div> </div>
</ProjectPage> </ProjectPage>
); );
} }

View File

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

View File

@@ -2,68 +2,68 @@ import { Demo } from "@/components/Demo";
import { ProjectPage } from "@/components/ProjectPage"; import { ProjectPage } from "@/components/ProjectPage";
export const metadata = { export const metadata = {
title: "Watercooler", title: "Watercooler",
description: description:
"Virtual office space for remote teams allowing quick questions and spontaneous chats.", "Virtual office space for remote teams allowing quick questions and spontaneous chats.",
date: "March 2025", date: "March 2025",
slug: "watercooler", slug: "watercooler",
image: "/watercooler-icon.svg", image: "/watercooler-icon.svg",
hidden: true, hidden: true,
tags: [ tags: [
"Web", "Web",
"React", "React",
"TypeScript", "TypeScript",
"WebRTC", "WebRTC",
"LiveKit", "LiveKit",
"PostgreSQL", "PostgreSQL",
"OAuth2", "OAuth2",
"Databases", "Databases",
], ],
type: "personal", type: "personal",
}; };
export function WatercoolerProject() { export function WatercoolerProject() {
return ( return (
<ProjectPage metadata={metadata}> <ProjectPage metadata={metadata}>
<p className="text-pretty">watercooler description here</p> <p className="text-pretty">watercooler description here</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded mt-4"> <div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Key features Key features
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>feature1</li> <li>feature1</li>
<li> <li>
<span className="text-green-500">Status:</span> active prototype <span className="text-green-500">Status:</span> active prototype
</li> </li>
</ul> </ul>
</div> </div>
<div className="bg-muted p-4 rounded mt-4"> <div className="bg-muted p-4 rounded mt-4">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies Technologies
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>LiveKit (WebRTC)</li> <li>LiveKit (WebRTC)</li>
<li>Next.js + TypeScript</li> <li>Next.js + TypeScript</li>
<li>Prisma ORM + PostgreSQL</li> <li>Prisma ORM + PostgreSQL</li>
<li>Tailwind CSS</li> <li>Tailwind CSS</li>
<li>Google OAuth with NextAuth.js</li> <li>Google OAuth with NextAuth.js</li>
<li>Next.js server-side rendering and API routes</li> <li>Next.js server-side rendering and API routes</li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2> <h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <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/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" /> <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>
</div> </div>
</ProjectPage> </ProjectPage>
); );
} }

View File

@@ -2,63 +2,63 @@ import { Demo } from "@/components/Demo";
import { ProjectPage } from "@/components/ProjectPage"; import { ProjectPage } from "@/components/ProjectPage";
export const metadata = { export const metadata = {
title: "Wiskatron", title: "Wiskatron",
description: "Spotify listening activity with dynamic visuals", description: "Spotify listening activity with dynamic visuals",
date: "February 2024", date: "February 2024",
slug: "wiskatron", slug: "wiskatron",
image: "/wiskatron-icon.svg", image: "/wiskatron-icon.svg",
github: "https://github.com/hex248/wiskatron", github: "https://github.com/hex248/wiskatron",
hidden: false, hidden: false,
tags: ["Web", "React", "TypeScript", "Spotify API", "OAuth2"], tags: ["Web", "React", "TypeScript", "Spotify API", "OAuth2"],
type: "personal", type: "personal",
}; };
export function WiskatronProject() { export function WiskatronProject() {
return ( return (
<ProjectPage metadata={metadata}> <ProjectPage metadata={metadata}>
<p className="mb-4 text-pretty"> <p className="mb-4 text-pretty">
Spotify listening activity web app with dynamic visuals, built with Spotify listening activity web app with dynamic visuals, built with
Next.js. Next.js.
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-muted p-4 rounded"> <div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Key features Key features
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>Live fetch from Spotify API</li> <li>Live fetch from Spotify API</li>
<li>OAuth 2.0 authentication</li> <li>OAuth 2.0 authentication</li>
<li>Dynamic colour palette extraction</li> <li>Dynamic colour palette extraction</li>
<li>Smooth song transitions</li> <li>Smooth song transitions</li>
</ul> </ul>
</div> </div>
<div className="bg-muted p-4 rounded"> <div className="bg-muted p-4 rounded">
<h2 className="text-lg text-green-500 mb-2 text-balance"> <h2 className="text-lg text-green-500 mb-2 text-balance">
Technologies Technologies
</h2> </h2>
<ul className="list-disc list-inside space-y-1 text-pretty"> <ul className="list-disc list-inside space-y-1 text-pretty">
<li>Next.js + TypeScript</li> <li>Next.js + TypeScript</li>
<li>Spotify API</li> <li>Spotify API</li>
<li>OAuth 2.0 with fastify</li> <li>OAuth 2.0 with fastify</li>
<li>Next.js server-side rendering and API routes</li> <li>Next.js server-side rendering and API routes</li>
<li>Colour palette extraction with node-vibrant</li> <li>Colour palette extraction with node-vibrant</li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2> <h2 className="text-2xl text-accent mb-3 text-balance">Screenshots</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <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/1.png" title="Example 1" />
<Demo image="/images/wiskatron/2.png" title="Example 2" /> <Demo image="/images/wiskatron/2.png" title="Example 2" />
<Demo image="/images/wiskatron/3.png" title="Example 3" /> <Demo image="/images/wiskatron/3.png" title="Example 3" />
<Demo image="/images/wiskatron/4.png" title="Example 4" /> <Demo image="/images/wiskatron/4.png" title="Example 4" />
<Demo image="/images/wiskatron/5.png" title="Example 5" /> <Demo image="/images/wiskatron/5.png" title="Example 5" />
<Demo image="/images/wiskatron/6.png" title="Example 6" /> <Demo image="/images/wiskatron/6.png" title="Example 6" />
</div> </div>
</div> </div>
</ProjectPage> </ProjectPage>
); );
} }

View File

@@ -1,14 +1,14 @@
export type TravelMetadata = { export type TravelMetadata = {
id: number; id: number;
continent: string; continent: string;
country: string; country: string;
city: string; city: string;
date: string; date: string;
}; };
const travelModules = import.meta.glob<TravelMetadata[]>("./metadata.json", { const travelModules = import.meta.glob<TravelMetadata[]>("./metadata.json", {
eager: true, eager: true,
import: "default", import: "default",
}); });
export const locations = Object.values(travelModules).flat().reverse(); 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[]> = {}; export const locationPhotos: Record<number, string[]> = {};
const allTravelPhotoPaths = Object.keys( 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) { 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 locationPhotos[location.id] = allTravelPhotoPaths
.filter((path) => path.startsWith(locationFolder)) .filter((path) => path.startsWith(locationFolder))
.map((path) => path.replace(locationFolder, "")); .map((path) => path.replace(locationFolder, ""));
} }

View File

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

View File

@@ -1,34 +1,34 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
/* Tailwind */ /* Tailwind */
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src"] "include": ["src"]
} }

View File

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

View File

@@ -1,26 +1,26 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023", "target": "ES2023",
"lib": ["ES2023"], "lib": ["ES2023"],
"module": "ESNext", "module": "ESNext",
"types": ["node"], "types": ["node"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

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

View File

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