Initial commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface GlassCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hover?: boolean;
|
||||
variant?: "default" | "strong";
|
||||
}
|
||||
|
||||
export default function GlassCard({
|
||||
children,
|
||||
className,
|
||||
hover = true,
|
||||
variant = "default",
|
||||
}: GlassCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-2xl p-6 transition-all duration-500",
|
||||
variant === "default" ? "glass" : "glass-strong",
|
||||
hover &&
|
||||
"hover:border-purple-400/30 hover:shadow-glow-md hover:-translate-y-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface GlowButtonProps {
|
||||
children: ReactNode;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
type?: "button" | "submit" | "reset";
|
||||
variant?: "primary" | "secondary" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: "left" | "right";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function GlowButton({
|
||||
children,
|
||||
href,
|
||||
onClick,
|
||||
type = "button",
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className,
|
||||
icon,
|
||||
iconPosition = "right",
|
||||
disabled = false,
|
||||
}: GlowButtonProps) {
|
||||
const sizeStyles = {
|
||||
sm: "h-9 px-4 text-sm",
|
||||
md: "h-11 px-6 text-sm",
|
||||
lg: "h-13 px-8 text-base",
|
||||
}[size];
|
||||
|
||||
const variantStyles = {
|
||||
primary: cn(
|
||||
"bg-gradient-to-r from-purple-300 via-purple-500 to-purple-700",
|
||||
"text-white shadow-glow-md",
|
||||
"hover:shadow-glow-lg hover:brightness-110",
|
||||
),
|
||||
secondary: cn(
|
||||
"glass-strong text-text-primary",
|
||||
"hover:border-purple-400/40 hover:bg-elevated/60",
|
||||
),
|
||||
ghost: cn("text-text-secondary hover:text-text-primary hover:bg-white/5"),
|
||||
}[variant];
|
||||
|
||||
const inner = (
|
||||
<motion.span
|
||||
whileHover={disabled ? undefined : { y: -1 }}
|
||||
whileTap={disabled ? undefined : { scale: 0.98 }}
|
||||
className={cn(
|
||||
"group relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-xl font-medium tracking-tight transition-all duration-300",
|
||||
sizeStyles,
|
||||
variantStyles,
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{variant === "primary" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/25 to-transparent transition-transform duration-1000 group-hover:translate-x-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{icon && iconPosition === "left" && (
|
||||
<span className="relative z-10 inline-flex shrink-0 transition-transform duration-300 group-hover:-translate-x-0.5">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="relative z-10">{children}</span>
|
||||
{icon && iconPosition === "right" && (
|
||||
<span className="relative z-10 inline-flex shrink-0 transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</motion.span>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return <Link href={href}>{inner}</Link>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button type={type} onClick={onClick} disabled={disabled} className="inline-block">
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ShieldCheck, ShieldX, Loader2 } from "lucide-react";
|
||||
import { countryCodeToEmoji } from "@/lib/utils";
|
||||
import type { Dictionary } from "@/lib/dictionaries";
|
||||
|
||||
interface IPData {
|
||||
ip: string;
|
||||
country_name: string;
|
||||
country_code: string;
|
||||
region: string;
|
||||
city: string;
|
||||
org: string;
|
||||
asn: string;
|
||||
}
|
||||
|
||||
interface IPInfoCardProps {
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export default function IPInfoCard({ dict }: IPInfoCardProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [data, setData] = useState<IPData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isSecure, setIsSecure] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsSecure(window.location.protocol === "https:");
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
// Fetch IP data when opened. The request is tiny, so it's allowed to finish
|
||||
// even if the popup closes — aborting it used to leave the card stuck on
|
||||
// the loading state forever. Failed lookups retry on the next open.
|
||||
const fetching = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || data || fetching.current) return;
|
||||
fetching.current = true;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
fetch("https://ipapi.co/json/")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
setData({
|
||||
ip: json.ip,
|
||||
country_name: json.country_name,
|
||||
country_code: json.country_code,
|
||||
region: json.region,
|
||||
city: json.city,
|
||||
org: json.org || "Unknown",
|
||||
asn: json.asn || "",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
fetching.current = false;
|
||||
setLoading(false);
|
||||
});
|
||||
}, [open, data]);
|
||||
|
||||
const flag = data ? countryCodeToEmoji(data.country_code) : "🌐";
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 text-base transition-all hover:border-purple-400/40 hover:bg-purple-500/10"
|
||||
aria-label="Visitor info"
|
||||
>
|
||||
{data ? flag : "🌐"}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.96 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute right-0 top-12 z-50 w-72 overflow-hidden rounded-2xl border border-border/60 bg-surface/95 shadow-2xl shadow-purple-500/10 backdrop-blur-xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 border-b border-border/40 px-4 py-3">
|
||||
<span className="font-mono text-xs uppercase tracking-widest text-text-secondary">
|
||||
{dict.ipInfo.title}
|
||||
</span>
|
||||
<span className="ml-auto rounded-full border border-purple-400/30 bg-purple-500/10 px-2 py-0.5 text-[10px] font-medium text-purple-300">
|
||||
Visitor: {flag} {data?.country_code || "??"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 py-3">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-text-secondary">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{dict.ipInfo.loading}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="py-6 text-center text-sm text-text-secondary">
|
||||
{dict.ipInfo.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !loading && (
|
||||
<>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="text-3xl">{flag}</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{data.country_name}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-purple-400">
|
||||
{data.ip}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0 divide-y divide-border/30 rounded-xl border border-border/40 bg-background/50">
|
||||
<InfoRow label={dict.ipInfo.region} value={data.region} />
|
||||
<InfoRow label={dict.ipInfo.city} value={data.city} />
|
||||
<InfoRow
|
||||
label={dict.ipInfo.isp}
|
||||
value={`${data.asn} ${data.org}`}
|
||||
wrap
|
||||
/>
|
||||
<div className="flex items-center justify-between px-3 py-2.5">
|
||||
<span className="font-mono text-[11px] text-text-secondary">
|
||||
{dict.ipInfo.connection}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
isSecure ? "text-emerald-400" : "text-error"
|
||||
}`}
|
||||
>
|
||||
{isSecure
|
||||
? dict.ipInfo.secure
|
||||
: dict.ipInfo.notSecure}
|
||||
</span>
|
||||
{isSecure ? (
|
||||
<ShieldCheck
|
||||
className="h-3.5 w-3.5 text-emerald-400"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
) : (
|
||||
<ShieldX
|
||||
className="h-3.5 w-3.5 text-error"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
wrap,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
wrap?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start justify-between gap-3 px-3 py-2.5 ${
|
||||
wrap ? "" : "items-center"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[11px] text-text-secondary">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={`text-right font-mono text-xs text-text-primary ${
|
||||
wrap ? "break-all text-right" : "truncate"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Globe } from "lucide-react";
|
||||
import { localeFlags, type Locale, type Dictionary } from "@/lib/dictionaries";
|
||||
|
||||
interface LangBannerProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export default function LangBanner({ locale, dict }: LangBannerProps) {
|
||||
const [show, setShow] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const otherLocale: Locale = locale === "en" ? "sr" : "en";
|
||||
|
||||
useEffect(() => {
|
||||
// Don't show if user already dismissed
|
||||
if (document.cookie.includes("lang-banner-dismissed=1")) return;
|
||||
|
||||
// Check browser language
|
||||
const browserLangs = navigator.languages || [navigator.language];
|
||||
const prefersOther = browserLangs.some((lang) => {
|
||||
const base = lang.toLowerCase().split("-")[0];
|
||||
if (locale === "en" && ["sr", "bs", "hr"].includes(base)) return true;
|
||||
if (locale === "sr" && base === "en") return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (prefersOther) {
|
||||
// Small delay so it doesn't flash on load
|
||||
const timer = setTimeout(() => setShow(true), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.setProperty("--banner-h", show ? "40px" : "0px");
|
||||
return () => { document.body.style.setProperty("--banner-h", "0px"); };
|
||||
}, [show]);
|
||||
|
||||
function dismiss() {
|
||||
setShow(false);
|
||||
// Dismiss for 30 days
|
||||
document.cookie = "lang-banner-dismissed=1; path=/; max-age=2592000; SameSite=Lax";
|
||||
}
|
||||
|
||||
function getOtherLocalePath() {
|
||||
const segments = pathname.split("/");
|
||||
segments[1] = otherLocale;
|
||||
return segments.join("/") || `/${otherLocale}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="fixed inset-x-0 top-0 z-[60] overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3 border-b border-purple-400/20 bg-purple-500/10 px-4 py-2.5 text-sm backdrop-blur-xl">
|
||||
<Globe className="h-3.5 w-3.5 text-purple-300" strokeWidth={1.5} />
|
||||
<span className="text-text-secondary">
|
||||
{dict.langBanner.message}{" "}
|
||||
<Link
|
||||
href={getOtherLocalePath()}
|
||||
onClick={() => {
|
||||
// Set cookie so future root visits go to this locale
|
||||
document.cookie = `NEXT_LOCALE=${otherLocale}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
dismiss();
|
||||
}}
|
||||
className="font-medium text-purple-300 underline decoration-purple-400/40 underline-offset-2 transition-colors hover:text-purple-200"
|
||||
>
|
||||
{localeFlags[otherLocale]} {dict.langBanner.switchTo}
|
||||
</Link>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="ml-2 rounded-md p-1 text-text-secondary transition-colors hover:bg-white/5 hover:text-text-primary"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Globe } from "lucide-react";
|
||||
import { locales, localeNames, localeFlags, type Locale } from "@/lib/dictionaries";
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function LanguageSwitcher({ locale }: LanguageSwitcherProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
// Build the equivalent path for the other locale
|
||||
function getLocalePath(targetLocale: Locale) {
|
||||
// Replace /en/... or /bs/... with the target locale
|
||||
const segments = pathname.split("/");
|
||||
segments[1] = targetLocale;
|
||||
return segments.join("/") || `/${targetLocale}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex h-9 items-center gap-1.5 rounded-lg border border-border/60 px-2.5 font-mono text-xs text-text-secondary transition-all hover:border-purple-400/40 hover:bg-purple-500/10 hover:text-text-primary"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
<span className="uppercase">{locale}</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 6, scale: 0.97 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute right-0 top-11 z-50 min-w-[140px] overflow-hidden rounded-xl border border-border/60 bg-surface/95 shadow-xl shadow-purple-500/10 backdrop-blur-xl"
|
||||
>
|
||||
{locales.map((l) => {
|
||||
const isActive = l === locale;
|
||||
return (
|
||||
<Link
|
||||
key={l}
|
||||
href={getLocalePath(l)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
// Persist user's explicit language choice
|
||||
document.cookie = `NEXT_LOCALE=${l}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
// Dismiss language banner since user chose explicitly
|
||||
document.cookie = "lang-banner-dismissed=1; path=/; max-age=2592000; SameSite=Lax";
|
||||
}}
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-purple-500/10 text-purple-300"
|
||||
: "text-text-secondary hover:bg-white/5 hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{localeFlags[l]}</span>
|
||||
<span>{localeNames[l]}</span>
|
||||
{isActive && (
|
||||
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-purple-400" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
interface LogoMarkProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ksan.dev "Kd" monogram — two vertical bars (the K stem and d ascender)
|
||||
* with the K's arms and the d's bowl in brand purple. Geometry is taken
|
||||
* directly from the source artwork (kd.svg) and shared with app/icon.svg and
|
||||
* app/[locale]/opengraph-image.tsx so every surface uses the same mark.
|
||||
*
|
||||
* The bars use `currentColor` (pass a text color via `className`, e.g.
|
||||
* "text-white"); the arms/bowl are fixed to the brand purple.
|
||||
*/
|
||||
export function LogoMark({ className }: LogoMarkProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 135.46666 135.46667"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* K stem */}
|
||||
<rect x="10.583333" y="8.20364" width="13.804729" height="118.32624" />
|
||||
{/* d ascender */}
|
||||
<rect x="112.60719" y="8.2020836" width="13.804729" height="118.32624" />
|
||||
{/* K arms */}
|
||||
<path
|
||||
fill="#a78bfa"
|
||||
d="M 24.392675,67.364089 66.876115,126.5268 H 80.680844 L 38.057204,67.366755 80.677942,8.2059598 66.876115,8.2020832 Z"
|
||||
/>
|
||||
{/* d bowl */}
|
||||
<path
|
||||
fill="#a78bfa"
|
||||
d="M 112.28492,126.5237 98.48174,126.5257 71.437499,88.862363 98.48174,51.458843 h 13.80472 L 85.153597,88.860364 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogoMark;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function ScanlineOverlay() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-1 overflow-hidden">
|
||||
<div className="absolute inset-0 scanline-bg opacity-40" />
|
||||
|
||||
<motion.div
|
||||
className="absolute inset-x-0 h-32"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, transparent 0%, rgba(167, 139, 250, 0.06) 50%, transparent 100%)",
|
||||
}}
|
||||
animate={{ y: ["-10vh", "110vh"] }}
|
||||
transition={{ duration: 12, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.015] mix-blend-overlay"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: "200px 200px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 vignette" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user