mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-19 22:28:50 -05:00
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:
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user