merge new into master

This commit is contained in:
2026-02-05 17:31:20 +00:00
16267 changed files with 2194867 additions and 0 deletions

198
src/App.tsx Normal file
View File

@@ -0,0 +1,198 @@
import {
Downasaur,
Github,
Home as HomeIcon,
Mail,
Notes,
} from "@nsmr/pixelart-react";
import { useEffect, useState } from "react";
import { Link, Route, Routes, useParams } from "react-router-dom";
import { AskAI } from "@/components/ask-ai";
import { ProjectListItem } from "@/components/ProjectListItem";
import { TimeSince } from "@/components/time-since";
import { type ProjectEntry, projectList, projects } from "@/projects";
import { ThemeToggle } from "./components/theme-toggle";
const asciiFiles = [
"cat-sleep.txt",
"polar-bear.txt",
"penguin-surfboard.txt",
"cat-shock.txt",
"exclamation.txt",
"fat-cat-head.txt",
"grumpy-dog.txt",
"cat-peek.txt",
"cat-loaf.txt",
];
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/projects/:slug" element={<ProjectRoute />} />
<Route path="*" element={<NotFound />} />
</Routes>
);
}
export default App;
function Home() {
const isDevMode = import.meta.env.VITE_PUBLIC_DEV === "1";
const [asciiArt, setAsciiArt] = useState("");
const [asciiFile] = useState(
() => asciiFiles[Math.floor(Math.random() * asciiFiles.length)],
);
const sortedProjects: ProjectEntry[] = [...projectList].sort(
(a, b) =>
parseDate(b.metadata.date).getTime() -
parseDate(a.metadata.date).getTime(),
);
useEffect(() => {
let isActive = true;
fetch(`/ascii/${asciiFile}`)
.then((response) => response.text())
.then((text) => {
if (isActive) setAsciiArt(text);
});
return () => {
isActive = false;
};
}, [asciiFile]);
return (
<div className="min-h-dvh flex flex-col items-center gap-2 text-2xl px-6 py-10">
<div className="flex flex-col items-center gap-4 mb-4">
{asciiArt ? (
<pre className="text-[#000000] dark:text-[#ffffff] leading-1.75 tracking-[-1.75px]">
<code className="commitmono text-[11px]">{asciiArt}</code>
</pre>
) : null}
<h1 className="text-center picnic text-8xl text-balance">
Oliver Bryan
</h1>
<div className="flex flex-wrap items-center justify-center gap-3 text-base text-fg">
<a
href="https://github.com/hex248"
rel="noreferrer"
target="_blank"
className="inline-flex items-center gap-2 hover:text-accent"
>
<Github className="size-6" />
hex248
</a>
<span className="text-fg/60">/</span>
<a
href="mailto:ob248@proton.me"
className="inline-flex items-center gap-2 hover:text-accent"
>
<Mail className="size-6" />
ob248@proton.me
</a>
<span className="text-fg/60">/</span>
<a
href="/cv.pdf"
className="inline-flex items-center gap-2 hover:text-accent"
>
<Notes className="size-6" /> CV
</a>
</div>
<div className="text-base text-fg">
Age: <TimeSince date={new Date(2004, 10, 4, 11, 47, 0)} />
</div>
</div>
<div className="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 gap-4">
{sortedProjects.map((project) => (
<ProjectListItem
key={project.metadata.slug}
metadata={project.metadata}
isDevMode={isDevMode}
/>
))}
</div>
<div className="w-full max-w-5xl flex items-center justify-between gap-4">
<div className="flex items-center gap-6">
<AskAI name="me" inline />
</div>
<ThemeToggle />
</div>
</div>
);
}
function ProjectRoute() {
const { slug } = useParams();
if (!slug || !projects[slug]) return <NotFound />;
const { Component } = projects[slug];
return <Component />;
}
function NotFound() {
return (
<div
className={`w-full h-[100vh] flex flex-col items-center justify-center gap-4`}
>
<span className="-ml-14 -mb-7 -rotate-20 text-xl text-accent">?</span>
<Downasaur size={72} className="text-accent" />
<span className="text-7xl">404</span>
<span className="text-2xl">Not Found</span>
<Link to="/">
<HomeIcon className="size-12 hover:text-accent" />
</Link>
</div>
);
}
// function NotFound() {
// return (
// <div className="min-h-dvh flex flex-col items-center justify-center gap-4 text-2xl">
// <Downasaur className="size-24 text-accent" />
// <Link to="/">
// <HomeIcon className="size-12 hover:text-accent" />
// </Link>
// </div>
// );
// }
function parseDate(dateStr: string): Date {
const lower = dateStr.toLowerCase();
if (lower.includes("q1")) return new Date("2023-01-01");
if (lower.includes("q2")) return new Date("2023-04-01");
if (lower.includes("q3")) return new Date("2023-07-01");
if (lower.includes("q4")) return new Date("2023-10-01");
const months: Record<string, number> = {
january: 0,
february: 1,
march: 2,
april: 3,
may: 4,
june: 5,
july: 6,
august: 7,
september: 8,
october: 9,
november: 10,
december: 11,
};
for (const [monthName, monthIndex] of Object.entries(months)) {
if (lower.includes(monthName)) {
const yearMatch = dateStr.match(/\b(20\d{2})\b/);
if (yearMatch) {
return new Date(Number.parseInt(yearMatch[1], 10), monthIndex, 1);
}
}
}
const yearMatch = dateStr.match(/\b(20\d{2})\b/);
if (yearMatch) {
return new Date(Number.parseInt(yearMatch[1], 10), 0, 1);
}
return new Date(0);
}

View File

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

View File

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

88
src/components/ask-ai.tsx Normal file
View File

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

View File

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

View File

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

View File

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

172
src/index.css Normal file
View File

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

10
src/lib/constants.ts Normal file
View File

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

View File

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