Compare commits
26 Commits
cd0965e617
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| def3e3b01d | |||
| f406800b78 | |||
| 4a9ca707fc | |||
| c4f90bf93d | |||
| 035ff49e0b | |||
| b387a394e3 | |||
| 709bf78d01 | |||
| c10cfaa5b9 | |||
| a1310a774a | |||
| 9222292b9f | |||
| ab36d99ba3 | |||
| dab5651e99 | |||
| 9e15f900ba | |||
| ac1890391c | |||
| e12e1b7072 | |||
| 15b0ea1818 | |||
| 8b6393795e | |||
| 0fe7c10b1e | |||
| 54fd768af8 | |||
| f5010512d4 | |||
| 28e6151d36 | |||
| b6f9d33e55 | |||
| 70888d4096 | |||
| 92de396196 | |||
| 880b76eb43 | |||
| 796f422db0 |
@@ -1,11 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { IconBrandLinkedinFilled, IconMailFilled } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBrandLinkedinFilled,
|
||||
IconMailFilled,
|
||||
IconPhoneFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import Hero from "@/components/content/Hero";
|
||||
import SectionLabel from "@/components/layout/SectionLabel";
|
||||
import AccentLink from "@/components/navigation/AccentLink";
|
||||
import GiteaIcon from "@/components/icons/GiteaIcon";
|
||||
import Tooltip from "@/components/ui/Tooltip";
|
||||
import { colors, education, timeline } from "@/constants";
|
||||
import CopyButton from "@/components/ui/CopyButton";
|
||||
import {
|
||||
about,
|
||||
colors,
|
||||
education,
|
||||
email,
|
||||
gitea,
|
||||
linkedIn,
|
||||
phone,
|
||||
timeline,
|
||||
} from "@/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About — Angel Mankel",
|
||||
@@ -13,28 +27,14 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="space-y-14">
|
||||
<div className="flex flex-col gap-12">
|
||||
|
||||
{/* Hero Section */}
|
||||
<Hero label="About">
|
||||
<div className="space-y-5 text-[19px] leading-[1.65]">
|
||||
<p>
|
||||
I came up through healthcare IT — help desk, then Salesforce
|
||||
administration, then software engineering. A path that taught me
|
||||
to translate between operations, design, and engineering without
|
||||
losing the thread.
|
||||
</p>
|
||||
<p>
|
||||
These days I write React and TypeScript for production, lead
|
||||
component-library work, and spend a lot of time in Claude Code —
|
||||
custom slash commands, MCP integrations, agent workflows. The
|
||||
AI-tooling layer is reshaping how I work across the stack.
|
||||
</p>
|
||||
<p>
|
||||
Based in Arizona, looking for remote-US roles where shipping
|
||||
frontend product and building AI tooling around it are both
|
||||
first-class.
|
||||
</p>
|
||||
{about.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
</Hero>
|
||||
|
||||
@@ -68,40 +68,58 @@ export default function AboutPage() {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<AccentLink link="/cv.pdf" label="Download CV (PDF)" />
|
||||
<div className="flex flex-col gap-6">
|
||||
<AccentLink link="/Angel_Mankel_Resume_2026.pdf" label="Download Resume (PDF)" download />
|
||||
|
||||
<div className="flex flex-col gap-2 text-neutral-300">
|
||||
<p>
|
||||
<span className="font-bold">Email:</span> {email}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-bold">Phone:</span> {phone}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Links */}
|
||||
<nav aria-label="Contact" className="flex gap-5 text-neutral-300">
|
||||
<Tooltip text="Email">
|
||||
<a
|
||||
href="mailto:angelmankel@gmail.com"
|
||||
aria-label="Email"
|
||||
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200`}
|
||||
{/* Social Links */}
|
||||
<nav aria-label="Contact" className="flex gap-5 text-neutral-300">
|
||||
<CopyButton
|
||||
value={email}
|
||||
label="Copy email"
|
||||
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200 cursor-pointer`}
|
||||
>
|
||||
<IconMailFilled size={32} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip text="LinkedIn">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/angel-mankel-0616aa132/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="LinkedIn"
|
||||
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200`}
|
||||
</CopyButton>
|
||||
<CopyButton
|
||||
value={phone}
|
||||
label="Copy phone"
|
||||
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200 cursor-pointer`}
|
||||
>
|
||||
<IconBrandLinkedinFilled size={32} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip text="Gitea">
|
||||
<a
|
||||
href="#"
|
||||
aria-label="Gitea"
|
||||
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200`}
|
||||
>
|
||||
<GiteaIcon size={32} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</nav>
|
||||
<IconPhoneFilled size={32} />
|
||||
</CopyButton>
|
||||
<Tooltip text="LinkedIn">
|
||||
<a
|
||||
href={linkedIn}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="LinkedIn"
|
||||
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200`}
|
||||
>
|
||||
<IconBrandLinkedinFilled size={32} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip text="Gitea">
|
||||
<a
|
||||
href={gitea}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Gitea"
|
||||
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200`}
|
||||
>
|
||||
<GiteaIcon size={32} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -16,3 +16,12 @@ body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 400ms ease;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import "./globals.css";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import Container from "@/components/layout/Container";
|
||||
import Cursor from "@/components/ui/Cursor";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -33,7 +32,6 @@ export default function RootLayout({
|
||||
className={`${inter.variable} ${instrumentSerif.variable} h-full antialiased scrollbar-track-transparent scrollbar-thumb-neutral-700`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col font-sans">
|
||||
{/* <Cursor /> */}
|
||||
<Header />
|
||||
<Container>{children}</Container>
|
||||
<Footer />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import Hero from "@/components/content/Hero";
|
||||
import Testimonials from "@/components/content/Testimonials";
|
||||
import RotatingWord from "@/components/content/RotatingWord";
|
||||
@@ -14,20 +16,20 @@ export default function HomePage() {
|
||||
<p className="font-serif italic text-3xl leading-[1.3]">
|
||||
I'm{" "}
|
||||
<RotatingWord
|
||||
items={["a developer", "a creative", "a tinkerer"]}
|
||||
items={["a developer", "a creative", "a builder", "a tinkerer"]}
|
||||
/>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p className="text-[19px] leading-[1.65]">
|
||||
Building software people rely on — and understanding what makes those
|
||||
people tick — is what drives my greatest ambitions.
|
||||
I build software people actually use, and I care about the people on
|
||||
the other side of it as much as the code.
|
||||
</p>
|
||||
|
||||
<p className="text-[19px] leading-[1.65]">
|
||||
I'm the kind of engineer who picks up whatever the problem needs and
|
||||
keeps digging — into the code, and into the people it's meant for.
|
||||
The throughline isn't a stack, it's the curiosity.
|
||||
I'll pick up whatever a problem needs and keep digging until it
|
||||
works. The constant isn't a particular stack — it's the
|
||||
curiosity that got me here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { IconArrowNarrowLeft, IconExternalLink } from "@tabler/icons-react";
|
||||
import Hero from "@/components/content/Hero";
|
||||
import SectionLabel from "@/components/layout/SectionLabel";
|
||||
import { colors, projects } from "@/constants";
|
||||
import ProjectCarousel from "@/components/content/ProjectCarousel";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return projects.map((project) => ({ slug: project.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const project = projects.find((p) => p.slug === slug);
|
||||
|
||||
if (!project) return { title: "Project — Angel Mankel" };
|
||||
|
||||
return {
|
||||
title: `${project.title} — Angel Mankel`,
|
||||
description: project.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const project = projects.find((p) => p.slug === slug);
|
||||
|
||||
if (!project) notFound();
|
||||
|
||||
return (
|
||||
<div className="space-y-14">
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center gap-1 text-sm text-neutral-400 transition-colors hover:text-neutral-200"
|
||||
>
|
||||
<IconArrowNarrowLeft size={18} />
|
||||
All projects
|
||||
</Link>
|
||||
|
||||
<Hero label={project.title} subtitle={project.description}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-md text-neutral-400">
|
||||
{project.year} — {project.stack.join(" · ")}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-neutral-700 px-4 py-2 text-[15px] font-medium transition-colors hover:border-neutral-500"
|
||||
style={{ color: colors.accent }}
|
||||
>
|
||||
Open live app
|
||||
<IconExternalLink size={18} />
|
||||
</a>
|
||||
</div>
|
||||
</Hero>
|
||||
|
||||
{/* Screenshots */}
|
||||
{project.screenshots.length > 0 && (
|
||||
<ProjectCarousel screenshots={project.screenshots} />
|
||||
)}
|
||||
|
||||
{/* Overview */}
|
||||
<section className="space-y-4">
|
||||
<SectionLabel label="Overview" />
|
||||
<div className="text-[17px] leading-[1.65] text-neutral-200">
|
||||
{project.overview[0]}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Problem Solved */}
|
||||
<section className="space-y-4">
|
||||
<SectionLabel label="Problem Solved" />
|
||||
<div className="text-[17px] leading-[1.65] text-neutral-200">
|
||||
{project.overview[1]}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What I learned */}
|
||||
<section className="space-y-4">
|
||||
<SectionLabel label="What I learned" />
|
||||
<ul className="space-y-2 text-[15px] leading-[1.65] text-neutral-200">
|
||||
{project.learned.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="before:mr-2 before:text-neutral-600 before:content-['–']"
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* What I'd do differently */}
|
||||
<section className="space-y-4">
|
||||
<SectionLabel label="What I'd do differently" />
|
||||
<ul className="space-y-2 text-[15px] leading-[1.65] text-neutral-200">
|
||||
{project.improvements.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="before:mr-2 before:text-neutral-600 before:content-['–']"
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import ProjectCard from "@/components/content/ProjectCard";
|
||||
import { projects } from "@/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Work — Angel Mankel",
|
||||
title: "Projects — Angel Mankel",
|
||||
};
|
||||
|
||||
export default function ProjectsPage() {
|
||||
|
||||
@@ -1,77 +1,137 @@
|
||||
import type { Metadata } from "next";
|
||||
import Hero from "@/components/content/Hero";
|
||||
import SectionLabel from "@/components/layout/SectionLabel";
|
||||
import Collapsible from "@/components/ui/Collapsible";
|
||||
import { hardware, editorAndShell, aiTools } from "@/constants";
|
||||
import type { ToolGroup } from "@/constants/uses";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Uses — Angel Mankel",
|
||||
};
|
||||
|
||||
type Section =
|
||||
| { label: string; kind: "list"; items: string[] }
|
||||
| { label: string; kind: "prose"; paragraphs: string[] };
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
label: "Hardware",
|
||||
kind: "list",
|
||||
items: [
|
||||
"Custom desktop — i9-12900H, 32GB RAM, Intel Iris Xe",
|
||||
"Nobara Linux 43 · KDE Plasma 6 · Wayland",
|
||||
"Headphones / keyboard / monitor — placeholder",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Editor & Shell",
|
||||
kind: "list",
|
||||
items: [
|
||||
"VS Code with a personal extension pack — placeholder",
|
||||
"Bash / zsh — placeholder for shell",
|
||||
"JetBrains Mono as the editor font",
|
||||
"Theme — placeholder",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "AI Workflow",
|
||||
kind: "prose",
|
||||
paragraphs: [
|
||||
"Claude Code is the daily driver — custom slash commands, MCP servers wired into my editor, and agent workflows that handle the repeat work so I can stay in the design problem.",
|
||||
"Placeholder paragraph two — name a specific workflow and the time it saves.",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Self-hosting",
|
||||
kind: "list",
|
||||
items: [
|
||||
"Bare-metal home server, exposed to the internet through Traefik",
|
||||
"This site lives on it — Next.js standalone build, no managed host",
|
||||
"Other services — placeholder",
|
||||
],
|
||||
},
|
||||
];
|
||||
function ToolSection({
|
||||
label,
|
||||
groups,
|
||||
}: {
|
||||
label: string;
|
||||
groups: ToolGroup[];
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<SectionLabel label={label} />
|
||||
<div className="space-y-3">
|
||||
{groups.map((group) => (
|
||||
<Collapsible
|
||||
key={group.label}
|
||||
title={group.label}
|
||||
subtitle={`${group.items.length} items`}
|
||||
>
|
||||
<ul className="space-y-2 text-[14px]">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.name} className="flex flex-col gap-0.5">
|
||||
<span className="text-neutral-100">{item.name}</span>
|
||||
{item.description && (
|
||||
<span className="text-neutral-400">{item.description}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsesPage() {
|
||||
return (
|
||||
<div className="space-y-14">
|
||||
<Hero label="Uses" subtitle="What I reach for daily." />
|
||||
<Hero label="Uses" subtitle="The gear, software, and setup behind my work." />
|
||||
|
||||
{sections.map((section) => (
|
||||
<section key={section.label} className="space-y-4">
|
||||
<SectionLabel label={section.label} />
|
||||
{section.kind === "list" ? (
|
||||
<ul className="space-y-2 text-[15px] leading-[1.65] text-neutral-200">
|
||||
{section.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="space-y-4 text-[15px] leading-[1.65] text-neutral-200">
|
||||
{section.paragraphs.map((p) => (
|
||||
<p key={p}>{p}</p>
|
||||
<section className="space-y-4">
|
||||
<SectionLabel label="Hardware" />
|
||||
|
||||
<div className="space-y-10">
|
||||
{hardware.map((group) => (
|
||||
<div key={group.label} className="space-y-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">
|
||||
{group.label}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{group.machines.map((machine) => (
|
||||
<Collapsible
|
||||
key={machine.name}
|
||||
title={machine.name}
|
||||
subtitle={machine.role}
|
||||
>
|
||||
<dl className="space-y-3 text-[14px]">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:gap-3">
|
||||
<dt className="w-20 shrink-0 text-neutral-500">OS</dt>
|
||||
<dd className="text-neutral-200">{machine.os}</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:gap-3">
|
||||
<dt className="w-20 shrink-0 text-neutral-500">Specs</dt>
|
||||
<dd className="flex flex-wrap gap-1.5">
|
||||
{machine.specs.map((spec) => (
|
||||
<span
|
||||
key={spec}
|
||||
className="rounded-md border border-neutral-800 bg-neutral-900 px-2 py-0.5 text-[12px] text-neutral-300"
|
||||
>
|
||||
{spec}
|
||||
</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
{machine.services && (
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:gap-3">
|
||||
<dt className="w-20 shrink-0 text-neutral-500">
|
||||
Running
|
||||
</dt>
|
||||
<dd>
|
||||
<ul className="space-y-1 text-neutral-200">
|
||||
{machine.services.map((service) => (
|
||||
<li
|
||||
key={service}
|
||||
className="before:mr-2 before:text-neutral-600 before:content-['–']"
|
||||
>
|
||||
{service}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{group.extras?.map((extra) => (
|
||||
<Collapsible
|
||||
key={extra.label}
|
||||
title={extra.label}
|
||||
subtitle={`${extra.items.length} items`}
|
||||
>
|
||||
<ul className="space-y-1.5 text-[14px] text-neutral-200">
|
||||
{extra.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="before:mr-2 before:text-neutral-600 before:content-['–']"
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ToolSection label="Editor & Shell" groups={editorAndShell} />
|
||||
<ToolSection label="AI Tools" groups={aiTools} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { IconArrowNarrowRight } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { IconArrowNarrowRight, IconExternalLink } from '@tabler/icons-react';
|
||||
import { colors } from '@/constants';
|
||||
|
||||
interface ProjectCardProps {
|
||||
title: string;
|
||||
year: string;
|
||||
stack: string[];
|
||||
description: string;
|
||||
outcome: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
year: string;
|
||||
stack: string[];
|
||||
description: string;
|
||||
liveUrl: string;
|
||||
}
|
||||
|
||||
export default function ProjectCard({ title, year, stack, description, outcome }: ProjectCardProps) {
|
||||
export default function ProjectCard({ slug, title, year, stack, description, liveUrl }: ProjectCardProps) {
|
||||
return (
|
||||
<section className="space-y-2 border-t border-neutral-700 pt-4 flex flex-col mt-5">
|
||||
<button className='flex justify-between hover:text-neutral-500 transition-colors items-center w-full text-left cursor-pointer group'>
|
||||
<Link href={`/projects/${slug}`} className='flex justify-between hover:text-neutral-500 transition-colors items-center w-full text-left cursor-pointer group'>
|
||||
<h1 className="text-[24px] font-semibold">{title}</h1>
|
||||
<IconArrowNarrowRight className='group-hover:scale-150 transition-transform'/>
|
||||
</button>
|
||||
</Link>
|
||||
<p className="text-md text-neutral-400 text-muted">{year} — {stack.join(' · ')}</p>
|
||||
<p className="text-xl">{description}</p>
|
||||
<p className="text-md text-neutral-400 text-muted">{outcome}</p>
|
||||
<a
|
||||
href={liveUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex w-fit items-center gap-2 rounded-lg border border-neutral-700 px-4 py-2 text-[15px] font-medium transition-colors hover:border-neutral-500"
|
||||
style={{ color: colors.accent }}
|
||||
>
|
||||
Live Demo
|
||||
<IconExternalLink size={18} />
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { IconChevronLeft, IconChevronRight, IconZoomInFilled } from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import ImageViewer from "@/components/ui/ImageViewer";
|
||||
import type { Screenshot } from "@/constants/projects";
|
||||
|
||||
interface ProjectCarouselProps {
|
||||
screenshots: Screenshot[];
|
||||
}
|
||||
|
||||
/** Milliseconds each slide stays up before auto-advancing. */
|
||||
const AUTOPLAY_MS = 5000;
|
||||
|
||||
export default function ProjectCarousel({ screenshots }: ProjectCarouselProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const len = screenshots.length;
|
||||
const go = (i: number) => setIndex(((i % len) + len) % len);
|
||||
|
||||
useEffect(() => {
|
||||
if (len < 2 || paused || viewerOpen) return;
|
||||
const t = setInterval(() => setIndex((i) => (i + 1) % len), AUTOPLAY_MS);
|
||||
return () => clearInterval(t);
|
||||
}, [len, paused, viewerOpen]);
|
||||
|
||||
if (len === 0) return null;
|
||||
|
||||
const active = screenshots[index];
|
||||
|
||||
const handleButtonClick = (e: React.MouseEvent, i: number) => {
|
||||
e.stopPropagation();
|
||||
go(i);
|
||||
setPaused(true);
|
||||
setTimeout(() => setPaused(false), AUTOPLAY_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => { setPaused(true); setIsHovered(true); }}
|
||||
onMouseLeave={() => { setPaused(false); setIsHovered(false); }}
|
||||
onFocusCapture={() => setPaused(true)}
|
||||
onBlurCapture={() => setPaused(false)}
|
||||
>
|
||||
<div
|
||||
className="relative aspect-[8/5] w-full cursor-pointer overflow-hidden rounded-lg bg-black/50"
|
||||
onClick={() => setViewerOpen(true)}
|
||||
>
|
||||
{screenshots.map((shot, i) => (
|
||||
<Image
|
||||
key={shot.src}
|
||||
src={shot.src}
|
||||
alt={shot.alt}
|
||||
fill
|
||||
sizes="(min-width: 768px) 720px, 100vw"
|
||||
priority={i === 0}
|
||||
className={`object-contain transition-opacity duration-500 ${
|
||||
i === index ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 z-10 hidden items-center justify-center md:flex">
|
||||
{isHovered && <IconZoomInFilled size={32} className="text-neutral-300" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<p key={index} className="animate-fade-in text-sm text-neutral-500">
|
||||
{active.caption ?? active.alt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{len > 1 && (
|
||||
<div className="flex justify-center gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous image"
|
||||
onClick={(e) => { handleButtonClick(e, index - 1); }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
className={`p-1 rounded-full hover:bg-neutral-800 justify-center items-center flex `}
|
||||
>
|
||||
<IconChevronLeft size={20} />
|
||||
</button>
|
||||
{screenshots.map((shot, i) => (
|
||||
<button
|
||||
key={shot.src}
|
||||
type="button"
|
||||
aria-label={`Go to image ${i + 1}`}
|
||||
aria-current={i === index}
|
||||
onClick={() => go(i)}
|
||||
className={`h-3 rounded-full transition-all cursor-pointer ${
|
||||
i === index ? "w-6 bg-neutral-300" : "w-3 bg-neutral-700 hover:bg-neutral-500"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next image"
|
||||
onClick={(e) => { handleButtonClick(e, index + 1); }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
className={`p-1 rounded-full hover:bg-neutral-800 justify-center items-center flex `}
|
||||
>
|
||||
<IconChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewerOpen && (
|
||||
<ImageViewer
|
||||
items={screenshots}
|
||||
index={index}
|
||||
onIndexChange={go}
|
||||
onClose={() => setViewerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { IconZoomInFilled } from "@tabler/icons-react";
|
||||
|
||||
interface ProjectImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function ProjectImage({ src, alt, caption, onClick }: ProjectImageProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovered(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative cursor-pointer"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={1200}
|
||||
height={750}
|
||||
className={`h-auto w-full rounded-lg border border-neutral-800`}
|
||||
/>
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<p className="text-sm text-neutral-500">{caption ?? alt}</p>
|
||||
</div>
|
||||
|
||||
<div className={`pointer-events-none absolute inset-0 z-100 hidden md:flex transition-opacity duration-300 justify-center items-center w-full h-full`}>
|
||||
{isHovered && <IconZoomInFilled size={32} className="text-neutral-300" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export default function Testimonials({ items }: TestimonialsProps) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<blockquote className="p-3">{active.quote}</blockquote>
|
||||
<blockquote className="p-3">"{active.quote}"</blockquote>
|
||||
|
||||
<footer className="flex items-center justify-between">
|
||||
<div className="flex mt-3">
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import {
|
||||
IconBrandLinkedinFilled,
|
||||
IconMailFilled,
|
||||
IconPhoneFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import GiteaIcon from "@/components/icons/GiteaIcon";
|
||||
import Tooltip from "@/components/ui/Tooltip";
|
||||
import CopyButton from "@/components/ui/CopyButton";
|
||||
import { email, linkedIn, phone, gitea } from "@/constants";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mx-auto w-full max-w-4xl px-6 py-6 flex items-center justify-between border-t border-neutral-800 text-xs text-neutral-500 sticky bottom-0 backdrop-blur-lg">
|
||||
<span>© 2026 Angel Mankel</span>
|
||||
<span>Self-hosted · Traefik · Next.js</span>
|
||||
<nav aria-label="Contact" className="flex gap-3 text-neutral-600">
|
||||
<CopyButton value={email} label="Copy email">
|
||||
<IconMailFilled size={18} />
|
||||
</CopyButton>
|
||||
<CopyButton value={phone} label="Copy phone">
|
||||
<IconPhoneFilled size={18} />
|
||||
</CopyButton>
|
||||
<Tooltip text="LinkedIn">
|
||||
<a
|
||||
href={linkedIn}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="LinkedIn"
|
||||
className="rounded-md p-1 transition-colors duration-200 hover:text-neutral-200"
|
||||
>
|
||||
<IconBrandLinkedinFilled size={18} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip text="Gitea">
|
||||
<a
|
||||
href={gitea}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Gitea"
|
||||
className="rounded-md p-1 transition-colors duration-200 hover:text-neutral-200"
|
||||
>
|
||||
<GiteaIcon size={18} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</nav>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IconArrowNarrowRight } from '@tabler/icons-react';
|
||||
import { colors } from '@/constants';
|
||||
|
||||
export default function AccentLink({ link, label }: { link: string, label: string }) {
|
||||
export default function AccentLink({ link, label, download }: { link: string, label: string, download?: boolean }) {
|
||||
return (
|
||||
<a href={link} className='flex gap-1 items-center hover:translate-x-2 hover:scale-110 transition-transform duration-200 w-fit'>
|
||||
<a href={link} download={download} className='flex gap-1 items-center hover:translate-x-2 hover:scale-110 transition-transform duration-200 w-fit'>
|
||||
<p style={{ color: colors.accent }}>{label}</p>
|
||||
<IconArrowNarrowRight color={colors.accent} />
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
|
||||
type CollapsibleProps = {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function Collapsible({
|
||||
title,
|
||||
subtitle,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
}: CollapsibleProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900/40 transition-colors hover:border-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-medium text-neutral-100">
|
||||
{title}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="mt-0.5 text-xs text-neutral-400">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<IconChevronDown
|
||||
size={18}
|
||||
className={`shrink-0 text-neutral-500 transition-transform duration-200 ${
|
||||
open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`grid transition-[grid-template-rows] duration-300 ease-out ${
|
||||
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="border-t border-neutral-800 px-4 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
type CopyButtonProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
copiedLabel?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function CopyButton({
|
||||
value,
|
||||
label,
|
||||
copiedLabel = "Copied!",
|
||||
className = "rounded-md p-1 text-neutral-600 transition-colors duration-200 hover:text-neutral-200 cursor-pointer",
|
||||
children,
|
||||
}: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.currentTarget.blur();
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// clipboard unavailable — silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="group relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={label}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="
|
||||
pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2
|
||||
rounded-md bg-neutral-800 px-2 py-1 text-xs text-neutral-100
|
||||
whitespace-nowrap opacity-0
|
||||
transition-opacity duration-150
|
||||
group-hover:opacity-100 group-focus-within:opacity-100
|
||||
"
|
||||
>
|
||||
{copied ? copiedLabel : label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconX, IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||
import { usePanZoom } from '@/hooks/usePanZoom';
|
||||
import type { Screenshot } from '@/constants/projects';
|
||||
|
||||
type ImageViewerProps = {
|
||||
items: Screenshot[];
|
||||
index: number;
|
||||
onIndexChange: (i: number) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** Caption to display — falls back to the required `alt` text. */
|
||||
const captionOf = (s: Screenshot) => s.caption ?? s.alt;
|
||||
|
||||
/**
|
||||
* Full-bleed fullscreen image viewer with pan / pinch / wheel zoom, keyboard
|
||||
* navigation, and a thumbnail rail (vertical on desktop, horizontal at the
|
||||
* bottom on mobile). Borrowed from imagelab-gpu's FullscreenImage, trimmed to
|
||||
* the parts this portfolio needs — no slideshow.
|
||||
*/
|
||||
export default function ImageViewer({ items, index, onIndexChange, onClose }: ImageViewerProps) {
|
||||
// Natural size is tagged with the index it was measured for, so it reads as
|
||||
// null until the *current* image's onLoad fires — no reset effect needed.
|
||||
const [sized, setSized] = useState<{ i: number; w: number; h: number } | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const activeThumbRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const len = items.length;
|
||||
const item = items[index];
|
||||
const prev = () => onIndexChange((index - 1 + len) % len);
|
||||
const next = () => onIndexChange((index + 1) % len);
|
||||
|
||||
const { zoom, offset, reframing, handlers } = usePanZoom(stageRef, {
|
||||
resetKey: index,
|
||||
onSwipe: (dir) => onIndexChange((index + dir + len) % len),
|
||||
onTap: onClose,
|
||||
});
|
||||
|
||||
// Lock body scroll while the viewer is open.
|
||||
useEffect(() => {
|
||||
const original = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = original; };
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation: arrows to move, Esc to close.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
else if (e.key === 'ArrowLeft') onIndexChange((index - 1 + len) % len);
|
||||
else if (e.key === 'ArrowRight') onIndexChange((index + 1) % len);
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [index, len, onClose, onIndexChange]);
|
||||
|
||||
// Keep the active thumbnail scrolled into view.
|
||||
useEffect(() => {
|
||||
activeThumbRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}, [index]);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const url = item.src;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[80] flex flex-col overflow-hidden bg-black md:flex-row">
|
||||
{/* Ambient backdrop sampled from the current image via heavy blur. */}
|
||||
<div className="pointer-events-none absolute inset-0 z-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="h-full w-full scale-125 object-cover opacity-40"
|
||||
style={{ filter: 'blur(60px)' }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
</div>
|
||||
|
||||
{/* Stage — owns the pan/zoom gestures. */}
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative z-10 min-h-0 flex-1 overflow-hidden"
|
||||
style={{ touchAction: 'none' }}
|
||||
onWheel={handlers.onWheel}
|
||||
onPointerDown={handlers.onPointerDown}
|
||||
onPointerMove={handlers.onPointerMove}
|
||||
onPointerUp={handlers.onPointerUp}
|
||||
onPointerCancel={handlers.onPointerUp}
|
||||
onClick={handlers.onClick}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center p-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
alt={item.alt}
|
||||
draggable={false}
|
||||
onLoad={(e) => setSized({ i: index, w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
|
||||
className="max-h-full max-w-full select-none rounded-md shadow-[0_20px_70px_rgba(0,0,0,0.7)]"
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
|
||||
transition: reframing ? 'transform 250ms cubic-bezier(0.22, 1, 0.36, 1)' : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close viewer"
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white"
|
||||
>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
|
||||
{/* Page counter + dimensions */}
|
||||
<div className="pointer-events-none absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/40 px-3 py-1.5 font-mono text-[12px] text-white/70 backdrop-blur-md">
|
||||
<span>{index + 1} / {len}</span>
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-10 flex justify-center px-16">
|
||||
<p className="max-w-2xl rounded-full bg-black/40 px-4 py-1.5 text-center text-sm text-white/90 backdrop-blur-md">
|
||||
{captionOf(item)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail rail — horizontal at the bottom on mobile, vertical on the right on desktop. */}
|
||||
{len > 1 && (
|
||||
<div className="z-10 flex shrink-0 gap-2 overflow-x-auto border-t border-white/10 bg-black/40 p-3 backdrop-blur-md md:w-32 md:flex-col md:overflow-x-visible md:overflow-y-auto md:border-l md:border-t-0">
|
||||
{items.map((shot, i) => {
|
||||
const isActive = i === index;
|
||||
return (
|
||||
<button
|
||||
key={shot.src}
|
||||
ref={isActive ? activeThumbRef : null}
|
||||
type="button"
|
||||
aria-label={`View ${captionOf(shot)}`}
|
||||
aria-current={isActive}
|
||||
onClick={() => onIndexChange(i)}
|
||||
className={`relative shrink-0 overflow-hidden rounded-md border transition-all md:w-full ${
|
||||
isActive
|
||||
? 'border-white/80 opacity-100 ring-2 ring-white/60'
|
||||
: 'border-white/10 opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={shot.src}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="h-16 w-24 object-cover md:h-20 md:w-full"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { IconXFilled } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type ModalProps = {
|
||||
children?: React.ReactNode;
|
||||
headline?: string;
|
||||
open?: boolean;
|
||||
setOpen?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export default function Modal({ children, headline, open, setOpen }: ModalProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const original = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = original;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && handleClose();
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [open]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (setOpen) setOpen(false);
|
||||
};
|
||||
|
||||
if (!mounted || !open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
onClick={handleClose}
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xl"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="fixed inset-0 m-2 sm:m-24 sm:mx-64 xs:mx-64 flex flex-col rounded-md border border-neutral-700 bg-neutral-900/50 p-2"
|
||||
>
|
||||
<nav className="flex justify-between items-center p-2">
|
||||
<div className="text-lg font-medium text-center w-full">
|
||||
{headline}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-neutral-400 hover:text-neutral-200 rounded-md p-1 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<IconXFilled />
|
||||
</button>
|
||||
</nav>
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const about: string[] = [
|
||||
"When I was about 10, I built a working gaming PC out of parts and a dead Dell tower. Taking things apart, figuring out how they worked, and putting them back together is what eventually pulled me into software development.",
|
||||
"I started my career as an Audio Engineer in Los Angeles, CA — where I spent three years recording and mixing music. I loved it, but I realized the technical problem-solving was what I'd really been chasing all along.",
|
||||
"I moved back to Arizona in 2016 to pursue a career in technology. Took an IT Help Desk job and taught myself Python and JavaScript. A few years later I was a Software Engineer building production web apps for a health tech company. Now I'm finishing my BA in Computer Science and heading straight into a Master's in AI.",
|
||||
"Outside of work I build web apps for myself, develop games in Unreal Engine 5, and dig into AI pipelines — Claude Code is wired into most of my daily workflows.",
|
||||
"I'm currently looking for US-based, remote AI-Native roles.",
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
export const email = "angelmankel@gmail.com"
|
||||
export const linkedIn = "https://www.linkedin.com/in/angel-mankel-0616aa132/"
|
||||
export const phone = "+1 (520) 709-0197"
|
||||
export const gitea = "https://gitea.dev.blueoceanswim.com/donny/portfolio"
|
||||
@@ -2,4 +2,7 @@ export { colors } from "./colors";
|
||||
export { testimonials } from "./testimonials";
|
||||
export { projects } from "./projects";
|
||||
export { education } from "./education";
|
||||
export { timeline } from "./timeline";
|
||||
export { timeline } from "./timeline";
|
||||
export { hardware, editorAndShell, aiTools } from "./uses";
|
||||
export { about } from "./about";
|
||||
export { email, linkedIn, phone, gitea } from "./contact";
|
||||
@@ -1,44 +1,148 @@
|
||||
export const projects = [
|
||||
export type Screenshot = {
|
||||
src: string;
|
||||
/** Accessibility / SEO text — always required. */
|
||||
alt: string;
|
||||
/** Human-facing label shown in the carousel + viewer. Falls back to `alt`. */
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
/** Route segment — the page lives at /projects/<slug> */
|
||||
slug: string;
|
||||
title: string;
|
||||
year: string;
|
||||
stack: string[];
|
||||
/** Short summary shown on cards */
|
||||
description: string;
|
||||
/** Live demo URL */
|
||||
liveUrl: string;
|
||||
/** Longer write-up for the detail page (one entry per paragraph) */
|
||||
overview: string[];
|
||||
screenshots: Screenshot[];
|
||||
/** What I learned building it */
|
||||
learned: string[];
|
||||
/** What I'd do differently next time */
|
||||
improvements: string[];
|
||||
};
|
||||
|
||||
export const projects: Project[] = [
|
||||
{
|
||||
title: "Project 1",
|
||||
year: "2023",
|
||||
stack: ["React", "Node.js"],
|
||||
description: "A web application for managing tasks.",
|
||||
outcome: "Increased productivity by 20%."
|
||||
slug: "orbit",
|
||||
title: "Orbit",
|
||||
year: "2026",
|
||||
stack: ["React", "TypeScript", "Bun", "Hono", "SQLite", "MCP"],
|
||||
description:
|
||||
"A self-hosted project and ticket management workspace with spaces, pages, and notes. MCP server",
|
||||
liveUrl: "https://demo.angelmankel.com/orbit",
|
||||
overview: [
|
||||
"Orbit is a custom project management tool I built for myself to organize my personal projects and easily integrate Claude Code to manage projects with natural language. I can create kanban boards with tickets, create markdown formatted documentation, upload files, and create excalidraw diagrams — all in one place. And since I built it with AI agents in mind, the MCP server provides an API for programmatic access to all my data.",
|
||||
"Orbit solves the friction of context-switching between separate tools for project management, documentation, and file storage — it's one workspace for all of it. A key feature is being able to upload screenshots or files to a ticket, then hand them to Claude Code or other agents without pasting anything into the terminal — which matters a lot over SSH, where pasting files is a real pain point. And the MCP server lets all my local AI infrastructure pull project context, find relevant information, and create or update tickets directly, without ever touching the web interface."
|
||||
],
|
||||
screenshots: [
|
||||
{ src: "/projects/orbit/1.png", alt: "Kanban board view" },
|
||||
{ src: "/projects/orbit/2.png", alt: "Ticket detail view" },
|
||||
{ src: "/projects/orbit/3.png", alt: "Documentation view" },
|
||||
{ src: "/projects/orbit/4.png", alt: "Settings view" },
|
||||
],
|
||||
learned: ["I learned a lot about the MCP protocol and some of the roadblocks you can run into when allowing AI agents to create and modify data in a tool like this. One example is ensuring agents have a way to patch slices of data without needing to resubmit the entire content. It's MUCH more efficient for an agent and I learned first hand - wondering why a simple markdown adjustment I asked for was taking 5+ minutes! Another lesson learned was gating off destructive functions and making sure to have a backup/restore strategy. Claude once deleted all my projects during development (I had a backup) and it got me thinking more about what specific tools to expose to agents to empower them while minimizing risk."],
|
||||
improvements: ["The app works great for what I wanted it for, but there are still some issues. One being the markdown editor and parsing is a bit buggy sometimes, and if I were to do this all over again, I'd probably use a more robust solution. The layout is a bit clunky in my opinion, and I would have liked a more Jira style layout with a tabbed top nav instead of the sidebar. Lastly, I created a web chat feature which would allow Claude to interact with you with a small set of web components like checkboxes, buttons, text inputs, etc. But it needed a much more robust communication channel to work seamlessly."],
|
||||
},
|
||||
{
|
||||
title: "Project 2",
|
||||
year: "2023",
|
||||
stack: ["React", "Node.js"],
|
||||
description: "A web application for managing tasks.",
|
||||
outcome: "Increased productivity by 20%."
|
||||
slug: "imagelab",
|
||||
title: "ImageLab",
|
||||
year: "2026",
|
||||
stack: ["React", "TypeScript", "Pixi.js", "Tailwind CSS"],
|
||||
description:
|
||||
"A browser-based AI image processing playground. Built as a frontend for the popular open source project: ComfyUI",
|
||||
liveUrl: "https://demo.angelmankel.com/imagelab",
|
||||
overview: [
|
||||
"ImageLab is something I've built many different iterations of over the years — and it's grown over time just as AI image generation has evolved. This latest version replaces heavy infrastructure with a simple client-side web interface that anyone can utilize with their own local ComfyUI instance. All you really need is the frontend running in your browser and a ComfyUI server running my custom node plugin. You can even add multiple servers to control multiple instances at once. The app provides a parameters focused view for experiementing, a model browser with metadata pulled from Civit.ai (models are hashed in my custom node), and an inpainting mode with an infinite canvas driven by Pixi.js for super smooth performance.",
|
||||
"I've used many different types of image generation interfaces, and I always felt like there was so much room for improvement. I liked the flexibility of ComfyUI's node-based system, but wanted a way to interact with a custom workflow while abstracting away the complexity after it's been set up. ImageLab allows me to utilize a pretty complex workflow in the background, but easily interface with it and experiment without having to mess with a node graph at all. Not to mention the model browser allows me to actually see metadata about the model which provides important context when experimenting. And the infinite canvas adds features not available in ComfyUI itself. Spinning up a Runpod instance using a H200 and connecting it to ImageLab in just a few clicks is awesome as well.",
|
||||
],
|
||||
screenshots: [
|
||||
{ src: "/projects/imagelab/1.png", alt: "Main editor" },
|
||||
{ src: "/projects/imagelab/2.png", alt: "Prompt snippets library feature" },
|
||||
{ src: "/projects/imagelab/3.png", alt: "Model metadata modal view with data from Civit.ai" },
|
||||
{ src: "/projects/imagelab/4.png", alt: "Model selector with metadata from Civit.ai" },
|
||||
{ src: "/projects/imagelab/5.png", alt: "Fullscreen image modal with image metadata" },
|
||||
{ src: "/projects/imagelab/6.png", alt: "Saved images collection view" },
|
||||
{ src: "/projects/imagelab/7.png", alt: "Inpainting workflow 1" },
|
||||
{ src: "/projects/imagelab/8.png", alt: "Inpainting workflow 2" },
|
||||
{ src: "/projects/imagelab/9.png", alt: "Inpainting workflow 3" },
|
||||
],
|
||||
learned: ["ImageLab was the first big web project I stepped into back when AI image generation started getting popular around 2021. Back then, I was familar with React but I was still not sure how web servers worked, or what full stack development really entailed. This project really pushed me to learn about relational databases, web servers, REST APIs, authentication, and many other core web development concepts. This project will always be a special one to me because it really represents my journey into web development and how much I've grown since then."],
|
||||
improvements: ["This project has gone through so many iterations over the years - client-only, complex multi-server configurations, Tauri/Electron apps, and more. If I were to do it all over again, I would keep the client-side approach to keep things simple, but I would add an optional web server that can be used alongside the client to persist data across sessions. And a lot of the UI/UX could be improved with a lot more time and testing. The inpainting workflow is overly complex and could be simplified a lot as well."],
|
||||
},
|
||||
{
|
||||
title: "Project 3",
|
||||
year: "2023",
|
||||
stack: ["React", "Node.js"],
|
||||
description: "A web application for managing tasks.",
|
||||
outcome: "Increased productivity by 20%."
|
||||
slug: "league",
|
||||
title: "League of Legends Companion",
|
||||
year: "2026",
|
||||
stack: ["React", "TypeScript", "Hono", "MySQL", "Riot API"],
|
||||
description:
|
||||
"An app I built for a friend who was trying to climb the ranks in League of Legends. It pulls in match data and account stats from the Riot API, and allows users to keep notes on matchups, builds, and more.",
|
||||
liveUrl: "https://demo.angelmankel.com/league",
|
||||
overview: [
|
||||
"This project is essentially a note taking and builds app for League of Legends, the popular competitive MOBA game. It utilizes a MySQL database to store user data, match history, and user created builds, item sets, and more. It utilizes the public and private Riot API to pull in match history, account rank and other data, and game assets like item icons and champion art.",
|
||||
"My best friend is a long time PC gamer. He's climbed the ranks in multiple competitive games over the years, and has even played and won in real tournaments. One game he had not quite mastered was League of Legends, and he set out to challenge himself to push to a higher rank in the game. He previously used spreadsheets of matchup data acquired from high level players on Patreon and other sources, but we all know spreadsheets are mostly nothing but headaches. I built this app for him to easily keep track of matchup data, with a clean data model on the backend to allow for importing from multiple data sources easily.",
|
||||
],
|
||||
screenshots: [
|
||||
{ src: "/projects/league-helper/1.png", alt: "League of Legends Companion screenshot 1" },
|
||||
{ src: "/projects/league-helper/2.png", alt: "League of Legends Companion screenshot 2" },
|
||||
{ src: "/projects/league-helper/3.png", alt: "League of Legends Companion screenshot 3" },
|
||||
{ src: "/projects/league-helper/4.png", alt: "League of Legends Companion screenshot 4" },
|
||||
{ src: "/projects/league-helper/5.png", alt: "League of Legends Companion screenshot 5" },
|
||||
{ src: "/projects/league-helper/6.png", alt: "League of Legends Companion screenshot 6" },
|
||||
{ src: "/projects/league-helper/7.png", alt: "League of Legends Companion screenshot 7" },
|
||||
],
|
||||
learned: ["This is actually the second iteration of this app. This iteration added many more features, utilized more of the available window space, and the data model was build from the ground up to allow for maximum flexibility and extensibility. The main thing I learned from this experience was that building on external APIs needs to be handled with care and planning. Since I don't control the API, I had to make sure to account for breaking changes. Luckily, a big update came out halfway through development, exposing parts I missed and ultimately forced me to learn the hard way."],
|
||||
improvements: ["I originally planned on adding a companion app that a user would install on their PC, which would talk to my server and pull in live match data. This data would be shown in the UI in a 'live game' mode. This would have been a really cool feature, but it was deceptively complex and required a lot of back and forth between my dev machine and a gaming machine to test. If I were to do this all over again, I would do more planning and research before attempting to build the feature."],
|
||||
},
|
||||
{
|
||||
title: "Project 4",
|
||||
year: "2023",
|
||||
stack: ["React", "Node.js"],
|
||||
description: "A web application for managing tasks.",
|
||||
outcome: "Increased productivity by 20%."
|
||||
slug: "study",
|
||||
title: "Study Time",
|
||||
year: "2026",
|
||||
stack: ["React", "TypeScript", "Vite", "Mantine"],
|
||||
description:
|
||||
"A study app I built for those late night study sessions - essentially a free Quizlet.",
|
||||
liveUrl: "https://demo.angelmankel.com/study",
|
||||
overview: [
|
||||
"Study Time is a very simple web app I built for myself to help with studying for my computer architecture class. This class required A LOT of memorization and a broad understanding of many different topics. I wanted a simple way to keep track of my progress and quiz myself on specific topics. It also was a great place to drop in all of my course content and have Claude Code parse the data, and upload it to the app since I built is as a fully JSON data driven system.",
|
||||
"This app really helped keep me on track with studying for the initial class but also carried over to many afterwards as well. The fact that it's a web app on my local network means I could start a quiz on my desktop, and then pull it up on my phone if I needed to step away and pick up where I left off. And a huge plus was that it is completely free and saved me from having to pay for Quizlet.",
|
||||
],
|
||||
screenshots: [
|
||||
{ src: "/projects/study-time/1.png", alt: "Study Time screenshot 1" },
|
||||
{ src: "/projects/study-time/2.png", alt: "Study Time screenshot 2" },
|
||||
{ src: "/projects/study-time/3.png", alt: "Study Time screenshot 3" },
|
||||
{ src: "/projects/study-time/4.png", alt: "Study Time screenshot 4" },
|
||||
{ src: "/projects/study-time/5.png", alt: "Study Time screenshot 5" },
|
||||
{ src: "/projects/study-time/6.png", alt: "Study Time screenshot 6" },
|
||||
{ src: "/projects/study-time/7.png", alt: "Study Time screenshot 7" },
|
||||
],
|
||||
learned: ["This app was a fun and quick project, built mainly with Claude Code. This was actually partially an experiment to see how well Claude Code could handle a project with a lot of different components and features. I was pleasantly surprised with how well it handled the project, and it ended up being a great way to quickly build a useful tool for myself. The main thing I learned from this project was that having a clear data model and structure from the beginning is crucial when building with AI assistance. It allows the AI to generate code that fits within the structure and reduces the chances of running into issues down the line."],
|
||||
improvements: ["If I were to do this again, I would expand on the study games/modes which I partially added but are currently a bit buggy. I hardly used anything other than the quiz feature and flash cards, but if those other modes were more fleshed out, they might have made studying a bit less monotonous."],
|
||||
},
|
||||
|
||||
{
|
||||
title: "Project 5",
|
||||
year: "2023",
|
||||
stack: ["React", "Node.js"],
|
||||
description: "A web application for managing tasks.",
|
||||
outcome: "Increased productivity by 20%."
|
||||
slug: "d2r",
|
||||
title: "Diablo II: Resurrected Tracker",
|
||||
year: "2026",
|
||||
stack: ["Bun", "JavaScript", "HTML/CSS"],
|
||||
description:
|
||||
"An event tracker and companion app for the game Diablo II: Resurrected.",
|
||||
liveUrl: "https://demo.angelmankel.com/d2r",
|
||||
overview: [
|
||||
"This project is a simple companion app for the game Diablo II: Resurrected. It has a few different features, but the main one is a live event tracker that pulls data from an unofficial API to show when in-game events are happening. It also has some guides and tools that are useful when creating new builds in the game.",
|
||||
"My friends and I are big Diablo fans. Recently, when the Warlock expansion was released for D2R, we started playing regularly again. We had a lot of runes and other high value items we were trading back and forth to other players to get gear we wanted for new builds. And there are now live service events in the game that we wanted to easily keep track of. So I built this app to solve those problems. It has a live event tracker that pulls data from an unofficial API, pricing data for high value runes and items to ensure we know how much things are worth when trading, and a few quality of life features that help when creating new builds and theorycrafting.",
|
||||
],
|
||||
screenshots: [
|
||||
{ src: "/projects/d2r/1.png", alt: "Diablo II: Resurrected Tracker screenshot 1" },
|
||||
{ src: "/projects/d2r/2.png", alt: "Diablo II: Resurrected Tracker screenshot 2" },
|
||||
{ src: "/projects/d2r/3.png", alt: "Diablo II: Resurrected Tracker screenshot 3" },
|
||||
{ src: "/projects/d2r/4.png", alt: "Diablo II: Resurrected Tracker screenshot 4" },
|
||||
{ src: "/projects/d2r/5.png", alt: "Diablo II: Resurrected Tracker screenshot 5" },
|
||||
{ src: "/projects/d2r/6.png", alt: "Diablo II: Resurrected Tracker screenshot 6" },
|
||||
],
|
||||
learned: ["This app was a great experience because it required me to utilize my existing web dev skills to get everything designed and working quickly - I didn't want to spend months on a simple companion app. I created a web scraper to pull data from the unofficial API which was something I normally stay away from. In this case, there were no other options so I had to dive in and learn quickly. The other thing was getting game data such as item images - by using some open source software, I was able to extract game assets directly from the game files and utilize them in my app. It was interesting to work with an AI agent to sort through the data and organize it for me, which would have been a nightmare to do manually."],
|
||||
improvements: ["Next time, I would take more time to plan out the features, adding much more robust build creation tools. It's a pretty simple guide right now, but it could be expanded to pull real build data from a character automatically, storing it in a database, and allowing users to share builds with each other. Similar to the popular website Maxroll.gg, but with more of a focus on QOL features."],
|
||||
},
|
||||
{
|
||||
title: "Project 6",
|
||||
year: "2023",
|
||||
stack: ["React", "Node.js"],
|
||||
description: "A web application for managing tasks.",
|
||||
outcome: "Increased productivity by 20%."
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
@@ -7,5 +7,12 @@ export const testimonials: Testimonial[] = [
|
||||
title: "Engineering Manager & Senior Engineer · Healthcare Tech",
|
||||
quote:
|
||||
"Angel is an outstanding engineer. One of the fastest learners I've ever met. If you put him on a project he will get up to speed & start working on it almost immediately, even if he has zero previous context. He is an excellent addition to any team. Reliable, insightful & a generally great human being.",
|
||||
},
|
||||
{
|
||||
name: "Karynn Tran",
|
||||
avatar: "https://picsum.photos/200",
|
||||
title: "Senior Software Engineer",
|
||||
quote:
|
||||
"I had the pleasure of working with Angel at VillageMD. What stood out immediately was how quickly Angel transitioned from the Salesforce team onto our Growth engineering team as a full stack engineer. He onboarded with impressive speed and confidence - hitting the ground running and contributing meaningfully in no time. Beyond his technical aptitude, Angel brings a genuine curiosity and willingness to learn. He always asked thoughtful questions and had a sharp eye for inefficiency, looking for ways to improve our processes rather than simply accepting the status quo. His knowledge of emerging technologies, particularly around AI, is impressive, and he regularly shared insights that broadened my own perspective. Angel is a true asset to any team he joins and any organization would be lucky to have him.",
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,155 @@
|
||||
export type Machine = {
|
||||
name: string;
|
||||
role: string;
|
||||
specs: string[];
|
||||
os: string;
|
||||
services?: string[];
|
||||
};
|
||||
|
||||
export type HardwareGroup = {
|
||||
label: string;
|
||||
machines: Machine[];
|
||||
extras?: { label: string; items: string[] }[];
|
||||
};
|
||||
|
||||
export type ToolEntry = {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type ToolGroup = {
|
||||
label: string;
|
||||
items: ToolEntry[];
|
||||
};
|
||||
|
||||
export const hardware: HardwareGroup[] = [
|
||||
{
|
||||
label: "For work",
|
||||
machines: [
|
||||
{
|
||||
name: "Minisforum MS-01",
|
||||
role: "Primary workstation",
|
||||
specs: ["Intel i9-12900H", "32GB DDR5", "4TB NVMe SSD"],
|
||||
os: "Nobara Linux 43 · KDE Plasma 6 · Wayland",
|
||||
services: [
|
||||
"Traefik",
|
||||
"Docker",
|
||||
"Gitea",
|
||||
"MetaMCP",
|
||||
"20+ self-hosted development infra services",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Minisforum N5 Pro",
|
||||
role: "Home infrastructure",
|
||||
specs: ["Ryzen AI 9 HX Pro 370", "32GB DDR5"],
|
||||
os: "Proxmox v8.3.2",
|
||||
services: [
|
||||
"AdGuard LXC",
|
||||
"Traefik",
|
||||
"Ubuntu Docker VM",
|
||||
"40+ self-hosted services",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Custom workstation",
|
||||
role: "GPU compute & creative work",
|
||||
specs: [
|
||||
"Ryzen 9 5900X",
|
||||
"64GB DDR4",
|
||||
"RTX A4500 20GB",
|
||||
"Radeon Pro W7600",
|
||||
],
|
||||
os: "Windows 11",
|
||||
},
|
||||
],
|
||||
extras: [
|
||||
{
|
||||
label: "Peripherals",
|
||||
items: [
|
||||
"TESmart 4-PC × 3-monitor KVM switch",
|
||||
"Keychron Q6",
|
||||
"Logitech PRO Superlight",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "For fun",
|
||||
machines: [
|
||||
{
|
||||
name: "Gaming rig",
|
||||
role: "Games & VR",
|
||||
specs: ["Ryzen 7 5800X", "64GB DDR4", "RTX 3090 24GB"],
|
||||
os: "Windows 11",
|
||||
},
|
||||
],
|
||||
extras: [
|
||||
{
|
||||
label: "Displays",
|
||||
items: [
|
||||
"2× Gigabyte G27Q — 27\" 1440p 144Hz",
|
||||
"1× ASUS VG289Q1A — 27\" 4K 60Hz",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const editorAndShell: ToolGroup[] = [
|
||||
{
|
||||
label: "Editors",
|
||||
items: [
|
||||
{ name: "VS Code", description: "" },
|
||||
{ name: "Zed", description: "" },
|
||||
{ name: "Visual Studio", description: "" },
|
||||
{ name: "JetBrains Rider", description: "" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Shell",
|
||||
items: [
|
||||
{ name: "Ghostty", description: "Terminal emulator on Linux" },
|
||||
{ name: "Windows Terminal", description: "Terminal emulator on Windows" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const aiTools: ToolGroup[] = [
|
||||
{
|
||||
label: "Coding",
|
||||
items: [
|
||||
{
|
||||
name: "Claude Code",
|
||||
description:
|
||||
"Daily driver — custom slash commands, MCP servers, agent workflows",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Chat clients",
|
||||
items: [
|
||||
{ name: "Claude", description: "claude.ai web & desktop" },
|
||||
{
|
||||
name: "Open WebUI",
|
||||
description: "Self-hosted chat UI in front of local & remote models",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Local models",
|
||||
items: [
|
||||
{ name: "Ollama", description: "Local model runtime" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "MCP & agents",
|
||||
items: [
|
||||
{
|
||||
name: "MetaMCP",
|
||||
description: "Self-hosted MCP server registry & router",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { THROW_MIN_DISTANCE, createVelocityTracker, runInertia } from '@/lib/momentum';
|
||||
|
||||
const ZOOM_MIN = 1;
|
||||
const ZOOM_MAX = 8;
|
||||
const SWIPE_THRESHOLD = 80; // px of horizontal flick (at zoom ~1) to navigate
|
||||
const CLICK_BUFFER = 6; // px of travel under which a press counts as a tap
|
||||
|
||||
type Options = {
|
||||
resetKey: unknown;
|
||||
onSwipe?: (dir: -1 | 1) => void;
|
||||
onTap?: () => void;
|
||||
};
|
||||
|
||||
export type PanZoom = {
|
||||
zoom: number;
|
||||
offset: { x: number; y: number };
|
||||
reframing: boolean;
|
||||
handlers: {
|
||||
onWheel: (e: React.WheelEvent) => void;
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerMove: (e: React.PointerEvent) => void;
|
||||
onPointerUp: (e: React.PointerEvent) => void;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Pan + pinch-zoom + wheel-zoom + release momentum for a CSS-transformed
|
||||
* element. Pure gesture mechanics built on `lib/momentum` — the consumer
|
||||
* supplies `onSwipe` for un-zoomed horizontal flicks and `onTap` for plain
|
||||
* presses, and applies the returned `zoom`/`offset` as a transform.
|
||||
*/
|
||||
export function usePanZoom(
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
{ resetKey, onSwipe, onTap }: Options,
|
||||
): PanZoom {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [reframing, setReframing] = useState(false);
|
||||
const reframeTimerRef = useRef<number | null>(null);
|
||||
|
||||
const reframe = () => {
|
||||
setZoom(ZOOM_MIN);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setReframing(true);
|
||||
if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current);
|
||||
reframeTimerRef.current = window.setTimeout(() => setReframing(false), 260);
|
||||
};
|
||||
|
||||
const pointersRef = useRef(new Map<number, { x: number; y: number; sx: number; sy: number }>());
|
||||
const downPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const panStartRef = useRef<{ ox: number; oy: number } | null>(null);
|
||||
const pinchStartRef = useRef<{ d: number; z: number; ox: number; oy: number; cx: number; cy: number } | null>(null);
|
||||
const velocity = useRef(createVelocityTracker());
|
||||
const cancelInertiaRef = useRef<(() => void) | null>(null);
|
||||
const zoomRef = useRef(zoom);
|
||||
const offsetRef = useRef(offset);
|
||||
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
|
||||
useEffect(() => { offsetRef.current = offset; }, [offset]);
|
||||
|
||||
const stopInertia = () => {
|
||||
cancelInertiaRef.current?.();
|
||||
cancelInertiaRef.current = null;
|
||||
};
|
||||
|
||||
const [prevKey, setPrevKey] = useState(resetKey);
|
||||
if (prevKey !== resetKey) {
|
||||
setPrevKey(resetKey);
|
||||
setZoom(ZOOM_MIN);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setReframing(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopInertia();
|
||||
if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current);
|
||||
};
|
||||
}, [resetKey]);
|
||||
useEffect(() => () => {
|
||||
stopInertia();
|
||||
if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) =>
|
||||
Math.hypot(a.x - b.x, a.y - b.y);
|
||||
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
stopInertia();
|
||||
const r = containerRef.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const mx = e.clientX - r.left - r.width / 2;
|
||||
const my = e.clientY - r.top - r.height / 2;
|
||||
const factor = Math.exp(-e.deltaY * 0.001);
|
||||
const z = zoomRef.current;
|
||||
const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z * factor));
|
||||
// Hit the floor → reframe to the original framing.
|
||||
if (newZoom <= ZOOM_MIN + 0.001 && z > ZOOM_MIN + 0.001) {
|
||||
reframe();
|
||||
return;
|
||||
}
|
||||
const k = newZoom / z - 1;
|
||||
setOffset(o => ({ x: o.x - mx * k, y: o.y - my * k }));
|
||||
setZoom(newZoom);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
stopInertia();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
const map = pointersRef.current;
|
||||
map.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY });
|
||||
|
||||
if (map.size === 1) {
|
||||
panStartRef.current = { ox: offsetRef.current.x, oy: offsetRef.current.y };
|
||||
pinchStartRef.current = null;
|
||||
downPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
velocity.current.reset(e.clientX, e.clientY);
|
||||
} else if (map.size === 2) {
|
||||
downPosRef.current = null;
|
||||
const [a, b] = [...map.values()];
|
||||
const r = containerRef.current!.getBoundingClientRect();
|
||||
pinchStartRef.current = {
|
||||
d: dist(a, b),
|
||||
z: zoomRef.current,
|
||||
ox: offsetRef.current.x,
|
||||
oy: offsetRef.current.y,
|
||||
cx: (a.x + b.x) / 2 - r.left - r.width / 2,
|
||||
cy: (a.y + b.y) / 2 - r.top - r.height / 2,
|
||||
};
|
||||
panStartRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
const map = pointersRef.current;
|
||||
const pt = map.get(e.pointerId);
|
||||
if (!pt) return;
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
|
||||
if (map.size === 1 && panStartRef.current) {
|
||||
const only = [...map.values()][0];
|
||||
setOffset({
|
||||
x: panStartRef.current.ox + (only.x - only.sx),
|
||||
y: panStartRef.current.oy + (only.y - only.sy),
|
||||
});
|
||||
velocity.current.sample(e.clientX, e.clientY);
|
||||
} else if (map.size === 2 && pinchStartRef.current) {
|
||||
const [a, b] = [...map.values()];
|
||||
const r = containerRef.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const start = pinchStartRef.current;
|
||||
const newD = dist(a, b);
|
||||
const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, start.z * newD / start.d));
|
||||
const ncx = (a.x + b.x) / 2 - r.left - r.width / 2;
|
||||
const ncy = (a.y + b.y) / 2 - r.top - r.height / 2;
|
||||
const imgPx = (start.cx - start.ox) / start.z;
|
||||
const imgPy = (start.cy - start.oy) / start.z;
|
||||
setZoom(newZoom);
|
||||
setOffset({ x: ncx - newZoom * imgPx, y: ncy - newZoom * imgPy });
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent) => {
|
||||
const map = pointersRef.current;
|
||||
const pt = map.get(e.pointerId);
|
||||
if (!pt) return;
|
||||
const dx = pt.x - pt.sx;
|
||||
const dy = pt.y - pt.sy;
|
||||
map.delete(e.pointerId);
|
||||
|
||||
if (map.size === 0) {
|
||||
if (zoomRef.current <= ZOOM_MIN + 0.001 && (offsetRef.current.x !== 0 || offsetRef.current.y !== 0)) {
|
||||
reframe();
|
||||
} else if (panStartRef.current) {
|
||||
const moved = Math.hypot(dx, dy);
|
||||
if (zoomRef.current <= 1.05) {
|
||||
if (moved > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy)) {
|
||||
onSwipe?.(dx > 0 ? -1 : 1);
|
||||
} else {
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}
|
||||
} else if (moved > THROW_MIN_DISTANCE) {
|
||||
stopInertia();
|
||||
cancelInertiaRef.current = runInertia(velocity.current.release(), (sdx, sdy) =>
|
||||
setOffset(o => ({ x: o.x + sdx, y: o.y + sdy })));
|
||||
}
|
||||
}
|
||||
panStartRef.current = null;
|
||||
pinchStartRef.current = null;
|
||||
} else if (map.size === 1) {
|
||||
const remaining = [...map.values()][0];
|
||||
remaining.sx = remaining.x;
|
||||
remaining.sy = remaining.y;
|
||||
panStartRef.current = { ox: offsetRef.current.x, oy: offsetRef.current.y };
|
||||
pinchStartRef.current = null;
|
||||
velocity.current.reset(remaining.x, remaining.y);
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
const down = downPosRef.current;
|
||||
downPosRef.current = null;
|
||||
if (!down) return;
|
||||
if (Math.hypot(e.clientX - down.x, e.clientY - down.y) <= CLICK_BUFFER) onTap?.();
|
||||
};
|
||||
|
||||
return { zoom, offset, reframing, handlers: { onWheel, onPointerDown, onPointerMove, onPointerUp, onClick } };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export const THROW_MIN_DISTANCE = 14;
|
||||
export const THROW_GRACE_MS = 24;
|
||||
export const THROW_STALE_MS = 90;
|
||||
|
||||
export function freshness(idleMs: number): number {
|
||||
if (idleMs <= THROW_GRACE_MS) return 1;
|
||||
return Math.max(0, 1 - (idleMs - THROW_GRACE_MS) / (THROW_STALE_MS - THROW_GRACE_MS));
|
||||
}
|
||||
|
||||
export type Vec = { x: number; y: number };
|
||||
|
||||
export function createVelocityTracker() {
|
||||
let last: { x: number; y: number; t: number } | null = null;
|
||||
let vx = 0;
|
||||
let vy = 0;
|
||||
|
||||
return {
|
||||
reset(x: number, y: number) {
|
||||
last = { x, y, t: performance.now() };
|
||||
vx = 0;
|
||||
vy = 0;
|
||||
},
|
||||
sample(x: number, y: number) {
|
||||
const now = performance.now();
|
||||
if (last) {
|
||||
const dt = now - last.t;
|
||||
if (dt > 0) {
|
||||
vx = vx * 0.7 + ((x - last.x) / dt) * 0.3;
|
||||
vy = vy * 0.7 + ((y - last.y) / dt) * 0.3;
|
||||
}
|
||||
}
|
||||
last = { x, y, t: now };
|
||||
},
|
||||
release(): Vec {
|
||||
const idle = last ? performance.now() - last.t : Infinity;
|
||||
const f = freshness(idle);
|
||||
return { x: vx * f, y: vy * f };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function runInertia(
|
||||
velocity: Vec,
|
||||
onStep: (dx: number, dy: number) => void,
|
||||
opts: { minSpeed?: number; stopSpeed?: number; decayPerFrame?: number } = {},
|
||||
): () => void {
|
||||
const minSpeed = opts.minSpeed ?? 0.04;
|
||||
const stopSpeed = opts.stopSpeed ?? 0.012;
|
||||
const decayPerFrame = opts.decayPerFrame ?? 0.94;
|
||||
let { x: vx, y: vy } = velocity;
|
||||
if (Math.hypot(vx, vy) < minSpeed) return () => { /* nothing started */ };
|
||||
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const dt = Math.min(40, now - last);
|
||||
last = now;
|
||||
onStep(vx * dt, vy * dt);
|
||||
const decay = Math.pow(decayPerFrame, dt / 16);
|
||||
vx *= decay;
|
||||
vy *= decay;
|
||||
if (Math.hypot(vx, vy) > stopSpeed) {
|
||||
raf = requestAnimationFrame(tick);
|
||||
} else {
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => { if (raf) cancelAnimationFrame(raf); };
|
||||
}
|
||||
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 804 KiB |
|
After Width: | Height: | Size: 879 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 76 KiB |