From 66a1009569900051bbeab76d52a231bad27d7cc3 Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Wed, 15 Oct 2025 13:09:09 -1000 Subject: [PATCH] Add PixelImage component --- src/components/Hack/Gallery.tsx | 8 +-- src/components/HackCard.tsx | 41 ++++++------ src/components/PixelImage.tsx | 112 ++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 25 deletions(-) create mode 100644 src/components/PixelImage.tsx diff --git a/src/components/Hack/Gallery.tsx b/src/components/Hack/Gallery.tsx index 6a8d60a..f59b310 100644 --- a/src/components/Hack/Gallery.tsx +++ b/src/components/Hack/Gallery.tsx @@ -1,6 +1,6 @@ "use client"; -import Image from "next/image"; +import PixelImage from "../PixelImage"; import React from "react"; import useEmblaCarousel from "embla-carousel-react"; @@ -24,7 +24,7 @@ export default function Gallery({ images, title }: { images: string[]; title: st {images.map((src, idx) => (
))} @@ -58,7 +58,7 @@ export default function Gallery({ images, title }: { images: string[]; title: st }`} aria-label={`Show image ${i + 1}`} > - {`${title} + ))} @@ -98,7 +98,7 @@ function Lightbox({ images, startIndex, title, onClose }: { images: string[]; st if (e.target === e.currentTarget) onClose(); }} > - {`${title} +
diff --git a/src/components/HackCard.tsx b/src/components/HackCard.tsx index 096ba2c..875e3fe 100644 --- a/src/components/HackCard.tsx +++ b/src/components/HackCard.tsx @@ -33,45 +33,44 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h const cardClass = `rounded-[12px] overflow-hidden ${ clickable ? "transition-transform duration-300 hover:-translate-y-0.5 hover:shadow-xl anim-float" : "" } ring-1 ${ready ? "ring-emerald-400/50 bg-emerald-500/10" : "card ring-[var(--border)]"}`; + const gradientBgClass = `bg-gradient-to-b ${ready ? 'from-emerald-300/5 to-emerald-400/30 dark:from-emerald-950/10 dark:to-emerald-600/40' : 'from-black/30 to-black/10 dark:from-black/80 dark:to-black/40'}`; + const shadowClass = `shadow-xl ${ready ? "shadow-emerald-700/40 dark:shadow-emerald-200/40" : "shadow-slate-500/40 dark:shadow-slate-300/40"}`; const content = (
-
+
+
{showTitlePlaceholder ? ( -
- - {hack.title} - +
+
) : isCarousel ? (
{images.map((src, idx) => (
- {hack.title}
))}
) : images[0] ? ( - {hack.title} +
+ +
) : null} - {!showTitlePlaceholder &&
} +
{hack.tags.slice(0, 2).map((t) => ( ; +}; + +export default function PixelImage({ src, alt, mode = "cover", className = "", imgClassName = "", style, onClick }: PixelImageProps) { + const wrapperRef = React.useRef(null); + const [containerSize, setContainerSize] = React.useState<{ width: number; height: number }>({ width: 0, height: 0 }); + const [naturalSize, setNaturalSize] = React.useState<{ width: number; height: number }>({ width: 0, height: 0 }); + const [devicePixelRatioState, setDevicePixelRatioState] = React.useState(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1); + + // Observe container size + React.useEffect(() => { + if (!wrapperRef.current) return; + const el = wrapperRef.current; + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + const cr = entry.contentRect; + setContainerSize({ width: Math.max(0, cr.width), height: Math.max(0, cr.height) }); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Track DPR so we can snap scales to integer device-pixel multiples even under zoom + React.useEffect(() => { + const updateDpr = () => setDevicePixelRatioState(window.devicePixelRatio || 1); + updateDpr(); + window.addEventListener("resize", updateDpr); + window.addEventListener("orientationchange", updateDpr); + // Some browsers support matchMedia for resolution changes + let mm: MediaQueryList | null = null; + try { + mm = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + const onChange = () => updateDpr(); + mm.addEventListener && mm.addEventListener("change", onChange); + } catch {} + return () => { + window.removeEventListener("resize", updateDpr); + window.removeEventListener("orientationchange", updateDpr); + try { + if (mm && (mm as any).removeEventListener) (mm as any).removeEventListener("change", updateDpr); + } catch {} + }; + }, []); + + // Determine integer scaling factor + const scale = React.useMemo(() => { + const iw = naturalSize.width; + const ih = naturalSize.height; + const cw = containerSize.width; + const ch = containerSize.height; + const dpr = devicePixelRatioState || 1; + if (iw <= 0 || ih <= 0 || cw <= 0 || ch <= 0) return 1; + const scaleX = cw / iw; + const scaleY = ch / ih; + const step = 1 / dpr; // CSS-px step that maps to 1 device pixel increment + if (mode === "cover") { + const required = Math.max(scaleX, scaleY); + // Snap UP to the nearest step to ensure we cover the container + return Math.max(step, Math.ceil(required * dpr) / dpr); + } + // contain + const allowed = Math.min(scaleX, scaleY); + // Snap DOWN to nearest step to avoid overflow + return Math.max(step, Math.floor(allowed * dpr) / dpr); + }, [naturalSize, containerSize, mode, devicePixelRatioState]); + + const widthPx = naturalSize.width > 0 ? naturalSize.width * scale : undefined; + const heightPx = naturalSize.height > 0 ? naturalSize.height * scale : undefined; + + return ( +
+ {/* We purposefully avoid Next/Image here to fully control integer scaling */} + {alt} { + const img = e.currentTarget; + // Guard against zero values which can happen briefly + const iw = img.naturalWidth || 0; + const ih = img.naturalHeight || 0; + setNaturalSize({ width: iw, height: ih }); + }} + 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", + imageRendering: "pixelated", + }} + /> +
+ ); +} + +