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 <cursoragent@cursor.com>
This commit is contained in:
Jared Schoeny
2026-08-28 18:59:58 -06:00
committed by GitHub
parent 19c2cd65ae
commit d6d04240d4
3 changed files with 176 additions and 65 deletions

View File

@@ -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<{

View File

@@ -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 (
<div className="card-simple p-4">
<div className="relative aspect-[16/9] w-full overflow-hidden" ref={emblaRef}>
<div className="flex h-full">
{images.map((src, idx) => (
<div key={`${src}-${idx}`} className="relative h-full flex-[0_0_100%]">
<button onClick={() => setLightboxOpen(true)} className="absolute inset-0">
<PixelImage src={src} alt={title} mode="contain" className="absolute inset-0" />
</button>
</div>
))}
</div>
<div className="pointer-events-auto absolute inset-y-0 left-0 flex items-center">
<button
aria-label="Previous image"
onClick={() => emblaApi && emblaApi.scrollPrev()}
className="m-2 rounded-full bg-[color-mix(in_oklab,black_30%,transparent)] p-2 text-white ring-1 ring-white/30 hover:bg-[color-mix(in_oklab,black_50%,transparent)] dark:bg-black/30 dark:hover:bg-black/50"
>
‹
</button>
</div>
<div className="pointer-events-auto absolute inset-y-0 right-0 flex items-center">
<button
aria-label="Next image"
onClick={() => emblaApi && emblaApi.scrollNext()}
className="m-2 rounded-full bg-[color-mix(in_oklab,black_30%,transparent)] p-2 text-white ring-1 ring-white/30 hover:bg-[color-mix(in_oklab,black_50%,transparent)] dark:bg-black/30 dark:hover:bg-black/50"
>
›
</button>
<div className="relative">
<div className="relative aspect-[16/9] w-full overflow-hidden" ref={emblaRef}>
<div className="flex h-full">
{images.map((src, idx) => (
<div key={`${src}-${idx}`} className="relative h-full flex-[0_0_100%]">
<button onClick={() => setLightboxOpen(true)} className="absolute inset-0">
<PixelImage src={src} alt={title} mode="contain" className="absolute inset-0" />
</button>
</div>
))}
</div>
</div>
<button
type="button"
aria-label="Previous image"
disabled={images.length < 2}
onClick={() => emblaApi && emblaApi.scrollPrev()}
className="absolute left-0 top-1/2 z-10 -translate-x-2 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,black_30%,transparent)] p-2 text-white ring-1 ring-white/30 hover:bg-[color-mix(in_oklab,black_50%,transparent)] disabled:pointer-events-none disabled:opacity-40 dark:bg-black/30 dark:hover:bg-black/50"
>
<FiChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
aria-label="Next image"
disabled={images.length < 2}
onClick={() => emblaApi && emblaApi.scrollNext()}
className="absolute right-0 top-1/2 z-10 translate-x-2 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,black_30%,transparent)] p-2 text-white ring-1 ring-white/30 hover:bg-[color-mix(in_oklab,black_50%,transparent)] disabled:pointer-events-none disabled:opacity-40 dark:bg-black/30 dark:hover:bg-black/50"
>
<FiChevronRight className="h-5 w-5" />
</button>
</div>
<div className="mt-3 flex gap-2 overflow-x-auto">
{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<HTMLButtonElement | null>(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 (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={`Screenshots for ${title}`}>
<div className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm" onClick={onClose} />
<div className="relative mx-auto flex h-full max-w-6xl items-center px-4" onClick={(e) => e.stopPropagation()}>
<div className="relative w-full">
<div className="relative mx-auto flex h-full max-w-6xl px-0 md:px-4" onClick={(e) => e.stopPropagation()}>
<div className="relative flex min-h-0 w-full flex-col">
<div className="mb-3 flex justify-end pt-[calc(1rem+env(safe-area-inset-top,0px))] pr-[calc(1rem+env(safe-area-inset-right,0px))] pl-[calc(1rem+env(safe-area-inset-left,0px))] md:px-0 md:pt-4">
<button
type="button"
ref={closeRef}
className={`${controlClass} flex h-10 w-10 items-center justify-center`}
onClick={onClose}
aria-label="Close lightbox"
>
<FiX size={24} />
</button>
</div>
<div
className="relative aspect-[16/9] w-full overflow-hidden rounded"
className="relative min-h-0 flex-1 overflow-hidden rounded"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<PixelImage src={images[index]} alt={`${title} screenshot ${index + 1}`} mode="contain" className="absolute inset-0" />
<PixelImage
src={images[index]}
alt={`${title} screenshot ${index + 1}`}
mode="contain"
pixelPerfect={pixelPerfect}
className="absolute inset-0"
/>
</div>
<div className="mt-3 flex items-center justify-center">
<div className="rounded-full bg-[var(--surface-2)] px-3 py-1 text-xs text-foreground ring-1 ring-[var(--border)]" aria-live="polite">
{index + 1} / {images.length}
<div className="mt-3 flex flex-col items-center gap-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] pl-[calc(1rem+env(safe-area-inset-left,0px))] pr-[calc(1rem+env(safe-area-inset-right,0px))] md:px-0 md:pb-4">
<button
type="button"
role="switch"
aria-checked={pixelPerfect}
onClick={() => setPixelPerfect((enabled) => !enabled)}
className="flex items-center gap-2 rounded-full px-2 py-1 text-xs text-white/80 transition-colors hover:bg-white/10 hover:text-white active:bg-white/10 active:text-white focus:outline-none md:hidden"
>
<FiGrid className="h-4 w-4" aria-hidden="true" />
<span>Pixel-perfect</span>
<span
aria-hidden="true"
className={`flex h-4 w-8 items-center rounded-full p-0.5 ring-1 ring-white/30 transition-colors ${
pixelPerfect ? "bg-white/80" : "bg-white/10"
}`}
>
<span
className={`h-3 w-3 rounded-full transition-transform ${
pixelPerfect ? "translate-x-4 bg-black/70" : "translate-x-0 bg-white/80"
}`}
/>
</span>
</button>
<div className="grid w-full grid-cols-[1fr_auto_1fr] items-center md:flex md:justify-center">
<button
type="button"
aria-label="Previous image"
disabled={!canCycle}
onClick={onPrev}
className={`${controlClass} flex h-14 w-14 items-center justify-center justify-self-start md:hidden`}
>
<FiChevronLeft className="h-7 w-7" />
</button>
<div className="rounded-full bg-black/20 px-3 py-1 text-sm text-white ring-1 ring-white/30 md:text-xs" aria-live="polite">
{index + 1} / {images.length}
</div>
<button
type="button"
aria-label="Next image"
disabled={!canCycle}
onClick={onNext}
className={`${controlClass} flex h-14 w-14 items-center justify-center justify-self-end md:hidden`}
>
<FiChevronRight className="h-7 w-7" />
</button>
</div>
</div>
<button
type="button"
ref={closeRef}
className="absolute right-3 top-3 rounded bg-[var(--surface-2)] px-3 py-1 text-sm text-foreground ring-1 ring-[var(--border)] hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
onClick={onClose}
aria-label="Close lightbox"
>
Close
</button>
</div>
<button
type="button"
aria-label="Previous image"
disabled={!canCycle}
onClick={onPrev}
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-[var(--surface-2)] p-3 text-foreground ring-1 ring-[var(--border)] hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
className={`${controlClass} absolute left-4 top-1/2 hidden -translate-y-1/2 p-3 md:block`}
>
‹
<FiChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
aria-label="Next image"
disabled={!canCycle}
onClick={onNext}
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-[var(--surface-2)] p-3 text-foreground ring-1 ring-[var(--border)] hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
className={`${controlClass} absolute right-4 top-1/2 hidden -translate-y-1/2 p-3 md:block`}
>
›
<FiChevronRight className="h-5 w-5" />
</button>
</div>
</div>

View File

@@ -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<HTMLDivElement>;
};
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<HTMLDivElement | null>(null);
const imgRef = React.useRef<HTMLImageElement | null>(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 (
<div ref={setRef} className={`relative overflow-hidden w-full h-full ${className}`.trim()} style={style} onClick={onClick}>
<div ref={setRef} className={`relative flex h-full w-full items-center justify-center overflow-hidden ${className}`.trim()} style={style} onClick={onClick}>
{/* Loading spinner */}
{!isReady && (
<div
@@ -253,15 +263,16 @@ export default function PixelImage({ src, alt, mode = "cover", className = "", i
checkAndSetNaturalSize();
}}
width={naturalSize.width || undefined}
height={naturalSize.height || undefined}
className={`pointer-events-none select-none ${imgClassName}`.trim()}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: widthPx ? `${widthPx}px` : undefined,
height: heightPx ? `${heightPx}px` : undefined,
maxWidth: "max-content",
width: widthPx,
height: heightPx,
maxWidth: "none",
maxHeight: "none",
aspectRatio: naturalSize.width > 0 && naturalSize.height > 0 ? `${naturalSize.width} / ${naturalSize.height}` : undefined,
objectFit: "contain",
imageRendering: "pixelated",
opacity: isReady ? 1 : 0,
transition: isReady ? "opacity 0.1s" : "none",