From d6d04240d496f94f6255b1526fb524cb1beb62da Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Fri, 28 Aug 2026 18:59:58 -0600 Subject: [PATCH] Make the hack gallery fullscreen preview usable on phones (#84) Opening a screenshot barely grew it on mobile, and Safari stretched every PixelImage. The lightbox now fits the screen by default on phones, with pixel-perfect still available, and WebKit keeps the original aspect ratio. Co-authored-by: Cursor --- src/app/layout.tsx | 6 +- src/components/Hack/Gallery.tsx | 192 ++++++++++++++++++++++++-------- src/components/PixelImage.tsx | 43 ++++--- 3 files changed, 176 insertions(+), 65 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cae263d..aaa1e3b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Analytics } from "@vercel/analytics/react"; import "./globals.css"; @@ -29,6 +29,10 @@ export const metadata: Metadata = { metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!), }; +export const viewport: Viewport = { + viewportFit: "cover", +}; + export default function RootLayout({ children, }: Readonly<{ diff --git a/src/components/Hack/Gallery.tsx b/src/components/Hack/Gallery.tsx index 3e12f53..642453e 100644 --- a/src/components/Hack/Gallery.tsx +++ b/src/components/Hack/Gallery.tsx @@ -3,6 +3,7 @@ import PixelImage from "../PixelImage"; import React from "react"; import useEmblaCarousel from "embla-carousel-react"; +import { FiChevronLeft, FiChevronRight, FiGrid, FiX } from "react-icons/fi"; export default function Gallery({ images, title }: { images: string[]; title: string }) { const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }); @@ -19,34 +20,36 @@ export default function Gallery({ images, title }: { images: string[]; title: st }, [emblaApi]); return (
-
-
- {images.map((src, idx) => ( -
- -
- ))} -
-
- -
-
- +
+
+
+ {images.map((src, idx) => ( +
+ +
+ ))} +
+ +
{images.map((src, i) => ( @@ -70,66 +73,159 @@ export default function Gallery({ images, title }: { images: string[]; title: st ); } +const DESKTOP_LIGHTBOX = "(min-width: 768px)"; + +function isDesktopLightbox() { + return typeof window !== "undefined" && window.matchMedia(DESKTOP_LIGHTBOX).matches; +} + function Lightbox({ images, startIndex, title, onClose }: { images: string[]; startIndex: number; title: string; onClose: () => void }) { const [index, setIndex] = React.useState(startIndex); + const [pixelPerfect, setPixelPerfect] = React.useState(isDesktopLightbox); const closeRef = React.useRef(null); + const canCycle = images.length > 1; const onPrev = React.useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]); const onNext = React.useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]); React.useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); + if (!canCycle) return; if (e.key === "ArrowRight") onNext(); if (e.key === "ArrowLeft") onPrev(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [onClose, onNext, onPrev]); + }, [canCycle, onClose, onNext, onPrev]); React.useEffect(() => { closeRef.current?.focus(); }, []); + React.useEffect(() => { + const html = document.documentElement; + const body = document.body; + const previousHtmlOverflow = html.style.overflow; + const previousBodyOverflow = body.style.overflow; + const previousBodyPaddingRight = body.style.paddingRight; + const scrollBarWidth = window.innerWidth - html.clientWidth; + html.style.overflow = "hidden"; + body.style.overflow = "hidden"; + if (scrollBarWidth > 0) { + body.style.paddingRight = `${scrollBarWidth}px`; + } + const preventTouchScroll = (e: TouchEvent) => e.preventDefault(); + document.addEventListener("touchmove", preventTouchScroll, { passive: false }); + return () => { + html.style.overflow = previousHtmlOverflow; + body.style.overflow = previousBodyOverflow; + body.style.paddingRight = previousBodyPaddingRight; + document.removeEventListener("touchmove", preventTouchScroll); + }; + }, []); + React.useEffect(() => { + const mq = window.matchMedia(DESKTOP_LIGHTBOX); + const sync = () => { + if (mq.matches) setPixelPerfect(true); + }; + sync(); + mq.addEventListener("change", sync); + return () => mq.removeEventListener("change", sync); + }, []); + const controlClass = + "rounded-full text-white/80 ring-1 ring-white/30 transition-colors hover:bg-white/10 hover:text-white active:bg-white/10 active:text-white focus:outline-none disabled:pointer-events-none disabled:opacity-40"; return (
-
e.stopPropagation()}> -
+
e.stopPropagation()}> +
+
+ +
{ if (e.target === e.currentTarget) onClose(); }} > - +
-
-
- {index + 1} / {images.length} +
+ +
+ +
+ {index + 1} / {images.length} +
+
-
diff --git a/src/components/PixelImage.tsx b/src/components/PixelImage.tsx index 42becb0..1fad75a 100644 --- a/src/components/PixelImage.tsx +++ b/src/components/PixelImage.tsx @@ -9,13 +9,23 @@ type PixelImageProps = { src: string; alt: string; mode?: "cover" | "contain"; // cover: fill and crop, contain: letterbox without cropping + pixelPerfect?: boolean; // snap to integer scaling; rendering stays pixelated either way className?: string; // applied to wrapper imgClassName?: string; // applied to img style?: React.CSSProperties; // wrapper style onClick?: React.MouseEventHandler; }; -export default function PixelImage({ src, alt, mode = "cover", className = "", imgClassName = "", style, onClick }: PixelImageProps) { +export default function PixelImage({ + src, + alt, + mode = "cover", + pixelPerfect = true, + className = "", + imgClassName = "", + style, + onClick, +}: PixelImageProps) { const wrapperRef = React.useRef(null); const imgRef = React.useRef(null); const [containerSize, setContainerSize] = React.useState<{ width: number; height: number }>({ width: 0, height: 0 }); @@ -146,7 +156,7 @@ export default function PixelImage({ src, alt, mode = "cover", className = "", i }; }, []); - // Determine integer scaling factor + // Determine scaling factor, snapping to whole pixels by default const scale = React.useMemo(() => { const iw = naturalSize.width; const ih = naturalSize.height; @@ -157,18 +167,18 @@ export default function PixelImage({ src, alt, mode = "cover", className = "", i const scaleX = cw / iw; const scaleY = ch / ih; + const availableScale = mode === "cover" ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY); + + if (!pixelPerfect) return availableScale; if (mode === "cover") { - const required = Math.max(scaleX, scaleY); // Snap UP to the nearest step to ensure we cover the container - return Math.max(1, Math.ceil(required)); + return Math.max(1, Math.ceil(availableScale)); } else { - // contain - const allowed = Math.min(scaleX, scaleY); // Snap DOWN to nearest step to avoid overflow - return Math.max(1, Math.floor(allowed)); + return Math.max(1, Math.floor(availableScale)); } - }, [naturalSize, containerSize, mode, devicePixelRatioState]); + }, [naturalSize, containerSize, mode, pixelPerfect, devicePixelRatioState]); const widthPx = naturalSize.width > 0 ? naturalSize.width * scale : undefined; const heightPx = naturalSize.height > 0 ? naturalSize.height * scale : undefined; @@ -201,7 +211,7 @@ export default function PixelImage({ src, alt, mode = "cover", className = "", i }, [checkImageNaturalSize]); return ( -
+
{/* Loading spinner */} {!isReady && (
0 && naturalSize.height > 0 ? `${naturalSize.width} / ${naturalSize.height}` : undefined, + objectFit: "contain", imageRendering: "pixelated", opacity: isReady ? 1 : 0, transition: isReady ? "opacity 0.1s" : "none",