Initial commit
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"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);
|
||||
}
|
||||
Reference in New Issue
Block a user