Add PixelImage component

This commit is contained in:
Jared Schoeny
2025-10-15 13:09:09 -10:00
parent be00bea7d5
commit 66a1009569
3 changed files with 136 additions and 25 deletions

View File

@@ -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) => (
<div key={`${src}-${idx}`} className="relative h-full flex-[0_0_100%]">
<button onClick={() => setLightboxOpen(true)} className="absolute inset-0">
<Image src={src} alt={title} fill className="object-contain" unoptimized />
<PixelImage src={src} alt={title} mode="contain" className="absolute inset-0" />
</button>
</div>
))}
@@ -58,7 +58,7 @@ export default function Gallery({ images, title }: { images: string[]; title: st
}`}
aria-label={`Show image ${i + 1}`}
>
<Image src={src} alt={`${title} screenshot ${i + 1}`} fill className="object-cover" unoptimized />
<PixelImage src={src} alt={`${title} screenshot ${i + 1}`} mode="cover" className="absolute inset-0" />
</button>
))}
</div>
@@ -98,7 +98,7 @@ function Lightbox({ images, startIndex, title, onClose }: { images: string[]; st
if (e.target === e.currentTarget) onClose();
}}
>
<Image src={images[index]} alt={`${title} screenshot ${index + 1}`} fill className="object-contain" unoptimized />
<PixelImage src={images[index]} alt={`${title} screenshot ${index + 1}`} mode="contain" 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">

View File

@@ -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 = (
<div className={cardClass}>
<div className="relative aspect-[3/2] w-full">
<div className="relative aspect-[3/2] w-full rounded-[12px] overflow-hidden">
<div className={`absolute inset-0 ${gradientBgClass}`} />
{showTitlePlaceholder ? (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
<span
className="text-[8rem] font-extrabold leading-[0.9] text-black/10 dark:text-white/20 select-none text-center uppercase tracking-tight"
>
{hack.title}
</span>
<div className="h-full w-full flex items-center justify-center">
<FaRegImages className={`text-[10rem] ${ready ? "text-emerald-600/40 dark:text-emerald-300/40" : "text-black/20 dark:text-white/30"} select-none text-center`} />
</div>
) : isCarousel ? (
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
{images.map((src, idx) => (
<div className="relative h-full flex-[0_0_100%]" key={`${src}-${idx}`}>
<Image
<PixelImage
src={src}
alt={hack.title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className={`w-full h-full object-cover ${clickable ? "transition-transform duration-300 group-hover:scale-[1.03]" : ""}`}
unoptimized
mode="contain"
className={`absolute inset-0 ${clickable ? "transition-transform duration-300 group-hover:scale-[1.05]" : ""}`}
imgClassName={shadowClass}
/>
</div>
))}
</div>
</div>
) : images[0] ? (
<Image
src={images[0]}
alt={hack.title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className={`object-cover ${clickable ? "transition-transform duration-300 group-hover:scale-[1.03]" : ""}`}
unoptimized
/>
<div className="relative h-full">
<PixelImage
src={images[0]}
alt={hack.title}
mode="contain"
className={`${clickable ? "transition-transform duration-300 group-hover:scale-[1.05]" : ""}`}
imgClassName={shadowClass}
/>
</div>
) : null}
{!showTitlePlaceholder && <div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent dark:from-black/50" />}
<div className="absolute left-3 top-3 z-10 flex gap-2">
{hack.tags.slice(0, 2).map((t) => (
<span

View File

@@ -0,0 +1,112 @@
"use client";
import React from "react";
type PixelImageProps = {
src: string;
alt: string;
mode?: "cover" | "contain"; // cover: fill and crop, contain: letterbox without cropping
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) {
const wrapperRef = React.useRef<HTMLDivElement | null>(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<number>(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 (
<div ref={wrapperRef} className={`relative overflow-hidden w-full h-full ${className}`.trim()} style={style} onClick={onClick}>
{/* We purposefully avoid Next/Image here to fully control integer scaling */}
<img
src={src}
alt={alt}
draggable={false}
onLoad={(e) => {
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",
}}
/>
</div>
);
}