Enhance project transitions and add photo modal
Build and Deploy / build-deploy (push) Successful in 49s
@@ -23,6 +23,10 @@ html {
|
||||
overflow-y: overlay;
|
||||
}
|
||||
|
||||
html.snap-active {
|
||||
scroll-snap-type: y mandatory;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { m, type Variants } from "motion/react";
|
||||
import ArrowUpRightIcon from "@/components/icons/arrow-up-right.svg";
|
||||
import DocumentIcon from "@/components/icons/document.svg";
|
||||
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
|
||||
|
||||
type Fact = {
|
||||
label: string;
|
||||
value: string;
|
||||
cta?: { label: string; href: string };
|
||||
};
|
||||
|
||||
type FactsRevealProps = {
|
||||
@@ -31,6 +34,30 @@ const itemVariants: Variants = {
|
||||
},
|
||||
};
|
||||
|
||||
const FactCta = ({ cta }: { cta: NonNullable<Fact["cta"]> }) => {
|
||||
const hover = useMagneticSpringHover<HTMLAnchorElement>({
|
||||
magnetStrength: 0.18,
|
||||
scaleAmount: 1.04,
|
||||
shadowElevation: 10,
|
||||
});
|
||||
|
||||
return (
|
||||
<m.a
|
||||
ref={hover.ref}
|
||||
href={cta.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
style={hover.style}
|
||||
{...hover.handlers}
|
||||
className="group mt-3 inline-flex cursor-pointer items-center gap-2 rounded-full border border-foreground/15 bg-foreground/3 px-4 py-2 font-heading text-base backdrop-blur-sm"
|
||||
>
|
||||
<DocumentIcon className="size-4" />
|
||||
<span>{cta.label}</span>
|
||||
<ArrowUpRightIcon className="group-hover:-translate-y-0.5 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||
</m.a>
|
||||
);
|
||||
};
|
||||
|
||||
const FactItem = ({ fact }: { fact: Fact }) => {
|
||||
const hover = useMagneticSpringHover<HTMLDivElement>({
|
||||
magnetStrength: 0.12,
|
||||
@@ -48,6 +75,7 @@ const FactItem = ({ fact }: { fact: Fact }) => {
|
||||
>
|
||||
<p className="font-body text-sm uppercase tracking-wider opacity-60">{fact.label}</p>
|
||||
<p className="font-body text-lg">{fact.value}</p>
|
||||
{fact.cta && <FactCta cta={fact.cta} />}
|
||||
</m.div>
|
||||
</m.div>
|
||||
);
|
||||
|
||||
@@ -22,12 +22,12 @@ export const Threshold = ({ children }: ThresholdProps) => {
|
||||
|
||||
const clipPath = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 1],
|
||||
[0, 0.6],
|
||||
["inset(0 50% 0 50%)", "inset(0 0% 0 0%)"],
|
||||
);
|
||||
const leftEdge = useTransform(scrollYProgress, [0, 1], ["50%", "0%"]);
|
||||
const rightEdge = useTransform(scrollYProgress, [0, 1], ["50%", "100%"]);
|
||||
const edgeOpacity = useTransform(scrollYProgress, [0, 0.12, 0.88, 1], [0, 1, 1, 0]);
|
||||
const leftEdge = useTransform(scrollYProgress, [0, 0.6], ["50%", "0%"]);
|
||||
const rightEdge = useTransform(scrollYProgress, [0, 0.6], ["50%", "100%"]);
|
||||
const edgeOpacity = useTransform(scrollYProgress, [0, 0.08, 0.5, 0.6], [0, 1, 1, 0]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
|
||||
@@ -19,7 +19,7 @@ const Digit = ({ value }: DigitProps) => {
|
||||
if (value === renderedValue || animatingRef.current) return;
|
||||
|
||||
const delta = value - renderedValue;
|
||||
// delta === 1: normal increment; delta === -9: rollover (9 → 0).
|
||||
// delta === 1: normal increment; delta === -9: rollover from 9 to 0.
|
||||
const isSingleForwardStep = delta === 1 || delta === -9;
|
||||
|
||||
if (!isSingleForwardStep || shouldReduce) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { m } from "motion/react";
|
||||
import type { FC, SVGProps } from "react";
|
||||
import DocumentIcon from "@/components/icons/document.svg";
|
||||
import GiteaIcon from "@/components/icons/gitea.svg";
|
||||
import GitHubIcon from "@/components/icons/github.svg";
|
||||
import LinkedInIcon from "@/components/icons/linkedin.svg";
|
||||
import SourceIcon from "@/components/icons/source.svg";
|
||||
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
|
||||
import { resolveHost } from "@/lib/host-icons";
|
||||
|
||||
type Link = {
|
||||
platform: string;
|
||||
@@ -13,13 +18,20 @@ type SocialLinksProps = {
|
||||
links: readonly Link[];
|
||||
};
|
||||
|
||||
const PLATFORM_ICONS: Record<string, FC<SVGProps<SVGSVGElement>>> = {
|
||||
GitHub: GitHubIcon,
|
||||
Gitea: GiteaIcon,
|
||||
LinkedIn: LinkedInIcon,
|
||||
CV: DocumentIcon,
|
||||
};
|
||||
|
||||
const SocialLink = ({ link }: { link: Link }) => {
|
||||
const hover = useMagneticSpringHover<HTMLAnchorElement>({
|
||||
magnetStrength: 0.22,
|
||||
scaleAmount: 1.05,
|
||||
shadowElevation: 0,
|
||||
});
|
||||
const { Icon } = resolveHost(link.url);
|
||||
const Icon = PLATFORM_ICONS[link.platform] ?? SourceIcon;
|
||||
|
||||
return (
|
||||
<m.a
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { PhotoItem } from "./types";
|
||||
|
||||
const formatExposureTime = (s: number): string => {
|
||||
if (s >= 1) return `${s}s`;
|
||||
const denom = Math.round(1 / s);
|
||||
return `1/${denom}s`;
|
||||
};
|
||||
|
||||
export const formatExifLine = (photo: PhotoItem): string | null => {
|
||||
const { exif } = photo;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (exif.focalLength) parts.push(`${exif.focalLength}mm`);
|
||||
if (exif.fNumber) parts.push(`ƒ/${exif.fNumber}`);
|
||||
if (exif.exposureTime) parts.push(formatExposureTime(exif.exposureTime));
|
||||
if (exif.iso) parts.push(`${exif.iso / 100}\u00A0hISO`);
|
||||
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import ArrowLeftIcon from "@/components/icons/arrow-left.svg";
|
||||
import ArrowRightIcon from "@/components/icons/arrow-right.svg";
|
||||
import ArrowUpRightIcon from "@/components/icons/arrow-up-right.svg";
|
||||
import CloseIcon from "@/components/icons/close.svg";
|
||||
import { formatExifLine } from "./exif";
|
||||
import type { PhotoItem } from "./types";
|
||||
|
||||
type PhotoModalProps = {
|
||||
photos: PhotoItem[];
|
||||
selectedIndex: number | null;
|
||||
apiUrl: string;
|
||||
onClose: () => void;
|
||||
onSelectIndex: (index: number) => void;
|
||||
};
|
||||
|
||||
const chromeButtonClasses =
|
||||
"inline-flex size-12 cursor-pointer items-center justify-center rounded-full border border-foreground/15 bg-foreground/3 text-foreground/80 backdrop-blur-sm transition-colors hover:border-accent-cyan/60 hover:text-accent-cyan disabled:cursor-default disabled:opacity-30 disabled:hover:border-foreground/15 disabled:hover:text-foreground/80";
|
||||
|
||||
const FOCUSABLE_SELECTOR = 'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export const PhotoModal = ({
|
||||
photos,
|
||||
selectedIndex,
|
||||
apiUrl,
|
||||
onClose,
|
||||
onSelectIndex,
|
||||
}: PhotoModalProps) => {
|
||||
const isOpen = selectedIndex !== null;
|
||||
const photo = selectedIndex !== null ? photos[selectedIndex] : null;
|
||||
const hasPrev = selectedIndex !== null && selectedIndex > 0;
|
||||
const hasNext = selectedIndex !== null && selectedIndex < photos.length - 1;
|
||||
const exifLine = photo ? formatExifLine(photo) : null;
|
||||
const lensName = photo?.exif.lensDisplayName ?? null;
|
||||
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const lastFocusedRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || selectedIndex === null) return;
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" && dialogRef.current) {
|
||||
const focusables = Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
);
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey && (active === first || !dialogRef.current.contains(active))) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && (active === last || !dialogRef.current.contains(active))) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft" && selectedIndex > 0) {
|
||||
e.preventDefault();
|
||||
onSelectIndex(selectedIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowRight" && selectedIndex < photos.length - 1) {
|
||||
e.preventDefault();
|
||||
onSelectIndex(selectedIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [isOpen, selectedIndex, photos.length, onClose, onSelectIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
lastFocusedRef.current = document.activeElement as HTMLElement | null;
|
||||
const id = requestAnimationFrame(() => {
|
||||
const focusables = dialogRef.current
|
||||
? Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
|
||||
: [];
|
||||
focusables[0]?.focus();
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(id);
|
||||
lastFocusedRef.current?.focus();
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && photo ? (
|
||||
<m.div
|
||||
ref={dialogRef}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Photo viewer"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-200 flex items-center justify-center bg-background/95 pb-4 backdrop-blur-sm md:pb-6"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
aria-label="Close"
|
||||
className={`absolute top-4 right-4 ${chromeButtonClasses}`}
|
||||
>
|
||||
<CloseIcon className="size-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (selectedIndex !== null && hasPrev) onSelectIndex(selectedIndex - 1);
|
||||
}}
|
||||
aria-label="Previous photo"
|
||||
disabled={!hasPrev}
|
||||
className={`-translate-y-1/2 absolute top-1/2 left-4 ${chromeButtonClasses}`}
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (selectedIndex !== null && hasNext) onSelectIndex(selectedIndex + 1);
|
||||
}}
|
||||
aria-label="Next photo"
|
||||
disabled={!hasNext}
|
||||
className={`-translate-y-1/2 absolute top-1/2 right-4 ${chromeButtonClasses}`}
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex h-full w-full flex-col items-center gap-4">
|
||||
<div className="flex min-h-0 w-full flex-1 items-center justify-center">
|
||||
<a
|
||||
href={`${apiUrl}/assets/${photo.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Open photo details"
|
||||
className="contents"
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: viewer image is served pre-optimized by rate-my-shots */}
|
||||
<img
|
||||
key={photo.id}
|
||||
src={`${apiUrl}/img/${photo.id}?size=preview`}
|
||||
alt=""
|
||||
className="max-h-full max-w-full cursor-pointer object-contain"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stops clicks on chrome from closing the modal */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: defensive click absorber, no keyboard semantics needed */}
|
||||
<div onClick={(e) => e.stopPropagation()} className="flex flex-col items-center gap-1">
|
||||
{lensName && <p className="font-mono text-foreground/80 text-xs">{lensName}</p>}
|
||||
{exifLine && <p className="font-mono text-foreground/55 text-xs">{exifLine}</p>}
|
||||
</div>
|
||||
<a
|
||||
href={`${apiUrl}/assets/${photo.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="group inline-flex items-center gap-1.5 font-body text-foreground/70 text-sm transition-colors hover:text-accent-cyan"
|
||||
>
|
||||
<span>Details</span>
|
||||
<ArrowUpRightIcon className="group-hover:-translate-y-0.5 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
</div>
|
||||
</m.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@@ -3,32 +3,16 @@
|
||||
import { m, useReducedMotion } from "motion/react";
|
||||
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
|
||||
import { springGentle } from "@/lib/motion";
|
||||
import { formatExifLine } from "./exif";
|
||||
import type { PhotoItem } from "./types";
|
||||
|
||||
type PhotoTileProps = {
|
||||
photo: PhotoItem;
|
||||
apiUrl: string;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const formatExposureTime = (s: number): string => {
|
||||
if (s >= 1) return `${s}s`;
|
||||
const denom = Math.round(1 / s);
|
||||
return `1/${denom}s`;
|
||||
};
|
||||
|
||||
const formatExifLine = (photo: PhotoItem): string | null => {
|
||||
const { exif } = photo;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (exif.focalLength) parts.push(`${exif.focalLength}mm`);
|
||||
if (exif.fNumber) parts.push(`ƒ/${exif.fNumber}`);
|
||||
if (exif.exposureTime) parts.push(formatExposureTime(exif.exposureTime));
|
||||
if (exif.iso) parts.push(`${exif.iso / 100}\u00A0hISO`);
|
||||
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
};
|
||||
|
||||
export const PhotoTile = ({ photo, apiUrl }: PhotoTileProps) => {
|
||||
export const PhotoTile = ({ photo, apiUrl, onSelect }: PhotoTileProps) => {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const hover = useMagneticSpringHover<HTMLDivElement>({
|
||||
magnetStrength: 0.15,
|
||||
@@ -51,11 +35,11 @@ export const PhotoTile = ({ photo, apiUrl }: PhotoTileProps) => {
|
||||
className="group relative overflow-hidden rounded-lg shadow-sm transition-shadow duration-200 hover:z-10 hover:shadow-2xl hover:shadow-black/30"
|
||||
{...hover.handlers}
|
||||
>
|
||||
<a
|
||||
href={`${apiUrl}/assets/${photo.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-label="Open photo"
|
||||
className="block w-full cursor-pointer"
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: thumbnails are pre-optimized upstream */}
|
||||
<img
|
||||
@@ -64,7 +48,7 @@ export const PhotoTile = ({ photo, apiUrl }: PhotoTileProps) => {
|
||||
width={photo.width}
|
||||
height={photo.height}
|
||||
loading="lazy"
|
||||
className="w-full"
|
||||
className="block w-full"
|
||||
/>
|
||||
{hasOverlay && (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col justify-end gap-1 bg-linear-to-t from-black/70 via-black/20 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
@@ -72,7 +56,7 @@ export const PhotoTile = ({ photo, apiUrl }: PhotoTileProps) => {
|
||||
{exifLine && <p className="font-mono text-white/60 text-xs">{exifLine}</p>}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</button>
|
||||
</m.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import ArrowUpRightIcon from "@/components/icons/arrow-up-right.svg";
|
||||
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
|
||||
import { PhotoModal } from "./photo-modal";
|
||||
import { PhotoTile } from "./photo-tile";
|
||||
import type { PhotoItem } from "./types";
|
||||
|
||||
@@ -100,6 +101,7 @@ export const PhotographyClient = ({ apiUrl }: PhotographyClientProps) => {
|
||||
const [columnCount, setColumnCount] = useState(MIN_COLUMNS);
|
||||
const [columnWidth, setColumnWidth] = useState(0);
|
||||
const [gap, setGap] = useState(GAP_DESKTOP);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const cacheRef = useRef<ColumnCache | null>(null);
|
||||
|
||||
const ctaHover = useMagneticSpringHover<HTMLAnchorElement>({
|
||||
@@ -124,6 +126,14 @@ export const PhotographyClient = ({ apiUrl }: PhotographyClientProps) => {
|
||||
const reachedCap = (data?.length ?? 0) >= MAX_PAGES;
|
||||
const autoLoad = upstreamHasMore && !reachedCap;
|
||||
|
||||
const indexById = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
photos.forEach((p, i) => {
|
||||
map.set(p.id, i);
|
||||
});
|
||||
return map;
|
||||
}, [photos]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const result = assignPhotosToColumns(cacheRef.current, photos, columnCount, columnWidth, gap);
|
||||
cacheRef.current = result;
|
||||
@@ -174,12 +184,24 @@ export const PhotographyClient = ({ apiUrl }: PhotographyClientProps) => {
|
||||
{columns.map((column, colIdx) => (
|
||||
<div key={`${colIdx}/${columnCount}`} className="flex flex-col" style={{ gap }}>
|
||||
{column.photos.map((photo) => (
|
||||
<PhotoTile key={photo.id} photo={photo} apiUrl={apiUrl} />
|
||||
<PhotoTile
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
apiUrl={apiUrl}
|
||||
onSelect={() => setSelectedIndex(indexById.get(photo.id) ?? 0)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{autoLoad && <div ref={loadMoreRef} className="col-span-full h-1" />}
|
||||
</div>
|
||||
<PhotoModal
|
||||
photos={photos}
|
||||
selectedIndex={selectedIndex}
|
||||
apiUrl={apiUrl}
|
||||
onClose={() => setSelectedIndex(null)}
|
||||
onSelectIndex={setSelectedIndex}
|
||||
/>
|
||||
{reachedCap && upstreamHasMore && (
|
||||
<div className="mt-8 flex justify-center">
|
||||
<m.a
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { type MotionValue, m, useTransform } from "motion/react";
|
||||
import { PHASES } from "./lifecycle";
|
||||
import { easeInOut, type MotionValue, m, useTransform } from "motion/react";
|
||||
import { type Anchors, linear, phaseFor } from "./lifecycle";
|
||||
|
||||
type DescriptionRevealProps = {
|
||||
text: string;
|
||||
localProgress: MotionValue<number>;
|
||||
anchors: Anchors;
|
||||
};
|
||||
|
||||
export const DescriptionReveal = ({ text, localProgress }: DescriptionRevealProps) => {
|
||||
const { enterStart, enterEnd, exitStart, exitEnd } = PHASES.description;
|
||||
export const DescriptionReveal = ({ text, localProgress, anchors }: DescriptionRevealProps) => {
|
||||
const { enterStart, enterEnd, exitStart, exitEnd } = phaseFor("description", anchors);
|
||||
const opacity = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[0, 1, 1, 0],
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
const y = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[28, 0, 0, -16],
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
const filter = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
["blur(10px)", "blur(0px)", "blur(0px)", "blur(8px)"],
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import type { MotionValue } from "motion/react";
|
||||
import { HookWord } from "./hook-word";
|
||||
import type { Anchors } from "./lifecycle";
|
||||
|
||||
type HookRevealProps = {
|
||||
text: string;
|
||||
localProgress: MotionValue<number>;
|
||||
anchors: Anchors;
|
||||
};
|
||||
|
||||
type Token = { kind: "word"; text: string; wordIndex: number } | { kind: "space"; text: string };
|
||||
@@ -24,7 +26,7 @@ const tokenize = (text: string): { tokens: Token[]; wordCount: number } => {
|
||||
return { tokens, wordCount: wordIndex };
|
||||
};
|
||||
|
||||
export const HookReveal = ({ text, localProgress }: HookRevealProps) => {
|
||||
export const HookReveal = ({ text, localProgress, anchors }: HookRevealProps) => {
|
||||
const { tokens, wordCount } = tokenize(text);
|
||||
|
||||
return (
|
||||
@@ -42,6 +44,7 @@ export const HookReveal = ({ text, localProgress }: HookRevealProps) => {
|
||||
index={token.wordIndex}
|
||||
total={wordCount}
|
||||
localProgress={localProgress}
|
||||
anchors={anchors}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { easeIn, easeOut, type MotionValue, m, useTransform } from "motion/react";
|
||||
import { linear, PHASES, stagger } from "./lifecycle";
|
||||
import { easeInOut, type MotionValue, m, useTransform } from "motion/react";
|
||||
import { type Anchors, linear, phaseFor, stagger } from "./lifecycle";
|
||||
|
||||
type HookWordProps = {
|
||||
word: string;
|
||||
index: number;
|
||||
total: number;
|
||||
localProgress: MotionValue<number>;
|
||||
anchors: Anchors;
|
||||
};
|
||||
|
||||
export const HookWord = ({ word, index, total, localProgress }: HookWordProps) => {
|
||||
const phase = stagger(PHASES.hook, index, total, 0.55, true);
|
||||
export const HookWord = ({ word, index, total, localProgress, anchors }: HookWordProps) => {
|
||||
const phase = stagger(phaseFor("hook", anchors), index, total, 0.55, true);
|
||||
const { enterStart, enterEnd, exitStart, exitEnd } = phase;
|
||||
|
||||
const exitX = index % 2 === 0 ? -48 : 48;
|
||||
@@ -22,25 +23,25 @@ export const HookWord = ({ word, index, total, localProgress }: HookWordProps) =
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[0, 1, 1, 0],
|
||||
{ ease: [easeOut, linear, easeIn] },
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
const y = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[88, 0, 0, exitY],
|
||||
{ ease: [easeOut, linear, easeIn] },
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
const x = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[0, 0, 0, exitX],
|
||||
{ ease: [easeOut, linear, easeIn] },
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
const rotate = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[4, 0, 0, exitRotate],
|
||||
{ ease: [easeOut, linear, easeIn] },
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,22 +5,97 @@ export type Phase = {
|
||||
exitEnd: number;
|
||||
};
|
||||
|
||||
export const PHASES = {
|
||||
export type Anchors = { pFirst: number; pLast: number };
|
||||
|
||||
// Reference window used to normalize RAW_PHASES into [0, 1].
|
||||
const ENTER_REF = 0.36;
|
||||
const EXIT_REF = 0.64;
|
||||
|
||||
const RAW_PHASES = {
|
||||
placard: { enterStart: 0.0, enterEnd: 0.08, exitStart: 0.92, exitEnd: 1.0 },
|
||||
hook: { enterStart: 0.0, enterEnd: 0.16, exitStart: 0.84, exitEnd: 1.0 },
|
||||
description: { enterStart: 0.08, enterEnd: 0.24, exitStart: 0.78, exitEnd: 0.94 },
|
||||
visual: { enterStart: 0.02, enterEnd: 0.22, exitStart: 0.82, exitEnd: 1.0 },
|
||||
visual: { enterStart: 0.0, enterEnd: 0.36, exitStart: 0.64, exitEnd: 1.0 },
|
||||
techKeys: { enterStart: 0.12, enterEnd: 0.34, exitStart: 0.66, exitEnd: 0.9 },
|
||||
links: { enterStart: 0.18, enterEnd: 0.36, exitStart: 0.64, exitEnd: 0.82 },
|
||||
} satisfies Record<string, Phase>;
|
||||
} as const satisfies Record<string, Phase>;
|
||||
|
||||
export const VISUAL_BODY = { start: 0.22, end: 0.82 } as const;
|
||||
// Invariant: every phase's enterEnd must sit at or before ENTER_REF, and exitStart at or after
|
||||
// EXIT_REF. Otherwise normalization produces out-of-range values and the choreography breaks
|
||||
// silently. Enforced in dev so edits to RAW_PHASES that violate the contract surface immediately.
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
for (const [key, p] of Object.entries(RAW_PHASES) as [keyof typeof RAW_PHASES, Phase][]) {
|
||||
if (p.enterEnd > ENTER_REF) {
|
||||
throw new Error(
|
||||
`RAW_PHASES.${key}.enterEnd (${p.enterEnd}) exceeds ENTER_REF (${ENTER_REF}).`,
|
||||
);
|
||||
}
|
||||
if (p.exitStart < EXIT_REF) {
|
||||
throw new Error(
|
||||
`RAW_PHASES.${key}.exitStart (${p.exitStart}) is below EXIT_REF (${EXIT_REF}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allPhases = Object.values(PHASES);
|
||||
/** Highest enterEnd across all elements — the moment everything has finished entering. */
|
||||
export const MAX_ENTER_END = Math.max(...allPhases.map((p) => p.enterEnd));
|
||||
/** Lowest exitStart across all elements — the moment the first element starts exiting. */
|
||||
export const MIN_EXIT_START = Math.min(...allPhases.map((p) => p.exitStart));
|
||||
export type PhaseKey = keyof typeof RAW_PHASES;
|
||||
|
||||
const PHASE_NORM: Record<PhaseKey, Phase> = (
|
||||
Object.entries(RAW_PHASES) as [PhaseKey, Phase][]
|
||||
).reduce(
|
||||
(acc, [k, p]) => {
|
||||
acc[k] = {
|
||||
enterStart: p.enterStart / ENTER_REF,
|
||||
enterEnd: p.enterEnd / ENTER_REF,
|
||||
exitStart: (p.exitStart - EXIT_REF) / (1 - EXIT_REF),
|
||||
exitEnd: (p.exitEnd - EXIT_REF) / (1 - EXIT_REF),
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<PhaseKey, Phase>,
|
||||
);
|
||||
|
||||
/**
|
||||
* Project-local phase for a given element under a project's anchors.
|
||||
* - Entries are squeezed into [0, pFirst]; exits into [pLast, 1].
|
||||
* - No plateau between pFirst and pLast for elements whose normalized enterEnd/exitStart
|
||||
* are both at the window boundary (e.g., visual). Chrome elements with smaller normalized
|
||||
* windows finish earlier within the entry budget and start exiting later within the exit
|
||||
* budget, preserving stagger.
|
||||
*/
|
||||
export const phaseFor = (key: PhaseKey, anchors: Anchors): Phase => {
|
||||
const n = PHASE_NORM[key];
|
||||
return {
|
||||
enterStart: n.enterStart * anchors.pFirst,
|
||||
enterEnd: n.enterEnd * anchors.pFirst,
|
||||
exitStart: anchors.pLast + n.exitStart * (1 - anchors.pLast),
|
||||
exitEnd: anchors.pLast + n.exitEnd * (1 - anchors.pLast),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Project anchors derived from its scroll-pixel budget.
|
||||
* - Entry budget = exit budget = basePx / 2 (in pixels).
|
||||
* - Cycle (slide-to-slide travel) consumes the remaining (N-1) * extraPx.
|
||||
* - Result: peaks are always separated by extraPx px, regardless of slide count.
|
||||
*/
|
||||
export const anchorsFor = (slideCount: number, basePx: number, extraPx: number): Anchors => {
|
||||
const N = Math.max(1, slideCount);
|
||||
if (N === 1) return { pFirst: 0.5, pLast: 0.5 };
|
||||
const total = basePx + (N - 1) * extraPx;
|
||||
const entry = basePx / 2 / total;
|
||||
return { pFirst: entry, pLast: 1 - entry };
|
||||
};
|
||||
|
||||
/** Local progress at which slide j is fully visible (its single stable point). */
|
||||
export const peakProgressFor = (
|
||||
slideIndex: number,
|
||||
slideCount: number,
|
||||
anchors: Anchors,
|
||||
): number => {
|
||||
if (slideCount <= 1) return (anchors.pFirst + anchors.pLast) / 2;
|
||||
return anchors.pFirst + (slideIndex * (anchors.pLast - anchors.pFirst)) / (slideCount - 1);
|
||||
};
|
||||
|
||||
export const linear = (t: number) => t;
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { easeIn, easeOut, type MotionValue, m, useTransform } from "motion/react";
|
||||
import { easeInOut, type MotionValue, m, useTransform } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Project } from "@/content/projects";
|
||||
import { linear, PHASES, stagger } from "./lifecycle";
|
||||
import { type Anchors, linear, phaseFor, stagger } from "./lifecycle";
|
||||
import { LiveLink, SourceLink } from "./source-link";
|
||||
|
||||
type LinksRowProps = {
|
||||
project: Project;
|
||||
localProgress: MotionValue<number>;
|
||||
sideSign: -1 | 1;
|
||||
anchors: Anchors;
|
||||
};
|
||||
|
||||
type LinkWrapperProps = {
|
||||
@@ -17,11 +18,19 @@ type LinkWrapperProps = {
|
||||
total: number;
|
||||
localProgress: MotionValue<number>;
|
||||
sideSign: -1 | 1;
|
||||
anchors: Anchors;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const LinkWrapper = ({ index, total, localProgress, sideSign, children }: LinkWrapperProps) => {
|
||||
const phase = stagger(PHASES.links, index, total, 0.5, true);
|
||||
const LinkWrapper = ({
|
||||
index,
|
||||
total,
|
||||
localProgress,
|
||||
sideSign,
|
||||
anchors,
|
||||
children,
|
||||
}: LinkWrapperProps) => {
|
||||
const phase = stagger(phaseFor("links", anchors), index, total, 0.5, true);
|
||||
const { enterStart, enterEnd, exitStart, exitEnd } = phase;
|
||||
const offEdge = -40 * sideSign;
|
||||
|
||||
@@ -29,13 +38,13 @@ const LinkWrapper = ({ index, total, localProgress, sideSign, children }: LinkWr
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[0, 1, 1, 0],
|
||||
{ ease: [easeOut, linear, easeIn] },
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
const x = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[offEdge, 0, 0, offEdge],
|
||||
{ ease: [easeOut, linear, easeIn] },
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -45,7 +54,7 @@ const LinkWrapper = ({ index, total, localProgress, sideSign, children }: LinkWr
|
||||
);
|
||||
};
|
||||
|
||||
export const LinksRow = ({ project, localProgress, sideSign }: LinksRowProps) => {
|
||||
export const LinksRow = ({ project, localProgress, sideSign, anchors }: LinksRowProps) => {
|
||||
type Item = { key: string; node: ReactNode };
|
||||
const items: Item[] = [];
|
||||
if (project.liveUrl) {
|
||||
@@ -66,6 +75,7 @@ export const LinksRow = ({ project, localProgress, sideSign }: LinksRowProps) =>
|
||||
total={items.length}
|
||||
localProgress={localProgress}
|
||||
sideSign={sideSign}
|
||||
anchors={anchors}
|
||||
>
|
||||
{item.node}
|
||||
</LinkWrapper>
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { type MotionValue, m, useTransform } from "motion/react";
|
||||
import { PHASES } from "./lifecycle";
|
||||
import { easeInOut, type MotionValue, m, useTransform } from "motion/react";
|
||||
import { type Anchors, linear, phaseFor } from "./lifecycle";
|
||||
|
||||
type PlacardProps = {
|
||||
index: number;
|
||||
total: number;
|
||||
localProgress: MotionValue<number>;
|
||||
anchors: Anchors;
|
||||
};
|
||||
|
||||
export const Placard = ({ index, total, localProgress }: PlacardProps) => {
|
||||
const { enterStart, enterEnd, exitStart, exitEnd } = PHASES.placard;
|
||||
export const Placard = ({ index, total, localProgress, anchors }: PlacardProps) => {
|
||||
const { enterStart, enterEnd, exitStart, exitEnd } = phaseFor("placard", anchors);
|
||||
const opacity = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[0, 1, 1, 0],
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
const y = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[-12, 0, 0, 12],
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { type CSSProperties, useState } from "react";
|
||||
import type { Project } from "@/content/projects";
|
||||
import { DescriptionReveal } from "./description-reveal";
|
||||
import { HookReveal } from "./hook-reveal";
|
||||
import type { Anchors } from "./lifecycle";
|
||||
import { LinksRow } from "./links-row";
|
||||
import { Placard } from "./placard";
|
||||
import { TechKeyDeck } from "./tech-key-deck";
|
||||
@@ -16,6 +17,7 @@ type ProjectLayerProps = {
|
||||
total: number;
|
||||
inputRange: number[];
|
||||
outputRange: number[];
|
||||
anchors: Anchors;
|
||||
sectionProgress: MotionValue<number>;
|
||||
};
|
||||
|
||||
@@ -25,6 +27,7 @@ export const ProjectLayer = ({
|
||||
total,
|
||||
inputRange,
|
||||
outputRange,
|
||||
anchors,
|
||||
sectionProgress,
|
||||
}: ProjectLayerProps) => {
|
||||
const localProgress = useTransform(sectionProgress, inputRange, outputRange, {
|
||||
@@ -58,11 +61,24 @@ export const ProjectLayer = ({
|
||||
<div className="mx-auto grid w-full max-w-7xl desktop:grid-cols-2 grid-cols-1 items-center desktop:gap-12 gap-10 lg:gap-20">
|
||||
<div className={visualOnLeft ? "desktop:order-2" : "desktop:order-1"}>
|
||||
<div className="space-y-6">
|
||||
<Placard index={index} total={total} localProgress={localProgress} />
|
||||
<HookReveal text={project.hook} localProgress={localProgress} />
|
||||
<DescriptionReveal text={project.description} localProgress={localProgress} />
|
||||
<TechKeyDeck techStack={project.techStack} localProgress={localProgress} />
|
||||
<LinksRow project={project} localProgress={localProgress} sideSign={contentSideSign} />
|
||||
<Placard index={index} total={total} localProgress={localProgress} anchors={anchors} />
|
||||
<HookReveal text={project.hook} localProgress={localProgress} anchors={anchors} />
|
||||
<DescriptionReveal
|
||||
text={project.description}
|
||||
localProgress={localProgress}
|
||||
anchors={anchors}
|
||||
/>
|
||||
<TechKeyDeck
|
||||
techStack={project.techStack}
|
||||
localProgress={localProgress}
|
||||
anchors={anchors}
|
||||
/>
|
||||
<LinksRow
|
||||
project={project}
|
||||
localProgress={localProgress}
|
||||
sideSign={contentSideSign}
|
||||
anchors={anchors}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +89,7 @@ export const ProjectLayer = ({
|
||||
priority={index === 0}
|
||||
sideSign={visualSideSign}
|
||||
active={active}
|
||||
anchors={anchors}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useScroll } from "motion/react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Project } from "@/content/projects";
|
||||
import { MAX_ENTER_END, MIN_EXIT_START } from "./lifecycle";
|
||||
import { type Anchors, anchorsFor, peakProgressFor } from "./lifecycle";
|
||||
import { ProjectLayer } from "./project-layer";
|
||||
|
||||
const BASE_VH = 160;
|
||||
const EXTRA_VH = 80;
|
||||
const OVERLAP_VH = 40;
|
||||
/**
|
||||
* Pre-/post-engagement zones, in vh. Equal to one viewport (sticky engages when
|
||||
* section.top reaches viewport.top, disengages when section.bottom reaches viewport.bottom).
|
||||
* Used to allocate scroll space for the first project's entrance and the last project's exit
|
||||
* so that their per-element choreography plays exactly across the section's approach/departure.
|
||||
*/
|
||||
const PRE_BUFFER_VH = 150;
|
||||
const POST_BUFFER_VH = 150;
|
||||
// All values are in viewports (1.0 = 100vh). Dimensionless ratios.
|
||||
const BASE = 0.95;
|
||||
const EXTRA = 0.6;
|
||||
const OVERLAP = 0.2;
|
||||
const PRE_BUFFER = 1.5;
|
||||
const POST_BUFFER = 1.5;
|
||||
|
||||
type ProjectsStageProps = {
|
||||
projects: readonly Project[];
|
||||
@@ -27,48 +22,83 @@ type LayerRange = {
|
||||
outputRange: number[];
|
||||
};
|
||||
|
||||
type Snap = {
|
||||
topVh: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type Layout = {
|
||||
layers: LayerRange[];
|
||||
anchors: Anchors[];
|
||||
sectionVh: number;
|
||||
snaps: Snap[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Invert a layer's piecewise-linear local-progress map back to a widened coordinate (in viewports).
|
||||
* Mid projects use a single linear segment; first/last projects have a knot at
|
||||
* (preBuffer, pFirst) / (totalWidened - postBuffer, pLast).
|
||||
*/
|
||||
const localToWidened = (
|
||||
local: number,
|
||||
rawStart: number,
|
||||
rawEnd: number,
|
||||
knot: { atProgress: number; atLocal: number } | null,
|
||||
): number => {
|
||||
if (!knot) {
|
||||
return rawStart + local * (rawEnd - rawStart);
|
||||
}
|
||||
if (local <= knot.atLocal) {
|
||||
const t = knot.atLocal === 0 ? 0 : local / knot.atLocal;
|
||||
return rawStart + t * (knot.atProgress - rawStart);
|
||||
}
|
||||
const t = (local - knot.atLocal) / (1 - knot.atLocal);
|
||||
return knot.atProgress + t * (rawEnd - knot.atProgress);
|
||||
};
|
||||
|
||||
const computeLayout = (projects: readonly Project[]): Layout => {
|
||||
const lastIndex = projects.length - 1;
|
||||
|
||||
const anchors = projects.map((p) => anchorsFor(p.media.length, BASE, EXTRA));
|
||||
|
||||
const weights = projects.map((p, i) => {
|
||||
let w = BASE_VH + (p.media.length - 1) * EXTRA_VH;
|
||||
if (i === 0) w += PRE_BUFFER_VH;
|
||||
if (i === lastIndex) w += POST_BUFFER_VH;
|
||||
let w = BASE + (p.media.length - 1) * EXTRA;
|
||||
if (i === 0) w += PRE_BUFFER;
|
||||
if (i === lastIndex) w += POST_BUFFER;
|
||||
return w;
|
||||
});
|
||||
|
||||
const rawRanges: { start: number; end: number }[] = [];
|
||||
let cursor = 0;
|
||||
for (let i = 0; i < weights.length; i++) {
|
||||
if (i > 0) cursor -= OVERLAP_VH;
|
||||
if (i > 0) cursor -= OVERLAP;
|
||||
rawRanges.push({ start: cursor, end: cursor + weights[i] });
|
||||
cursor += weights[i];
|
||||
}
|
||||
|
||||
// Total widened scroll = pre + content + post = section + viewport
|
||||
const totalWidenedVh = cursor;
|
||||
const sectionVh = totalWidenedVh - 100;
|
||||
const stickyEngageProgress = PRE_BUFFER_VH / totalWidenedVh;
|
||||
const stickyDisengageProgress = (totalWidenedVh - POST_BUFFER_VH) / totalWidenedVh;
|
||||
const totalWidened = cursor;
|
||||
// sticky-engaged section height = totalWidened - 1 viewport (the section, when pinned,
|
||||
// occupies one viewport, and scrolls the remaining "widened" budget).
|
||||
const sectionVh = Math.max(0, totalWidened - 1);
|
||||
const stickyEngage = PRE_BUFFER;
|
||||
const stickyDisengage = totalWidened - POST_BUFFER;
|
||||
const stickyEngageProgress = stickyEngage / totalWidened;
|
||||
const stickyDisengageProgress = stickyDisengage / totalWidened;
|
||||
|
||||
const layers: LayerRange[] = rawRanges.map((r, i) => {
|
||||
const startProgress = r.start / totalWidenedVh;
|
||||
const endProgress = r.end / totalWidenedVh;
|
||||
const startProgress = r.start / totalWidened;
|
||||
const endProgress = r.end / totalWidened;
|
||||
|
||||
if (i === 0) {
|
||||
return {
|
||||
inputRange: [startProgress, stickyEngageProgress, endProgress],
|
||||
outputRange: [0, MAX_ENTER_END, 1],
|
||||
outputRange: [0, anchors[0].pFirst, 1],
|
||||
};
|
||||
}
|
||||
if (i === lastIndex) {
|
||||
return {
|
||||
inputRange: [startProgress, stickyDisengageProgress, endProgress],
|
||||
outputRange: [0, MIN_EXIT_START, 1],
|
||||
outputRange: [0, anchors[lastIndex].pLast, 1],
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -77,12 +107,75 @@ const computeLayout = (projects: readonly Project[]): Layout => {
|
||||
};
|
||||
});
|
||||
|
||||
return { layers, sectionVh };
|
||||
const snaps: Snap[] = [];
|
||||
rawRanges.forEach((r, i) => {
|
||||
const project = projects[i];
|
||||
const slideCount = Math.max(1, project.media.length);
|
||||
const projectAnchors = anchors[i];
|
||||
const knot =
|
||||
i === 0
|
||||
? { atProgress: stickyEngage, atLocal: projectAnchors.pFirst }
|
||||
: i === lastIndex
|
||||
? { atProgress: stickyDisengage, atLocal: projectAnchors.pLast }
|
||||
: null;
|
||||
|
||||
for (let j = 0; j < slideCount; j++) {
|
||||
const peakLocal = peakProgressFor(j, slideCount, projectAnchors);
|
||||
const widened = localToWidened(peakLocal, r.start, r.end, knot);
|
||||
const topVh = Math.max(0, Math.min(sectionVh, widened - 0.5));
|
||||
snaps.push({
|
||||
topVh,
|
||||
label: slideCount > 1 ? `${project.name} · slide ${j + 1}/${slideCount}` : project.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { layers, anchors, sectionVh, snaps };
|
||||
};
|
||||
|
||||
export const ProjectsStage = ({ projects }: ProjectsStageProps) => {
|
||||
const { layers, sectionVh } = useMemo(() => computeLayout(projects), [projects]);
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sectionRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const state = { topPassed: false, bottomActive: false };
|
||||
const sync = () => {
|
||||
document.documentElement.classList.toggle(
|
||||
"snap-active",
|
||||
state.topPassed && state.bottomActive,
|
||||
);
|
||||
};
|
||||
|
||||
const topObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
state.topPassed = entry.isIntersecting;
|
||||
sync();
|
||||
},
|
||||
{ rootMargin: "0px 0px -100% 0px" },
|
||||
);
|
||||
|
||||
const bottomObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
state.bottomActive = entry.isIntersecting;
|
||||
sync();
|
||||
},
|
||||
{ rootMargin: "-100% 0px 0px 0px" },
|
||||
);
|
||||
|
||||
topObserver.observe(el);
|
||||
bottomObserver.observe(el);
|
||||
|
||||
return () => {
|
||||
topObserver.disconnect();
|
||||
bottomObserver.disconnect();
|
||||
document.documentElement.classList.remove("snap-active");
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { layers, anchors, sectionVh, snaps } = useMemo(() => computeLayout(projects), [projects]);
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ["start end", "end start"],
|
||||
@@ -92,9 +185,17 @@ export const ProjectsStage = ({ projects }: ProjectsStageProps) => {
|
||||
<div
|
||||
ref={sectionRef}
|
||||
className="relative"
|
||||
style={{ height: `${sectionVh}vh` }}
|
||||
style={{ height: `${sectionVh * 100}vh` }}
|
||||
data-projects-stage
|
||||
>
|
||||
{snaps.map((snap, i) => (
|
||||
<div
|
||||
key={`snap-${i}-${snap.label}`}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0 h-px w-px"
|
||||
style={{ top: `${snap.topVh * 100}vh`, scrollSnapAlign: "center" }}
|
||||
/>
|
||||
))}
|
||||
<div className="sticky top-0 h-screen overflow-hidden">
|
||||
{projects.map((project, i) => (
|
||||
<ProjectLayer
|
||||
@@ -104,6 +205,7 @@ export const ProjectsStage = ({ projects }: ProjectsStageProps) => {
|
||||
total={projects.length}
|
||||
inputRange={layers[i].inputRange}
|
||||
outputRange={layers[i].outputRange}
|
||||
anchors={anchors[i]}
|
||||
sectionProgress={scrollYProgress}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type ScaledIframeProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
simulatedWidth: number;
|
||||
aspectRatio?: number;
|
||||
};
|
||||
|
||||
export const ScaledIframe = ({
|
||||
src,
|
||||
alt,
|
||||
simulatedWidth,
|
||||
aspectRatio = 16 / 10,
|
||||
}: ScaledIframeProps) => {
|
||||
const wrapperRef = useRef<HTMLAnchorElement>(null);
|
||||
const [scale, setScale] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setScale(el.offsetWidth / simulatedWidth);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [simulatedWidth]);
|
||||
|
||||
const simulatedHeight = simulatedWidth / aspectRatio;
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={wrapperRef}
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label={alt}
|
||||
className="absolute inset-0 block overflow-hidden"
|
||||
>
|
||||
<iframe
|
||||
src={src}
|
||||
title={alt}
|
||||
loading="lazy"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none origin-top-left border-0 bg-background"
|
||||
style={{
|
||||
width: `${simulatedWidth}px`,
|
||||
height: `${simulatedHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { backOut, easeIn, easeOut, type MotionValue, m, useTransform } from "motion/react";
|
||||
import { linear, PHASES, stagger } from "./lifecycle";
|
||||
import { backOut, easeInOut, type MotionValue, m, useTransform } from "motion/react";
|
||||
import { type Anchors, linear, phaseFor, stagger } from "./lifecycle";
|
||||
import { TechKey } from "./tech-key";
|
||||
|
||||
type TechKeyDeckProps = {
|
||||
techStack: readonly string[];
|
||||
localProgress: MotionValue<number>;
|
||||
anchors: Anchors;
|
||||
};
|
||||
|
||||
type TechKeyWrapperProps = {
|
||||
@@ -14,29 +15,30 @@ type TechKeyWrapperProps = {
|
||||
index: number;
|
||||
total: number;
|
||||
localProgress: MotionValue<number>;
|
||||
anchors: Anchors;
|
||||
};
|
||||
|
||||
const TechKeyWrapper = ({ techKey, index, total, localProgress }: TechKeyWrapperProps) => {
|
||||
const phase = stagger(PHASES.techKeys, index, total, 0.6, true);
|
||||
const TechKeyWrapper = ({ techKey, index, total, localProgress, anchors }: TechKeyWrapperProps) => {
|
||||
const phase = stagger(phaseFor("techKeys", anchors), index, total, 0.6, true);
|
||||
const { enterStart, enterEnd, exitStart, exitEnd } = phase;
|
||||
|
||||
const opacity = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[0, 1, 1, 0],
|
||||
{ ease: [easeOut, linear, easeIn] },
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
const y = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[-140, 0, 0, 140],
|
||||
{ ease: [backOut, linear, easeIn] },
|
||||
{ ease: [backOut, linear, easeInOut] },
|
||||
);
|
||||
const rotate = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[index % 2 === 0 ? -18 : 18, 0, 0, index % 2 === 0 ? -32 : 32],
|
||||
{ ease: [backOut, linear, easeIn] },
|
||||
{ ease: [backOut, linear, easeInOut] },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -46,7 +48,7 @@ const TechKeyWrapper = ({ techKey, index, total, localProgress }: TechKeyWrapper
|
||||
);
|
||||
};
|
||||
|
||||
export const TechKeyDeck = ({ techStack, localProgress }: TechKeyDeckProps) => {
|
||||
export const TechKeyDeck = ({ techStack, localProgress, anchors }: TechKeyDeckProps) => {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
{techStack.map((tech, i) => (
|
||||
@@ -56,6 +58,7 @@ export const TechKeyDeck = ({ techStack, localProgress }: TechKeyDeckProps) => {
|
||||
index={i}
|
||||
total={techStack.length}
|
||||
localProgress={localProgress}
|
||||
anchors={anchors}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,66 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AnimatePresence,
|
||||
easeIn,
|
||||
easeOut,
|
||||
type MotionValue,
|
||||
m,
|
||||
useMotionValueEvent,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import { easeInOut, type MotionValue, m, useMotionValueEvent, useTransform } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ArrowUpRightIcon from "@/components/icons/arrow-up-right.svg";
|
||||
import type { ProjectMedia } from "@/content/projects";
|
||||
import { linear, PHASES, VISUAL_BODY } from "./lifecycle";
|
||||
|
||||
type ScaledIframeProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
simulatedWidth: number;
|
||||
aspectRatio?: number;
|
||||
};
|
||||
|
||||
const ScaledIframe = ({ src, alt, simulatedWidth, aspectRatio = 16 / 10 }: ScaledIframeProps) => {
|
||||
const wrapperRef = useRef<HTMLAnchorElement>(null);
|
||||
const [scale, setScale] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setScale(el.offsetWidth / simulatedWidth);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [simulatedWidth]);
|
||||
|
||||
const simulatedHeight = simulatedWidth / aspectRatio;
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={wrapperRef}
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label={alt}
|
||||
className="absolute inset-0 block overflow-hidden"
|
||||
>
|
||||
<iframe
|
||||
src={src}
|
||||
title={alt}
|
||||
loading="lazy"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none origin-top-left border-0 bg-background"
|
||||
style={{
|
||||
width: `${simulatedWidth}px`,
|
||||
height: `${simulatedHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
import { type Anchors, linear, peakProgressFor, phaseFor } from "./lifecycle";
|
||||
import { ScaledIframe } from "./scaled-iframe";
|
||||
|
||||
type VisualShowcaseProps = {
|
||||
media: readonly ProjectMedia[];
|
||||
@@ -68,6 +14,7 @@ type VisualShowcaseProps = {
|
||||
priority?: boolean;
|
||||
sideSign: -1 | 1;
|
||||
active: boolean;
|
||||
anchors: Anchors;
|
||||
};
|
||||
|
||||
const renderMedia = (item: ProjectMedia, priority: boolean, active: boolean) => {
|
||||
@@ -104,9 +51,10 @@ const renderMedia = (item: ProjectMedia, priority: boolean, active: boolean) =>
|
||||
href={item.src}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex size-full items-center justify-center bg-foreground/[0.04] font-mono text-foreground/60 text-sm"
|
||||
className="flex size-full items-center justify-center gap-2 bg-foreground/[0.04] font-mono text-foreground/60 text-sm"
|
||||
>
|
||||
Open live demo →
|
||||
<span>Open live demo</span>
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</noscript>
|
||||
);
|
||||
@@ -141,53 +89,152 @@ const renderMedia = (item: ProjectMedia, priority: boolean, active: boolean) =>
|
||||
}
|
||||
};
|
||||
|
||||
type SlideLayerProps = {
|
||||
item: ProjectMedia;
|
||||
index: number;
|
||||
totalSlides: number;
|
||||
peaks: number[];
|
||||
localProgress: MotionValue<number>;
|
||||
priority: boolean;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
const SlideLayer = ({
|
||||
item,
|
||||
index,
|
||||
totalSlides,
|
||||
peaks,
|
||||
localProgress,
|
||||
priority,
|
||||
active,
|
||||
}: SlideLayerProps) => {
|
||||
const [input, output] = useMemo<[number[], number[]]>(() => {
|
||||
if (totalSlides === 1)
|
||||
return [
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
];
|
||||
if (index === 0)
|
||||
return [
|
||||
[peaks[0], peaks[1]],
|
||||
[1, 0],
|
||||
];
|
||||
if (index === totalSlides - 1) {
|
||||
return [
|
||||
[peaks[totalSlides - 2], peaks[totalSlides - 1]],
|
||||
[0, 1],
|
||||
];
|
||||
}
|
||||
return [
|
||||
[peaks[index - 1], peaks[index], peaks[index + 1]],
|
||||
[0, 1, 0],
|
||||
];
|
||||
}, [index, totalSlides, peaks]);
|
||||
|
||||
const opacity = useTransform(localProgress, input, output, {
|
||||
ease: easeInOut,
|
||||
clamp: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<m.div style={{ opacity }} className="absolute inset-0">
|
||||
{renderMedia(item, priority, active)}
|
||||
</m.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const VisualShowcase = ({
|
||||
media,
|
||||
localProgress,
|
||||
priority = false,
|
||||
sideSign,
|
||||
active,
|
||||
anchors,
|
||||
}: VisualShowcaseProps) => {
|
||||
const bodyProgress = useTransform(localProgress, [VISUAL_BODY.start, VISUAL_BODY.end], [0, 1], {
|
||||
clamp: true,
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: media.map identity is irrelevant; media.length covers array changes
|
||||
const peaks = useMemo(
|
||||
() => media.map((_, j) => peakProgressFor(j, media.length, anchors)),
|
||||
[media.length, anchors],
|
||||
);
|
||||
|
||||
// Slide is mountable when L lies inside its visible window [peak_{j-1}, peak_{j+1}],
|
||||
// with a small margin so the slide is already mounted by the time its opacity becomes non-zero.
|
||||
const MARGIN = 0.01;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: media.map identity is irrelevant; media.length covers array changes
|
||||
const windowFor = useMemo(
|
||||
() =>
|
||||
media.map((_, j) => ({
|
||||
from: j > 0 ? peaks[j - 1] - MARGIN : Number.NEGATIVE_INFINITY,
|
||||
to: j < media.length - 1 ? peaks[j + 1] + MARGIN : Number.POSITIVE_INFINITY,
|
||||
})),
|
||||
[media.length, peaks],
|
||||
);
|
||||
|
||||
const compute = (L: number) => {
|
||||
const mounted: number[] = [];
|
||||
let dominantJ = 0;
|
||||
let dominantDist = Number.POSITIVE_INFINITY;
|
||||
for (let j = 0; j < media.length; j++) {
|
||||
const w = windowFor[j];
|
||||
if (L >= w.from && L <= w.to) mounted.push(j);
|
||||
const dist = Math.abs(L - peaks[j]);
|
||||
if (dist < dominantDist) {
|
||||
dominantDist = dist;
|
||||
dominantJ = j;
|
||||
}
|
||||
}
|
||||
return { mounted, dominantJ };
|
||||
};
|
||||
|
||||
const [mountedIndices, setMountedIndices] = useState<number[]>(media.length > 0 ? [0] : []);
|
||||
const [dominantIndex, setDominantIndex] = useState(0);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: one-shot initial sync; subsequent updates flow through useMotionValueEvent
|
||||
useEffect(() => {
|
||||
const { mounted, dominantJ } = compute(localProgress.get());
|
||||
setMountedIndices(mounted);
|
||||
setDominantIndex(dominantJ);
|
||||
}, []);
|
||||
|
||||
useMotionValueEvent(localProgress, "change", (L) => {
|
||||
const { mounted, dominantJ } = compute(L);
|
||||
setMountedIndices((prev) =>
|
||||
prev.length === mounted.length && prev.every((v, i) => v === mounted[i]) ? prev : mounted,
|
||||
);
|
||||
setDominantIndex((prev) => (prev === dominantJ ? prev : dominantJ));
|
||||
});
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useMotionValueEvent(bodyProgress, "change", (value) => {
|
||||
if (media.length <= 1) return;
|
||||
const next = Math.max(0, Math.min(Math.floor(value * media.length), media.length - 1));
|
||||
setIndex((prev) => (prev === next ? prev : next));
|
||||
});
|
||||
|
||||
const { enterStart, enterEnd, exitStart, exitEnd } = PHASES.visual;
|
||||
const { enterStart, enterEnd, exitStart, exitEnd } = phaseFor("visual", anchors);
|
||||
|
||||
const opacity = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[0, 1, 1, 0],
|
||||
{ ease: [easeOut, linear, easeIn] },
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
const scale = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[0.88, 1, 1, 0.84],
|
||||
{ ease: [easeOut, linear, easeIn] },
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
const rotate = useTransform(
|
||||
localProgress,
|
||||
[enterStart, enterEnd, exitStart, exitEnd],
|
||||
[sideSign * -3, 0, 0, sideSign * 3],
|
||||
{ ease: [easeOut, linear, easeIn] },
|
||||
{ ease: [easeInOut, linear, easeInOut] },
|
||||
);
|
||||
const filter = useTransform(localProgress, [exitStart, exitEnd], ["blur(0px)", "blur(10px)"], {
|
||||
clamp: true,
|
||||
ease: easeInOut,
|
||||
});
|
||||
const enterClip = useTransform(localProgress, [enterStart, enterEnd], [0, 110], {
|
||||
clamp: true,
|
||||
ease: easeInOut,
|
||||
});
|
||||
const enterClip = useTransform(localProgress, [enterStart, enterEnd], [0, 110], { clamp: true });
|
||||
const clipPath = useTransform(enterClip, (v) => `circle(${v}% at 50% 50%)`);
|
||||
|
||||
const item = media[index];
|
||||
if (!item) return null;
|
||||
if (media.length === 0) return null;
|
||||
|
||||
return (
|
||||
<m.div style={{ opacity, scale, rotate, filter }} className="relative w-full">
|
||||
@@ -195,18 +242,18 @@ export const VisualShowcase = ({
|
||||
style={{ clipPath }}
|
||||
className="relative aspect-16/10 w-full overflow-hidden rounded-lg bg-foreground/4 ring-1 ring-foreground/10 ring-inset"
|
||||
>
|
||||
<AnimatePresence mode="sync">
|
||||
<m.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 1.03 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ duration: 0.55, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{renderMedia(item, priority, active)}
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
{mountedIndices.map((j) => (
|
||||
<SlideLayer
|
||||
key={j}
|
||||
item={media[j]}
|
||||
index={j}
|
||||
totalSlides={media.length}
|
||||
peaks={peaks}
|
||||
localProgress={localProgress}
|
||||
priority={priority && j === 0}
|
||||
active={active}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 rounded-lg ring-1 ring-[oklch(var(--project-accent)/0.25)] ring-inset"
|
||||
@@ -220,7 +267,7 @@ export const VisualShowcase = ({
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: media list is static and positional
|
||||
key={i}
|
||||
className={
|
||||
i === index
|
||||
i === dominantIndex
|
||||
? "h-0.5 w-8 bg-[oklch(var(--project-accent))] transition-all duration-500"
|
||||
: "h-0.5 w-4 bg-foreground/20 transition-all duration-500"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12h14" />
|
||||
<path d="m13 6 6 6-6 6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 241 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 240 B |
@@ -1,3 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
|
||||
<path d="M9 18c-4.51 2-5-2-7-2" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 788 B After Width: | Height: | Size: 489 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M21.34.6H2.66A2.06 2.06 0 0 0 .6 2.66v18.68a2.06 2.06 0 0 0 2.06 2.06h18.68a2.06 2.06 0 0 0 2.06-2.06V2.66A2.06 2.06 0 0 0 21.34.6M7.65 20.29a.6.6 0 0 1-.6.6H4.5a.6.6 0 0 1-.6-.6V9.58c0-.34.27-.6.6-.6h2.55c.33 0 .6.26.6.6zM5.78 7.97a2.43 2.43 0 1 1 0-4.86 2.43 2.43 0 0 1 0 4.86M21 20.34a.55.55 0 0 1-.55.55H17.7a.55.55 0 0 1-.55-.55V15.3c0-.75.22-3.28-1.96-3.28-1.69 0-2.03 1.73-2.1 2.51v5.8a.55.55 0 0 1-.55.55H9.9a.55.55 0 0 1-.55-.55V9.53c0-.3.24-.55.55-.55h2.65c.3 0 .55.24.55.55v.93c.63-.94 1.56-1.66 3.54-1.66 4.4 0 4.37 4.1 4.37 6.35z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 746 B |
@@ -1,4 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m3 11 18-5v12L3 14v-3z" />
|
||||
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="m3 11 18-5v12L3 14zm8.6 5.3a3 3 0 1 1-5.8-1.6"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 270 B After Width: | Height: | Size: 248 B |
@@ -6,6 +6,7 @@ export type AboutContent = {
|
||||
personalFacts: readonly {
|
||||
label: string;
|
||||
value: string;
|
||||
cta?: { label: string; href: string };
|
||||
}[];
|
||||
transitionalStatement: string;
|
||||
};
|
||||
@@ -23,6 +24,11 @@ export const about = {
|
||||
],
|
||||
},
|
||||
personalFacts: [
|
||||
{
|
||||
label: "What I do",
|
||||
value: "Senior frontend engineer, six years in the trade",
|
||||
cta: { label: "CV", href: "/luisdralves-cv.pdf" },
|
||||
},
|
||||
{
|
||||
label: "Based in",
|
||||
value: "Viana do Castelo, Portugal. Looking to relocate.",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FC, SVGProps } from "react";
|
||||
import DocumentIcon from "@/components/icons/document.svg";
|
||||
import GiteaIcon from "@/components/icons/gitea.svg";
|
||||
import GitHubIcon from "@/components/icons/github.svg";
|
||||
import SourceIcon from "@/components/icons/source.svg";
|
||||
@@ -21,10 +20,6 @@ export type HostInfo = {
|
||||
};
|
||||
|
||||
export const resolveHost = (url: string): HostInfo => {
|
||||
if (url.toLowerCase().endsWith(".pdf")) {
|
||||
return { name: "Document", Icon: DocumentIcon };
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(url).hostname.toLowerCase();
|
||||
|
||||