mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Add PixelImage component
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
112
src/components/PixelImage.tsx
Normal file
112
src/components/PixelImage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user