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",