"use client"; import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { motion } from "framer-motion"; import Image from "next/image"; import { X, ExternalLink, Download, ChevronLeft, ChevronRight } from "lucide-react"; import { SiGitea } from "react-icons/si"; import { LiveDot, InProgressBadge } from "@/components/sections/ProjectsCarousel"; import type { Dictionary } from "@/lib/dictionaries"; type Project = Dictionary["projects"]["items"][number]; interface ProjectModalProps { project: Project | null; dict: Dictionary; onClose: () => void; } // SSR-safe client gate for the portal: false on the server, true after // hydration — no effect, no setState, no hydration mismatch. const emptySubscribe = () => () => {}; function useIsClient() { return useSyncExternalStore(emptySubscribe, () => true, () => false); } export default function ProjectModal({ project, dict, onClose }: ProjectModalProps) { const mounted = useIsClient(); const [imgIndex, setImgIndex] = useState(0); // Track which screenshot sources have finished decoding so we can fade them // in over a dark placeholder — never a blank/bright pop. const [loaded, setLoaded] = useState>({}); // Retain the last project so content stays rendered while the panel fades out. const [data, setData] = useState(project); const open = project !== null; const titleId = useId(); const panelRef = useRef(null); const restoreFocusRef = useRef(null); // Latch the project + reset the gallery when a new one opens. Adjusting state // during render (guarded) is the recommended alternative to a prop-sync effect. if (project && project !== data) { setData(project); setImgIndex(0); } // Body scroll lock with scrollbar-width compensation (prevents layout shift). useEffect(() => { if (!open) return; const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; const prevOverflow = document.body.style.overflow; const prevPadding = document.body.style.paddingRight; document.body.style.overflow = "hidden"; if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`; return () => { document.body.style.overflow = prevOverflow; document.body.style.paddingRight = prevPadding; }; }, [open]); // Focus management: trap Tab, close on Escape, restore focus on close. useEffect(() => { if (!open) return; restoreFocusRef.current = document.activeElement as HTMLElement | null; panelRef.current?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); return; } if (e.key !== "Tab" || !panelRef.current) return; const focusables = panelRef.current.querySelectorAll( 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', ); if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; document.addEventListener("keydown", onKey); return () => { document.removeEventListener("keydown", onKey); restoreFocusRef.current?.focus?.(); }; }, [open, onClose]); if (!mounted) return null; const screenshots = data?.screenshots ?? []; const hasGallery = screenshots.length > 1; const markLoaded = (src: string) => setLoaded((prev) => (prev[src] ? prev : { ...prev, [src]: true })); const overlay = ( // Static container — NEVER animates opacity. Animating opacity here is what // forced the backdrop-filter into a grouped buffer and produced the flash.
{/* Backdrop — own animated layer. will-change + translateZ promote it to a dedicated compositing layer so a panel/image repaint can't force the backdrop-filter to recomposite (the source of the bright flash). */} {/* Panel — independent sibling (not nested under an opacity-animated node) */} e.stopPropagation()} > {data && ( <> {/* Screenshot gallery — every frame is a permanently-mounted stacked layer. Keys are the stable loop index (never `src` or `imgIndex`), so nothing ever remounts → no decode resets. `loading="eager"` preloads the whole gallery on open; opacity is gated on decode so each frame fades in over a dark placeholder (no instant pop). */} {screenshots.length > 0 && (
{screenshots.map((src, i) => ( {`${data.title} markLoaded(src)} /> ))} {hasGallery && ( <>
{screenshots.map((_, i) => (
)}
{hasGallery && (
{screenshots.map((src, i) => ( ))}
)}
)} {/* Content */}
{data.type === "client" ? dict.projects.clientWork : dict.projects.personal} {data.live && } {data.inProgress && }

{data.title}

{data.about}

{data.tags.map((tag) => ( {tag} ))}
{data.live && data.url && ( {dict.projects.viewProject} )} {data.github && ( {dict.projects.source} )} {data.download && ( {dict.projects.download} )}
)}
); return createPortal(overlay, document.body); }