Compare commits

...

14 Commits

50 changed files with 849 additions and 106 deletions
+50 -39
View File
@@ -27,7 +27,7 @@ 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">
@@ -68,47 +68,58 @@ export default function AboutPage() {
</ul>
</section>
<AccentLink link="/Angel_Mankel_Resume_2026.pdf" label="Download CV (PDF)" download />
<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">
<CopyButton
value={email}
label="Copy email"
className={`hover:text-neutral-200 rounded-md hover:bg-[${colors.accent}] p-1 transition-colors duration-200`}
>
<IconMailFilled size={32} />
</CopyButton>
<CopyButton
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">
<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`}
{/* 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`}
>
<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`}
<IconMailFilled size={32} />
</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`}
>
<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>
);
}
+9
View File
@@ -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;
}
-2
View File
@@ -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 />
+2
View File
@@ -1,3 +1,5 @@
'use client';
import Hero from "@/components/content/Hero";
import Testimonials from "@/components/content/Testimonials";
import RotatingWord from "@/components/content/RotatingWord";
+13 -22
View File
@@ -1,11 +1,11 @@
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";
import ProjectCarousel from "@/components/content/ProjectCarousel";
export function generateStaticParams() {
return projects.map((project) => ({ slug: project.slug }));
@@ -48,7 +48,7 @@ export default async function ProjectPage({
</Link>
<Hero label={project.title} subtitle={project.description}>
<div className="space-y-5">
<div className="space-y-3">
<p className="text-md text-neutral-400">
{project.year} {project.stack.join(" · ")}
</p>
@@ -68,31 +68,22 @@ export default async function ProjectPage({
{/* 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>
<ProjectCarousel screenshots={project.screenshots} />
)}
{/* 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 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>
+128
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}
+1 -1
View File
@@ -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 -1
View File
@@ -14,7 +14,7 @@ export default function CopyButton({
value,
label,
copiedLabel = "Copied!",
className = "rounded-md p-1 text-neutral-600 transition-colors duration-200 hover:text-neutral-200",
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);
+169
View File
@@ -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,
);
}
+71
View File
@@ -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
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
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 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.",
+66 -40
View File
@@ -1,6 +1,9 @@
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 = {
@@ -27,96 +30,119 @@ export const projects: Project[] = [
slug: "orbit",
title: "Orbit",
year: "2026",
stack: ["Next.js", "TypeScript", "PostgreSQL"],
stack: ["React", "TypeScript", "Bun", "Hono", "SQLite", "MCP"],
description:
"A self-hosted project and ticket management workspace with spaces, pages, and notes.",
"A self-hosted project and ticket management workspace with spaces, pages, and notes. MCP server",
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.",
"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: "https://picsum.photos/seed/orbit1/1200/750", alt: "Orbit board view" },
{ src: "https://picsum.photos/seed/orbit2/1200/750", alt: "Orbit ticket detail" },
{ 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: ["TODO — something you learned building this."],
improvements: ["TODO — what you'd do differently next time."],
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."],
},
{
slug: "imagelab",
title: "ImageLab",
year: "2026",
stack: ["Next.js", "TypeScript", "WebAssembly"],
stack: ["React", "TypeScript", "Pixi.js", "Tailwind CSS"],
description:
"A browser-based image processing playground for transforms, filters, and batch edits.",
"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: [
"TODO — what ImageLab does and why you built it.",
"TODO — the core problem it solves and who it's for.",
"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: "https://picsum.photos/seed/imagelab1/1200/750", alt: "ImageLab editor" },
{ src: "https://picsum.photos/seed/imagelab2/1200/750", alt: "ImageLab batch view" },
{ 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: ["TODO — something you learned building this."],
improvements: ["TODO — what you'd do differently next time."],
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."],
},
{
slug: "league",
title: "League of Legends Companion",
year: "2026",
stack: ["React", "TypeScript", "Riot API"],
stack: ["React", "TypeScript", "Hono", "MySQL", "Riot API"],
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.",
"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: [
"TODO — what League of Legends Companion does and why you built it.",
"TODO — the core problem it solves and who it's for.",
"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: "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" },
{ 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: ["TODO — something you learned building this."],
improvements: ["TODO — what you'd do differently next time."],
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."],
},
{
slug: "study",
title: "Study Time",
year: "2026",
stack: ["Next.js", "TypeScript", "Tailwind CSS"],
stack: ["React", "TypeScript", "Vite", "Mantine"],
description:
"A study app I built for those late night study sessions - essentially a free Quizlet",
"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.",
"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: "https://picsum.photos/seed/study1/1200/750", alt: "Study Time dashboard" },
{ src: "https://picsum.photos/seed/study2/1200/750", alt: "Study Time session view" },
{ 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: ["TODO — something you learned building this."],
improvements: ["TODO — what you'd do differently next time."],
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."],
},
{
slug: "d2r",
title: "Diablo II: Resurrected Tracker",
year: "2026",
stack: ["React", "TypeScript", "Node.js"],
stack: ["Bun", "JavaScript", "HTML/CSS"],
description:
"A loot and event tracker for the game Diablo II: Resurrected.",
"An event tracker and companion app 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.",
"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: "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" },
{ 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: ["TODO — something you learned building this."],
improvements: ["TODO — what you'd do differently next time."],
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."],
},
];
+7
View File
@@ -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.",
}
];
+213
View File
@@ -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 } };
}
+70
View File
@@ -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); };
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB