Files
ksan.dev/components/ui/ProjectModal.tsx
T
2026-08-01 16:07:57 +02:00

299 lines
13 KiB
TypeScript

"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<Record<string, boolean>>({});
// Retain the last project so content stays rendered while the panel fades out.
const [data, setData] = useState<Project | null>(project);
const open = project !== null;
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const restoreFocusRef = useRef<HTMLElement | null>(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<HTMLElement>(
'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.
<div
className="fixed inset-0 z-[80] flex items-center justify-center p-4"
aria-hidden={!open}
style={{ pointerEvents: open ? "auto" : "none" }}
>
{/* 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). */}
<motion.div
className="absolute inset-0 bg-black/75 backdrop-blur-sm"
initial={false}
animate={{ opacity: open ? 1 : 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ willChange: "opacity", transform: "translateZ(0)" }}
onClick={onClose}
/>
{/* Panel — independent sibling (not nested under an opacity-animated node) */}
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
initial={false}
animate={{ opacity: open ? 1 : 0, scale: open ? 1 : 0.96, y: open ? 0 : 8 }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
className="relative max-h-[90vh] w-full max-w-3xl overflow-y-auto rounded-2xl border border-white/10 bg-[#0f0d1a] shadow-2xl outline-none"
onClick={(e) => e.stopPropagation()}
>
{data && (
<>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-lg bg-white/5 text-text-secondary transition-colors hover:bg-white/10 hover:text-text-primary"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
{/* 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 && (
<div>
<div className="relative aspect-video w-full overflow-hidden rounded-t-2xl bg-surface/50">
{screenshots.map((src, i) => (
<Image
key={i}
src={src}
alt={`${data.title} screenshot ${i + 1}`}
fill
loading="eager"
sizes="(max-width: 768px) 100vw, 768px"
draggable={false}
className="object-contain transition-opacity duration-300 ease-out"
style={{ opacity: i === imgIndex && loaded[src] ? 1 : 0 }}
onLoad={() => markLoaded(src)}
/>
))}
{hasGallery && (
<>
<button
type="button"
onClick={() => setImgIndex((idx) => (idx - 1 + screenshots.length) % screenshots.length)}
className="absolute left-3 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-lg bg-black/60 text-white transition-colors hover:bg-black/80"
aria-label="Previous screenshot"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setImgIndex((idx) => (idx + 1) % screenshots.length)}
className="absolute right-3 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-lg bg-black/60 text-white transition-colors hover:bg-black/80"
aria-label="Next screenshot"
>
<ChevronRight className="h-4 w-4" />
</button>
<div className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 gap-1.5">
{screenshots.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setImgIndex(i)}
className={`h-1.5 rounded-full transition-all duration-200 ${i === imgIndex ? "w-5 bg-white" : "w-1.5 bg-white/40"}`}
aria-label={`Screenshot ${i + 1}`}
/>
))}
</div>
</>
)}
</div>
{hasGallery && (
<div className="flex gap-2 overflow-x-auto bg-white/[0.02] px-4 py-3">
{screenshots.map((src, i) => (
<button
key={i}
type="button"
onClick={() => setImgIndex(i)}
className={`relative h-14 w-24 shrink-0 overflow-hidden rounded-lg border-2 transition-all ${
i === imgIndex
? "border-purple-400 opacity-100"
: "border-transparent opacity-50 hover:opacity-80"
}`}
>
<Image src={src} alt="" fill loading="eager" className="object-cover" sizes="96px" draggable={false} />
</button>
))}
</div>
)}
</div>
)}
{/* Content */}
<div className="p-6">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span
className={`rounded-md px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider ${
data.type === "client"
? "border border-purple-400/30 bg-purple-500/15 text-purple-300"
: "border border-border/50 bg-white/5 text-text-secondary"
}`}
>
{data.type === "client" ? dict.projects.clientWork : dict.projects.personal}
</span>
{data.live && <LiveDot />}
{data.inProgress && <InProgressBadge label={dict.projects.inProgress} />}
</div>
<h2 id={titleId} className="mb-4 font-display text-2xl font-medium tracking-tight text-text-primary">
{data.title}
</h2>
<p className="mb-5 leading-relaxed text-text-secondary">{data.about}</p>
<div className="mb-6 flex flex-wrap gap-1.5">
{data.tags.map((tag) => (
<span
key={tag}
className="rounded-md bg-surface/60 px-2.5 py-1 font-mono text-xs text-text-secondary"
>
{tag}
</span>
))}
</div>
<div className="flex flex-wrap gap-2">
{data.live && data.url && (
<a
href={data.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-300 transition-all hover:bg-emerald-500/20"
>
<ExternalLink className="h-3.5 w-3.5" />
{dict.projects.viewProject}
</a>
)}
{data.github && (
<a
href={data.github}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-white/5 px-4 py-2 text-sm text-text-secondary transition-all hover:border-purple-400/30 hover:bg-white/10 hover:text-text-primary"
>
<SiGitea className="h-3.5 w-3.5" />
{dict.projects.source}
</a>
)}
{data.download && (
<a
href={data.download}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-white/5 px-4 py-2 text-sm text-text-secondary transition-all hover:border-purple-400/30 hover:bg-white/10 hover:text-text-primary"
>
<Download className="h-3.5 w-3.5" />
{dict.projects.download}
</a>
)}
</div>
</div>
</>
)}
</motion.div>
</div>
);
return createPortal(overlay, document.body);
}