Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15b0ea1818 | |||
| 8b6393795e | |||
| 0fe7c10b1e | |||
| 54fd768af8 | |||
| f5010512d4 | |||
| 28e6151d36 | |||
| b6f9d33e55 | |||
| 70888d4096 | |||
| 92de396196 | |||
| 880b76eb43 | |||
| 796f422db0 |
+38
-31
@@ -1,11 +1,25 @@
|
|||||||
import type { Metadata } from "next";
|
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 Hero from "@/components/content/Hero";
|
||||||
import SectionLabel from "@/components/layout/SectionLabel";
|
import SectionLabel from "@/components/layout/SectionLabel";
|
||||||
import AccentLink from "@/components/navigation/AccentLink";
|
import AccentLink from "@/components/navigation/AccentLink";
|
||||||
import GiteaIcon from "@/components/icons/GiteaIcon";
|
import GiteaIcon from "@/components/icons/GiteaIcon";
|
||||||
import Tooltip from "@/components/ui/Tooltip";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "About — Angel Mankel",
|
title: "About — Angel Mankel",
|
||||||
@@ -18,23 +32,9 @@ export default function AboutPage() {
|
|||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<Hero label="About">
|
<Hero label="About">
|
||||||
<div className="space-y-5 text-[19px] leading-[1.65]">
|
<div className="space-y-5 text-[19px] leading-[1.65]">
|
||||||
<p>
|
{about.map((paragraph) => (
|
||||||
I came up through healthcare IT — help desk, then Salesforce
|
<p key={paragraph}>{paragraph}</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</Hero>
|
</Hero>
|
||||||
|
|
||||||
@@ -68,22 +68,27 @@ export default function AboutPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AccentLink link="/cv.pdf" label="Download CV (PDF)" />
|
<AccentLink link="/Angel_Mankel_Resume_2026.pdf" label="Download CV (PDF)" download />
|
||||||
|
|
||||||
{/* Social Links */}
|
{/* Social Links */}
|
||||||
<nav aria-label="Contact" className="flex gap-5 text-neutral-300">
|
<nav aria-label="Contact" className="flex gap-5 text-neutral-300">
|
||||||
<Tooltip text="Email">
|
<CopyButton
|
||||||
<a
|
value={email}
|
||||||
href="mailto:angelmankel@gmail.com"
|
label="Copy email"
|
||||||
aria-label="Email"
|
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200`}
|
||||||
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200`}
|
>
|
||||||
>
|
<IconMailFilled size={32} />
|
||||||
<IconMailFilled size={32} />
|
</CopyButton>
|
||||||
</a>
|
<CopyButton
|
||||||
</Tooltip>
|
value={phone}
|
||||||
|
label="Copy phone"
|
||||||
|
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200`}
|
||||||
|
>
|
||||||
|
<IconPhoneFilled size={32} />
|
||||||
|
</CopyButton>
|
||||||
<Tooltip text="LinkedIn">
|
<Tooltip text="LinkedIn">
|
||||||
<a
|
<a
|
||||||
href="https://www.linkedin.com/in/angel-mankel-0616aa132/"
|
href={linkedIn}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
aria-label="LinkedIn"
|
aria-label="LinkedIn"
|
||||||
@@ -94,7 +99,9 @@ export default function AboutPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip text="Gitea">
|
<Tooltip text="Gitea">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href={gitea}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
aria-label="Gitea"
|
aria-label="Gitea"
|
||||||
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200`}
|
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
|
|||||||
+6
-6
@@ -14,20 +14,20 @@ export default function HomePage() {
|
|||||||
<p className="font-serif italic text-3xl leading-[1.3]">
|
<p className="font-serif italic text-3xl leading-[1.3]">
|
||||||
I'm{" "}
|
I'm{" "}
|
||||||
<RotatingWord
|
<RotatingWord
|
||||||
items={["a developer", "a creative", "a tinkerer"]}
|
items={["a developer", "a creative", "a builder", "a tinkerer"]}
|
||||||
/>
|
/>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-[19px] leading-[1.65]">
|
<p className="text-[19px] leading-[1.65]">
|
||||||
Building software people rely on — and understanding what makes those
|
I build software people actually use, and I care about the people on
|
||||||
people tick — is what drives my greatest ambitions.
|
the other side of it as much as the code.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-[19px] leading-[1.65]">
|
<p className="text-[19px] leading-[1.65]">
|
||||||
I'm the kind of engineer who picks up whatever the problem needs and
|
I'll pick up whatever a problem needs and keep digging until it
|
||||||
keeps digging — into the code, and into the people it's meant for.
|
works. The constant isn't a particular stack — it's the
|
||||||
The throughline isn't a stack, it's the curiosity.
|
curiosity that got me here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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-5">
|
||||||
|
<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 && (
|
||||||
|
<section className="space-y-4">
|
||||||
|
{project.screenshots.map((shot) => (
|
||||||
|
<div
|
||||||
|
key={shot.src}
|
||||||
|
className="overflow-hidden rounded-xl border border-neutral-800"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={shot.src}
|
||||||
|
alt={shot.alt}
|
||||||
|
width={1200}
|
||||||
|
height={750}
|
||||||
|
className="h-auto w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<SectionLabel label="Overview" />
|
||||||
|
<div className="space-y-4 text-[17px] leading-[1.65] text-neutral-200">
|
||||||
|
{project.overview.map((paragraph) => (
|
||||||
|
<p key={paragraph}>{paragraph}</p>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+119
-59
@@ -1,77 +1,137 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Hero from "@/components/content/Hero";
|
import Hero from "@/components/content/Hero";
|
||||||
import SectionLabel from "@/components/layout/SectionLabel";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Uses — Angel Mankel",
|
title: "Uses — Angel Mankel",
|
||||||
};
|
};
|
||||||
|
|
||||||
type Section =
|
function ToolSection({
|
||||||
| { label: string; kind: "list"; items: string[] }
|
label,
|
||||||
| { label: string; kind: "prose"; paragraphs: string[] };
|
groups,
|
||||||
|
}: {
|
||||||
const sections: Section[] = [
|
label: string;
|
||||||
{
|
groups: ToolGroup[];
|
||||||
label: "Hardware",
|
}) {
|
||||||
kind: "list",
|
return (
|
||||||
items: [
|
<section className="space-y-4">
|
||||||
"Custom desktop — i9-12900H, 32GB RAM, Intel Iris Xe",
|
<SectionLabel label={label} />
|
||||||
"Nobara Linux 43 · KDE Plasma 6 · Wayland",
|
<div className="space-y-3">
|
||||||
"Headphones / keyboard / monitor — placeholder",
|
{groups.map((group) => (
|
||||||
],
|
<Collapsible
|
||||||
},
|
key={group.label}
|
||||||
{
|
title={group.label}
|
||||||
label: "Editor & Shell",
|
subtitle={`${group.items.length} items`}
|
||||||
kind: "list",
|
>
|
||||||
items: [
|
<ul className="space-y-2 text-[14px]">
|
||||||
"VS Code with a personal extension pack — placeholder",
|
{group.items.map((item) => (
|
||||||
"Bash / zsh — placeholder for shell",
|
<li key={item.name} className="flex flex-col gap-0.5">
|
||||||
"JetBrains Mono as the editor font",
|
<span className="text-neutral-100">{item.name}</span>
|
||||||
"Theme — placeholder",
|
{item.description && (
|
||||||
],
|
<span className="text-neutral-400">{item.description}</span>
|
||||||
},
|
)}
|
||||||
{
|
</li>
|
||||||
label: "AI Workflow",
|
))}
|
||||||
kind: "prose",
|
</ul>
|
||||||
paragraphs: [
|
</Collapsible>
|
||||||
"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.",
|
</div>
|
||||||
],
|
</section>
|
||||||
},
|
);
|
||||||
{
|
}
|
||||||
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",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function UsesPage() {
|
export default function UsesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-14">
|
<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 className="space-y-4">
|
||||||
<section key={section.label} className="space-y-4">
|
<SectionLabel label="Hardware" />
|
||||||
<SectionLabel label={section.label} />
|
|
||||||
{section.kind === "list" ? (
|
<div className="space-y-10">
|
||||||
<ul className="space-y-2 text-[15px] leading-[1.65] text-neutral-200">
|
{hardware.map((group) => (
|
||||||
{section.items.map((item) => (
|
<div key={group.label} className="space-y-4">
|
||||||
<li key={item}>{item}</li>
|
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">
|
||||||
))}
|
{group.label}
|
||||||
</ul>
|
</h3>
|
||||||
) : (
|
|
||||||
<div className="space-y-4 text-[15px] leading-[1.65] text-neutral-200">
|
<div className="space-y-3">
|
||||||
{section.paragraphs.map((p) => (
|
{group.machines.map((machine) => (
|
||||||
<p key={p}>{p}</p>
|
<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>
|
</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>
|
</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 {
|
interface ProjectCardProps {
|
||||||
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
year: string;
|
year: string;
|
||||||
stack: string[];
|
stack: string[];
|
||||||
description: string;
|
description: string;
|
||||||
outcome: 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 (
|
return (
|
||||||
<section className="space-y-2 border-t border-neutral-700 pt-4 flex flex-col mt-5">
|
<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>
|
<h1 className="text-[24px] font-semibold">{title}</h1>
|
||||||
<IconArrowNarrowRight className='group-hover:scale-150 transition-transform'/>
|
<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-md text-neutral-400 text-muted">{year} — {stack.join(' · ')}</p>
|
||||||
<p className="text-xl">{description}</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
export default function Footer() {
|
||||||
return (
|
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">
|
<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>© 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>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IconArrowNarrowRight } from '@tabler/icons-react';
|
import { IconArrowNarrowRight } from '@tabler/icons-react';
|
||||||
import { colors } from '@/constants';
|
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 (
|
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>
|
<p style={{ color: colors.accent }}>{label}</p>
|
||||||
<IconArrowNarrowRight color={colors.accent} />
|
<IconArrowNarrowRight color={colors.accent} />
|
||||||
</a>
|
</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",
|
||||||
|
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,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 — moved there at 18 years old and 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"
|
||||||
@@ -3,3 +3,6 @@ export { testimonials } from "./testimonials";
|
|||||||
export { projects } from "./projects";
|
export { projects } from "./projects";
|
||||||
export { education } from "./education";
|
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";
|
||||||
+112
-34
@@ -1,44 +1,122 @@
|
|||||||
export const projects = [
|
export type Screenshot = {
|
||||||
|
src: string;
|
||||||
|
alt: 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",
|
slug: "orbit",
|
||||||
year: "2023",
|
title: "Orbit",
|
||||||
stack: ["React", "Node.js"],
|
year: "2026",
|
||||||
description: "A web application for managing tasks.",
|
stack: ["Next.js", "TypeScript", "PostgreSQL"],
|
||||||
outcome: "Increased productivity by 20%."
|
description:
|
||||||
|
"A self-hosted project and ticket management workspace with spaces, pages, and notes.",
|
||||||
|
liveUrl: "https://demo.angelmankel.com/orbit",
|
||||||
|
overview: [
|
||||||
|
"TODO — what Orbit does and why you built it.",
|
||||||
|
"TODO — the core problem it solves and who it's for.",
|
||||||
|
],
|
||||||
|
screenshots: [
|
||||||
|
{ src: "https://picsum.photos/seed/orbit1/1200/750", alt: "Orbit board view" },
|
||||||
|
{ src: "https://picsum.photos/seed/orbit2/1200/750", alt: "Orbit ticket detail" },
|
||||||
|
],
|
||||||
|
learned: ["TODO — something you learned building this."],
|
||||||
|
improvements: ["TODO — what you'd do differently next time."],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Project 2",
|
slug: "imagelab",
|
||||||
year: "2023",
|
title: "ImageLab",
|
||||||
stack: ["React", "Node.js"],
|
year: "2026",
|
||||||
description: "A web application for managing tasks.",
|
stack: ["Next.js", "TypeScript", "WebAssembly"],
|
||||||
outcome: "Increased productivity by 20%."
|
description:
|
||||||
|
"A browser-based image processing playground for transforms, filters, and batch edits.",
|
||||||
|
liveUrl: "https://demo.angelmankel.com/imagelab",
|
||||||
|
overview: [
|
||||||
|
"TODO — what ImageLab does and why you built it.",
|
||||||
|
"TODO — the core problem it solves and who it's for.",
|
||||||
|
],
|
||||||
|
screenshots: [
|
||||||
|
{ src: "https://picsum.photos/seed/imagelab1/1200/750", alt: "ImageLab editor" },
|
||||||
|
{ src: "https://picsum.photos/seed/imagelab2/1200/750", alt: "ImageLab batch view" },
|
||||||
|
],
|
||||||
|
learned: ["TODO — something you learned building this."],
|
||||||
|
improvements: ["TODO — what you'd do differently next time."],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Project 3",
|
slug: "league",
|
||||||
year: "2023",
|
title: "League of Legends Companion",
|
||||||
stack: ["React", "Node.js"],
|
year: "2026",
|
||||||
description: "A web application for managing tasks.",
|
stack: ["React", "TypeScript", "Riot API"],
|
||||||
outcome: "Increased productivity by 20%."
|
description:
|
||||||
|
"An app I built for a friend who challenged themselves of reaching a higher rank. Surfaces specific matchup insights and personalized tips based on their match history.",
|
||||||
|
liveUrl: "https://demo.angelmankel.com/league",
|
||||||
|
overview: [
|
||||||
|
"TODO — what League of Legends Companion does and why you built it.",
|
||||||
|
"TODO — the core problem it solves and who it's for.",
|
||||||
|
],
|
||||||
|
screenshots: [
|
||||||
|
{ src: "https://picsum.photos/seed/league1/1200/750", alt: "League of Legends Companion overview" },
|
||||||
|
{ src: "https://picsum.photos/seed/league2/1200/750", alt: "League of Legends Companion match detail" },
|
||||||
|
],
|
||||||
|
learned: ["TODO — something you learned building this."],
|
||||||
|
improvements: ["TODO — what you'd do differently next time."],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Project 4",
|
slug: "study",
|
||||||
year: "2023",
|
title: "Study Time",
|
||||||
stack: ["React", "Node.js"],
|
year: "2026",
|
||||||
description: "A web application for managing tasks.",
|
stack: ["Next.js", "TypeScript", "Tailwind CSS"],
|
||||||
outcome: "Increased productivity by 20%."
|
description:
|
||||||
|
"A study app I built for those late night study sessions - essentially a free Quizlet",
|
||||||
|
liveUrl: "https://demo.angelmankel.com/study",
|
||||||
|
overview: [
|
||||||
|
"TODO — what Study Time does and why you built it.",
|
||||||
|
"TODO — the core problem it solves and who it's for.",
|
||||||
|
],
|
||||||
|
screenshots: [
|
||||||
|
{ src: "https://picsum.photos/seed/study1/1200/750", alt: "Study Time dashboard" },
|
||||||
|
{ src: "https://picsum.photos/seed/study2/1200/750", alt: "Study Time session view" },
|
||||||
|
],
|
||||||
|
learned: ["TODO — something you learned building this."],
|
||||||
|
improvements: ["TODO — what you'd do differently next time."],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: "Project 5",
|
slug: "d2r",
|
||||||
year: "2023",
|
title: "Diablo II: Resurrected Tracker",
|
||||||
stack: ["React", "Node.js"],
|
year: "2026",
|
||||||
description: "A web application for managing tasks.",
|
stack: ["React", "TypeScript", "Node.js"],
|
||||||
outcome: "Increased productivity by 20%."
|
description:
|
||||||
|
"A loot and event tracker for the game Diablo II: Resurrected.",
|
||||||
|
liveUrl: "https://demo.angelmankel.com/d2r",
|
||||||
|
overview: [
|
||||||
|
"TODO — what Diablo II: Resurrected Tracker does and why you built it.",
|
||||||
|
"TODO — the core problem it solves and who it's for.",
|
||||||
|
],
|
||||||
|
screenshots: [
|
||||||
|
{ src: "https://picsum.photos/seed/d2r1/1200/750", alt: "D2R Tracker grail view" },
|
||||||
|
{ src: "https://picsum.photos/seed/d2r2/1200/750", alt: "D2R Tracker item log" },
|
||||||
|
],
|
||||||
|
learned: ["TODO — something you learned building this."],
|
||||||
|
improvements: ["TODO — what you'd do differently next time."],
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
title: "Project 6",
|
|
||||||
year: "2023",
|
|
||||||
stack: ["React", "Node.js"],
|
|
||||||
description: "A web application for managing tasks.",
|
|
||||||
outcome: "Increased productivity by 20%."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
Binary file not shown.
Reference in New Issue
Block a user