Compare commits
8 Commits
e12e1b7072
...
709bf78d01
| Author | SHA1 | Date | |
|---|---|---|---|
| 709bf78d01 | |||
| c10cfaa5b9 | |||
| a1310a774a | |||
| 9222292b9f | |||
| ab36d99ba3 | |||
| dab5651e99 | |||
| 9e15f900ba | |||
| ac1890391c |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
@@ -29,18 +32,20 @@ export const projects: Project[] = [
|
||||
year: "2026",
|
||||
stack: ["Next.js", "TypeScript", "PostgreSQL"],
|
||||
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",
|
||||
@@ -48,18 +53,25 @@ export const projects: Project[] = [
|
||||
year: "2026",
|
||||
stack: ["Next.js", "TypeScript", "WebAssembly"],
|
||||
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",
|
||||
@@ -67,18 +79,23 @@ export const projects: Project[] = [
|
||||
year: "2026",
|
||||
stack: ["React", "TypeScript", "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",
|
||||
@@ -86,18 +103,23 @@ export const projects: Project[] = [
|
||||
year: "2026",
|
||||
stack: ["Next.js", "TypeScript", "Tailwind CSS"],
|
||||
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."],
|
||||
},
|
||||
|
||||
{
|
||||
@@ -106,17 +128,21 @@ export const projects: Project[] = [
|
||||
year: "2026",
|
||||
stack: ["React", "TypeScript", "Node.js"],
|
||||
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."],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 |