299 lines
13 KiB
TypeScript
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);
|
|
}
|