Initial commit
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import Link from "next/link";
|
||||
import { Mail } from "lucide-react";
|
||||
import { FaLinkedin } from "react-icons/fa6";
|
||||
import { SiGitea } from "react-icons/si";
|
||||
import type { Locale, Dictionary } from "@/lib/dictionaries";
|
||||
import { LogoMark } from "@/components/ui/Logo";
|
||||
|
||||
interface FooterProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export default function Footer({ locale, dict }: FooterProps) {
|
||||
const footerLinks = {
|
||||
[dict.footer.product]: [
|
||||
{ label: dict.nav.features, href: `/${locale}/#features` },
|
||||
{ label: dict.nav.services, href: `/${locale}/services` },
|
||||
{ label: dict.nav.contact, href: `/${locale}/contact` },
|
||||
],
|
||||
[dict.footer.company]: [
|
||||
{ label: dict.nav.about, href: `/${locale}/about` },
|
||||
],
|
||||
[dict.footer.connect]: [
|
||||
{ label: "Source Code", href: "https://git.ksan.dev/ksan", external: true },
|
||||
{ label: "Email", href: "mailto:contact@ksan.dev", external: true },
|
||||
{ label: "LinkedIn", href: "https://linkedin.com", external: true },
|
||||
],
|
||||
};
|
||||
|
||||
const socials = [
|
||||
{ icon: SiGitea, href: "https://git.ksan.dev/ksan", label: "Gitea" },
|
||||
{ icon: Mail, href: "mailto:contact@ksan.dev", label: "Email" },
|
||||
{ icon: FaLinkedin, href: "https://linkedin.com", label: "LinkedIn" },
|
||||
];
|
||||
|
||||
return (
|
||||
<footer className="relative z-10 mt-32 border-t border-border/40">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-purple-400/40 to-transparent" />
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-64 overflow-hidden">
|
||||
<div className="absolute left-1/2 top-0 h-64 w-[60rem] -translate-x-1/2 -translate-y-1/2 rounded-full bg-purple-500/15 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-20">
|
||||
<div className="grid grid-cols-2 gap-12 lg:grid-cols-5">
|
||||
<div className="col-span-2 max-w-sm">
|
||||
<Link href={`/${locale}`} className="inline-flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg border border-purple-400/30 bg-purple-500/10 shadow-glow-sm">
|
||||
<LogoMark className="w-[19px] text-white" />
|
||||
</div>
|
||||
<span className="font-display text-base font-medium text-text-primary">
|
||||
ksan.dev
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<p className="mt-5 text-sm leading-relaxed text-text-secondary">
|
||||
Full-stack engineering, end to end. Everything built and hosted by me.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
{socials.map((social) => (
|
||||
<a
|
||||
key={social.label}
|
||||
href={social.href}
|
||||
target={social.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={social.href.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 text-text-secondary transition-all hover:border-purple-400/40 hover:bg-purple-500/10 hover:text-purple-300"
|
||||
aria-label={social.label}
|
||||
>
|
||||
<social.icon className="h-4 w-4" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.entries(footerLinks).map(([title, links]) => (
|
||||
<div key={title}>
|
||||
<h4 className="mb-4 font-mono text-xs uppercase tracking-widest text-text-secondary">
|
||||
{title}
|
||||
</h4>
|
||||
<ul className="space-y-3">
|
||||
{links.map((link) => (
|
||||
<li key={link.label}>
|
||||
{"external" in link && link.external ? (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-text-secondary transition-colors hover:text-text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-text-secondary transition-colors hover:text-text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex flex-col items-start gap-4 border-t border-border/40 pt-8 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="font-mono text-xs text-text-secondary">
|
||||
© {new Date().getFullYear()} ksan.dev · {dict.footer.rights}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-text-secondary">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-purple-400 opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-purple-400" />
|
||||
</span>
|
||||
{dict.footer.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Menu, X, ArrowRight } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Locale, Dictionary } from "@/lib/dictionaries";
|
||||
import LanguageSwitcher from "@/components/ui/LanguageSwitcher";
|
||||
import IPInfoCard from "@/components/ui/IPInfoCard";
|
||||
import { LogoMark } from "@/components/ui/Logo";
|
||||
|
||||
interface NavbarProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export default function Navbar({ locale, dict }: NavbarProps) {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const navLinks = [
|
||||
{ href: `/${locale}/#features`, label: dict.nav.features },
|
||||
{ href: `/${locale}/projects`, label: dict.nav.projects },
|
||||
|
||||
{ href: `/${locale}/services`, label: dict.nav.services },
|
||||
{ href: `/${locale}/about`, label: dict.nav.about },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setScrolled(window.scrollY > 12);
|
||||
handler();
|
||||
window.addEventListener("scroll", handler, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.header
|
||||
initial={{ y: -32, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
|
||||
style={{ top: "var(--banner-h, 0px)" }}
|
||||
className={cn(
|
||||
"fixed inset-x-0 z-50 transition-all duration-500",
|
||||
scrolled
|
||||
? "border-b border-white/5 bg-background/70 backdrop-blur-xl"
|
||||
: "bg-transparent",
|
||||
)}
|
||||
>
|
||||
<nav className="mx-auto flex h-16 w-full max-w-7xl items-center justify-between px-6 lg:h-[72px] lg:px-8">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="group flex items-center gap-2.5 transition-opacity hover:opacity-80"
|
||||
>
|
||||
<div className="relative flex h-8 w-8 items-center justify-center rounded-lg border border-purple-400/30 bg-purple-500/10 shadow-glow-sm">
|
||||
<LogoMark className="w-[19px] text-white" />
|
||||
<div className="absolute inset-0 rounded-lg bg-purple-500/20 opacity-0 blur-md transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
<span className="font-display text-base font-medium tracking-tight text-text-primary">
|
||||
ksan.dev
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Center: nav links + contact CTA — desktop */}
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
{navLinks.map((link) => {
|
||||
// Hash links (e.g. /en/#features) never appear in pathname,
|
||||
// so they are deliberately excluded from active highlighting
|
||||
const isActive =
|
||||
!link.href.includes("#") &&
|
||||
(pathname === link.href ||
|
||||
pathname.startsWith(link.href + "/"));
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={cn(
|
||||
"relative rounded-lg px-4 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "text-text-primary"
|
||||
: "text-text-secondary hover:text-text-primary",
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
{isActive && (
|
||||
<motion.span
|
||||
layoutId="nav-active"
|
||||
className="absolute inset-0 -z-10 rounded-lg bg-white/5"
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Contact CTA — sits right next to About */}
|
||||
<Link
|
||||
href={`/${locale}/contact`}
|
||||
className="group relative ml-1 inline-flex h-9 items-center gap-1.5 overflow-hidden rounded-xl bg-gradient-to-r from-purple-300 via-purple-500 to-purple-700 px-4 text-sm font-medium text-white shadow-glow-md transition-all hover:shadow-glow-lg hover:brightness-110"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<span className="relative z-10">{dict.nav.contact}</span>
|
||||
<ArrowRight className="relative z-10 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right side: lang + IP — desktop */}
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<LanguageSwitcher locale={locale} />
|
||||
<IPInfoCard dict={dict} />
|
||||
</div>
|
||||
|
||||
{/* Mobile right side */}
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<LanguageSwitcher locale={locale} />
|
||||
<IPInfoCard dict={dict} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="rounded-lg p-2 text-text-secondary hover:bg-white/5 hover:text-text-primary"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</motion.header>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 bg-background/95 backdrop-blur-xl md:hidden"
|
||||
>
|
||||
<div className="flex h-full flex-col items-center justify-center gap-8 px-6 pt-16">
|
||||
{navLinks.map((link, i) => (
|
||||
<motion.div
|
||||
key={link.href}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 * i, duration: 0.4 }}
|
||||
>
|
||||
<Link
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="font-display text-3xl font-medium tracking-tight text-text-primary transition-colors hover:text-purple-300"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.4 }}
|
||||
>
|
||||
<Link
|
||||
href={`/${locale}/contact`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="group inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-purple-300 via-purple-500 to-purple-700 px-6 py-3 font-display text-lg font-medium text-white shadow-glow-lg"
|
||||
>
|
||||
{dict.nav.contact}
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, Mail } from "lucide-react";
|
||||
import GlowButton from "@/components/ui/GlowButton";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
import type { Dictionary, Locale } from "@/lib/dictionaries";
|
||||
|
||||
interface CTAProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export default function CTA({ locale, dict }: CTAProps) {
|
||||
return (
|
||||
<section className="relative w-full py-24 sm:py-32">
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.08)}
|
||||
className="relative overflow-hidden rounded-3xl border border-purple-400/30 bg-gradient-to-br from-purple-500/10 via-surface/60 to-purple-700/10 px-8 py-16 text-center backdrop-blur-md sm:px-16 sm:py-24"
|
||||
>
|
||||
{/* Background effects */}
|
||||
<div className="absolute -left-32 -top-32 h-96 w-96 rounded-full bg-purple-500/20 blur-3xl" />
|
||||
<div className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full bg-purple-400/15 blur-3xl" />
|
||||
<div className="absolute inset-0 grid-bg opacity-30 [mask-image:radial-gradient(ellipse_at_center,#000_20%,transparent_70%)]" />
|
||||
|
||||
<div className="relative mx-auto max-w-3xl">
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-lg font-medium tracking-tight"
|
||||
>
|
||||
<span className="text-gradient-fade">{dict.cta.title}</span>
|
||||
<br />
|
||||
<span className="text-gradient-purple">
|
||||
{dict.cta.titleAccent}
|
||||
</span>
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-6 max-w-xl text-base leading-relaxed text-text-secondary sm:text-lg"
|
||||
>
|
||||
{dict.cta.subtitle}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
className="mt-10 flex flex-wrap items-center justify-center gap-3"
|
||||
>
|
||||
<GlowButton
|
||||
href={`/${locale}/contact`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<ArrowRight className="h-4 w-4" />}
|
||||
>
|
||||
{dict.cta.contact}
|
||||
</GlowButton>
|
||||
|
||||
<GlowButton
|
||||
href={`mailto:${dict.cta.email}`}
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
icon={<Mail className="h-4 w-4" />}
|
||||
iconPosition="left"
|
||||
>
|
||||
{dict.cta.email}
|
||||
</GlowButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { SiGitea } from "react-icons/si";
|
||||
import {
|
||||
ArrowRight,
|
||||
Mail,
|
||||
MapPin,
|
||||
Clock,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import GlassCard from "@/components/ui/GlassCard";
|
||||
import GlowButton from "@/components/ui/GlowButton";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
import type { Dictionary } from "@/lib/dictionaries";
|
||||
|
||||
interface ContactFormProps {
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export default function ContactForm({ dict }: ContactFormProps) {
|
||||
const t = dict.contactPage;
|
||||
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setStatus("sending");
|
||||
setErrorMsg("");
|
||||
|
||||
const form = e.currentTarget;
|
||||
const data = {
|
||||
name: (form.elements.namedItem("name") as HTMLInputElement).value,
|
||||
email: (form.elements.namedItem("email") as HTMLInputElement).value,
|
||||
subject: (form.elements.namedItem("subject") as HTMLInputElement).value,
|
||||
message: (form.elements.namedItem("message") as HTMLTextAreaElement)
|
||||
.value,
|
||||
// Honeypot — hidden from real users, bots fill it in
|
||||
company: (form.elements.namedItem("company") as HTMLInputElement).value,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to send");
|
||||
}
|
||||
|
||||
setStatus("sent");
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
setErrorMsg(
|
||||
err instanceof Error ? err.message : "Something went wrong",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const contactInfo = [
|
||||
{
|
||||
icon: Mail,
|
||||
label: t.emailLabel,
|
||||
value: "contact@ksan.dev",
|
||||
href: "mailto:contact@ksan.dev",
|
||||
},
|
||||
{
|
||||
icon: SiGitea,
|
||||
label: "Source Code",
|
||||
value: "git.ksan.dev/ksan",
|
||||
href: "https://git.ksan.dev/ksan",
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
label: t.responseTime,
|
||||
value: t.responseValue,
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
label: t.location,
|
||||
value: t.locationValue,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.1)}
|
||||
className="mx-auto grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-5"
|
||||
>
|
||||
{/* Form */}
|
||||
<motion.div variants={fadeUp} className="lg:col-span-3">
|
||||
<GlassCard variant="strong" hover={false} className="p-8 sm:p-10">
|
||||
{status === "sent" ? (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center text-center">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full border border-emerald-400/30 bg-emerald-500/10 text-emerald-300">
|
||||
<CheckCircle2 className="h-8 w-8" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="font-display text-2xl font-medium text-gradient-fade">
|
||||
{t.successTitle}
|
||||
</h3>
|
||||
<p className="mt-3 max-w-md text-text-secondary">
|
||||
{t.successMessage}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus("idle")}
|
||||
className="mt-6 text-sm text-purple-300 underline decoration-purple-400/40 underline-offset-2 hover:text-purple-200"
|
||||
>
|
||||
{t.sendAnother}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Honeypot field — invisible to humans, catches spam bots */}
|
||||
<input
|
||||
type="text"
|
||||
name="company"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
className="absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t.nameLabel}
|
||||
id="name"
|
||||
placeholder={t.namePlaceholder}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t.emailLabel}
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={t.emailPlaceholder}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label={t.subjectLabel}
|
||||
id="subject"
|
||||
placeholder={t.subjectPlaceholder}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t.messageLabel}
|
||||
id="message"
|
||||
placeholder={t.messagePlaceholder}
|
||||
multiline
|
||||
required
|
||||
/>
|
||||
|
||||
{status === "error" && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 pt-2">
|
||||
<p className="font-mono text-xs text-text-secondary">
|
||||
{t.avgResponse}
|
||||
</p>
|
||||
<GlowButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
disabled={status === "sending"}
|
||||
icon={
|
||||
status === "sending" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{status === "sending" ? t.sending : t.send}
|
||||
</GlowButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<motion.div variants={fadeUp} className="space-y-3 lg:col-span-2">
|
||||
{contactInfo.map((item) => {
|
||||
const inner = (
|
||||
<GlassCard className="flex items-center gap-4 py-5">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-purple-400/20 bg-purple-500/10 text-purple-300">
|
||||
<item.icon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-mono text-[11px] uppercase tracking-widest text-text-secondary">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-sm font-medium text-text-primary">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
{item.href && (
|
||||
<ArrowRight
|
||||
className="h-4 w-4 shrink-0 text-text-secondary transition-transform group-hover:translate-x-0.5"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
return item.href ? (
|
||||
<a
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
target={item.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
item.href.startsWith("http")
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
className="group block"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
<div key={item.label}>{inner}</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
id,
|
||||
type = "text",
|
||||
placeholder,
|
||||
required,
|
||||
multiline,
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
const className =
|
||||
"w-full rounded-xl border border-border/60 bg-surface/60 px-4 py-3 text-sm text-text-primary placeholder:text-text-secondary/40 transition-all focus:border-purple-400/60 focus:bg-surface/80 focus:outline-none focus:ring-2 focus:ring-purple-500/20";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="mb-2 block font-mono text-[11px] uppercase tracking-widest text-text-secondary"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
id={id}
|
||||
name={id}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
rows={5}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={id}
|
||||
name={id}
|
||||
type={type}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import GlowButton from "@/components/ui/GlowButton";
|
||||
import { fadeUp, stagger } from "@/lib/animations";
|
||||
import type { Dictionary, Locale } from "@/lib/dictionaries";
|
||||
|
||||
interface HeroProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export default function Hero({ locale, dict }: HeroProps) {
|
||||
const stats = [
|
||||
{ value: "13+", label: dict.hero.stats.projects },
|
||||
{ value: "4", label: dict.hero.stats.clients },
|
||||
{ value: "10", label: dict.hero.stats.infra },
|
||||
{ value: "<24h", label: dict.hero.stats.response },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="relative isolate flex min-h-[100svh] items-center overflow-hidden pt-24">
|
||||
{/* Grid background */}
|
||||
<div className="pointer-events-none absolute inset-0 grid-bg [mask-image:radial-gradient(ellipse_at_center,#000_30%,transparent_70%)] opacity-50" />
|
||||
|
||||
{/* Floating orbs */}
|
||||
<motion.div
|
||||
className="pointer-events-none absolute left-[10%] top-[10%] h-[700px] w-[700px] rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(139, 92, 246, 0.55) 0%, transparent 70%)",
|
||||
filter: "blur(60px)",
|
||||
}}
|
||||
animate={{
|
||||
x: [0, 40, -20, 0],
|
||||
y: [0, -30, 20, 0],
|
||||
scale: [1, 1.1, 0.95, 1],
|
||||
}}
|
||||
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute right-[5%] top-[30%] h-[600px] w-[600px] rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(167, 139, 250, 0.35) 0%, transparent 70%)",
|
||||
filter: "blur(70px)",
|
||||
}}
|
||||
animate={{
|
||||
x: [0, -50, 30, 0],
|
||||
y: [0, 40, -25, 0],
|
||||
scale: [1, 0.9, 1.1, 1],
|
||||
}}
|
||||
transition={{ duration: 22, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
|
||||
{/* Top accent line */}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 mx-auto h-px max-w-3xl bg-gradient-to-r from-transparent via-purple-400/50 to-transparent" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<motion.div
|
||||
variants={stagger(0.1)}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="mx-auto max-w-4xl text-center"
|
||||
>
|
||||
{/* Badge */}
|
||||
<motion.div variants={fadeUp} className="mb-8 flex justify-center">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3.5 py-1.5 font-mono text-xs tracking-tight text-emerald-300 backdrop-blur-md">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
{dict.hero.badge}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Heading */}
|
||||
<motion.h1
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-2xl font-medium tracking-tight"
|
||||
>
|
||||
<span className="text-gradient-fade">{dict.hero.titleLine1}</span>
|
||||
<br />
|
||||
<span className="text-gradient-purple">{dict.hero.titleLine2}</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-text-secondary sm:text-xl"
|
||||
>
|
||||
{dict.hero.subtitle}
|
||||
</motion.p>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
className="mt-12 flex flex-wrap items-center justify-center gap-4"
|
||||
>
|
||||
<GlowButton
|
||||
href={`/${locale}/contact`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<ArrowRight className="h-4 w-4" />}
|
||||
>
|
||||
{dict.hero.cta}
|
||||
</GlowButton>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
className="mt-20 grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-border/60 bg-border/40 sm:grid-cols-4"
|
||||
>
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="bg-background/60 px-6 py-6 backdrop-blur-sm"
|
||||
>
|
||||
<div className="font-display text-3xl font-medium tracking-tight text-text-primary sm:text-4xl">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] uppercase tracking-widest text-text-secondary">
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Bottom fade */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-background to-transparent" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { motion, useMotionValue, animate as motionAnimate } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import ProjectModal from "@/components/ui/ProjectModal";
|
||||
import { SiGitea } from "react-icons/si";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Download,
|
||||
ShoppingCart,
|
||||
ShieldCheck,
|
||||
Server,
|
||||
Smartphone,
|
||||
BarChart3,
|
||||
Building,
|
||||
} from "lucide-react";
|
||||
import GlassCard from "@/components/ui/GlassCard";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
import type { Dictionary, Locale } from "@/lib/dictionaries";
|
||||
|
||||
const projectIcons: Record<string, typeof ShoppingCart> = {
|
||||
"security-consultancy": ShieldCheck,
|
||||
"ecommerce-platform": ShoppingCart,
|
||||
"infra-dashboard": Server,
|
||||
"mobile-companion": Smartphone,
|
||||
"analytics-service": BarChart3,
|
||||
"business-landing": Building,
|
||||
"etf-oglasi": Smartphone,
|
||||
};
|
||||
|
||||
const projectColors: Record<string, string> = {
|
||||
"security-consultancy": "from-blue-500/20 to-blue-600/5 text-blue-300",
|
||||
"ecommerce-platform": "from-emerald-500/20 to-emerald-600/5 text-emerald-300",
|
||||
"infra-dashboard": "from-purple-500/20 to-purple-600/5 text-purple-300",
|
||||
"mobile-companion": "from-rose-500/20 to-rose-600/5 text-rose-300",
|
||||
"analytics-service": "from-amber-500/20 to-amber-600/5 text-amber-300",
|
||||
"business-landing": "from-slate-400/20 to-slate-500/5 text-slate-300",
|
||||
"etf-oglasi": "from-violet-500/20 to-violet-600/5 text-violet-300",
|
||||
};
|
||||
|
||||
function useCardsToShow() {
|
||||
const [count, setCount] = useState(3);
|
||||
useEffect(() => {
|
||||
function update() {
|
||||
if (window.innerWidth < 640) setCount(1);
|
||||
else if (window.innerWidth < 1024) setCount(2);
|
||||
else setCount(3);
|
||||
}
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, []);
|
||||
return count;
|
||||
}
|
||||
|
||||
interface ProjectsCarouselProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export default function ProjectsCarousel({
|
||||
locale,
|
||||
dict,
|
||||
featured = false,
|
||||
}: ProjectsCarouselProps) {
|
||||
const projects = dict.projects.items
|
||||
.filter((p) => p.inCarousel)
|
||||
.sort((a, b) => a.carouselOrder - b.carouselOrder);
|
||||
|
||||
const cardsToShow = useCardsToShow();
|
||||
const [index, setIndex] = useState(0);
|
||||
const maxIndex = Math.max(0, projects.length - cardsToShow);
|
||||
|
||||
// Clamp index when viewport resize shrinks maxIndex
|
||||
useEffect(() => {
|
||||
if (index > maxIndex) {
|
||||
setIndex(maxIndex);
|
||||
}
|
||||
}, [index, maxIndex]);
|
||||
|
||||
// ─── PIXEL-BASED MOTION VALUE ─────────────────────────────────
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const x = useMotionValue(0);
|
||||
const gapPx = 16;
|
||||
|
||||
// stride = distance in px between card positions
|
||||
function getStridePx(): number {
|
||||
const container = trackRef.current?.parentElement;
|
||||
if (!container) return 300;
|
||||
return (container.offsetWidth + gapPx) / cardsToShow;
|
||||
}
|
||||
|
||||
function animateToIndex(idx: number) {
|
||||
motionAnimate(x, -(idx * getStridePx()), {
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 30,
|
||||
mass: 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
// Animate whenever index or cardsToShow changes (handles resize too)
|
||||
useEffect(() => {
|
||||
animateToIndex(index);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [index, cardsToShow]);
|
||||
|
||||
// ─── LEAK-SAFE TIMER SYSTEM ───────────────────────────────────
|
||||
const interacting = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pendingTimeouts = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setIndex((prev) => (prev >= maxIndex ? 0 : prev + 1));
|
||||
}, [maxIndex]);
|
||||
|
||||
const goNextRef = useRef(goNext);
|
||||
useEffect(() => { goNextRef.current = goNext; }, [goNext]);
|
||||
|
||||
function goPrev() {
|
||||
setIndex((prev) => (prev <= 0 ? maxIndex : prev - 1));
|
||||
}
|
||||
|
||||
function clearAllTimers() {
|
||||
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
|
||||
pendingTimeouts.current.forEach((id) => clearTimeout(id));
|
||||
pendingTimeouts.current.clear();
|
||||
}
|
||||
|
||||
function startAutoScroll() {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = setInterval(() => {
|
||||
if (!interacting.current && mountedRef.current) goNextRef.current();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function pauseAuto() {
|
||||
interacting.current = true;
|
||||
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
|
||||
}
|
||||
|
||||
function resumeAuto() {
|
||||
if (!mountedRef.current) return;
|
||||
interacting.current = false;
|
||||
startAutoScroll();
|
||||
}
|
||||
|
||||
function safeTimeout(fn: () => void, ms: number) {
|
||||
const id = setTimeout(() => {
|
||||
pendingTimeouts.current.delete(id);
|
||||
if (mountedRef.current) fn();
|
||||
}, ms);
|
||||
pendingTimeouts.current.add(id);
|
||||
}
|
||||
|
||||
function handlePrev() { pauseAuto(); goPrev(); safeTimeout(resumeAuto, 1200); }
|
||||
function handleNext() { pauseAuto(); goNext(); safeTimeout(resumeAuto, 1200); }
|
||||
function handleDot(i: number) { pauseAuto(); setIndex(i); safeTimeout(resumeAuto, 1200); }
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
startAutoScroll();
|
||||
return () => { mountedRef.current = false; clearAllTimers(); };
|
||||
}, []);
|
||||
|
||||
// ─── MODAL ───────────────────────────────────────────────────
|
||||
type Project = (typeof projects)[number];
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
|
||||
// ─── DRAG SYSTEM ─────────────────────────────────────────────
|
||||
// Pointer capture is deliberately NOT taken on pointerdown — doing so
|
||||
// retargets the synthesized `click` event to this container, which would
|
||||
// swallow clicks on the cards/buttons inside. We only capture once an
|
||||
// actual drag begins (threshold crossed), so taps/clicks pass through.
|
||||
const DRAG_THRESHOLD = 6;
|
||||
const isPointerDown = useRef(false);
|
||||
const isDragging = useRef(false);
|
||||
const didDrag = useRef(false); // true if this gesture became a drag
|
||||
const pointerStartX = useRef(0);
|
||||
const xOnDragStart = useRef(0);
|
||||
const [grabbing, setGrabbing] = useState(false);
|
||||
|
||||
function onPointerDown(e: React.PointerEvent) {
|
||||
pointerStartX.current = e.clientX;
|
||||
xOnDragStart.current = x.get();
|
||||
isPointerDown.current = true;
|
||||
isDragging.current = false;
|
||||
didDrag.current = false;
|
||||
pauseAuto();
|
||||
}
|
||||
|
||||
function onPointerMove(e: React.PointerEvent) {
|
||||
if (!isPointerDown.current) return;
|
||||
const delta = e.clientX - pointerStartX.current;
|
||||
|
||||
if (!isDragging.current && Math.abs(delta) > DRAG_THRESHOLD) {
|
||||
isDragging.current = true;
|
||||
didDrag.current = true;
|
||||
setGrabbing(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
if (isDragging.current) {
|
||||
x.set(xOnDragStart.current + delta);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: React.PointerEvent) {
|
||||
if (!isPointerDown.current) return;
|
||||
isPointerDown.current = false;
|
||||
const wasDragging = isDragging.current;
|
||||
isDragging.current = false;
|
||||
setGrabbing(false);
|
||||
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
if (el.hasPointerCapture?.(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
||||
|
||||
// A plain tap/click — let the card/button onClick handlers run normally.
|
||||
if (!wasDragging) {
|
||||
safeTimeout(resumeAuto, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = e.clientX - pointerStartX.current;
|
||||
const stride = getStridePx();
|
||||
const threshold = stride * 0.15;
|
||||
|
||||
let newIndex = index;
|
||||
if (delta < -threshold) {
|
||||
newIndex = Math.min(maxIndex, index + Math.max(1, Math.round(-delta / stride)));
|
||||
} else if (delta > threshold) {
|
||||
newIndex = Math.max(0, index - Math.max(1, Math.round(delta / stride)));
|
||||
}
|
||||
|
||||
if (newIndex !== index) {
|
||||
setIndex(newIndex);
|
||||
// useEffect above handles the spring animation
|
||||
} else {
|
||||
animateToIndex(index); // snap back
|
||||
}
|
||||
|
||||
safeTimeout(resumeAuto, 1200);
|
||||
}
|
||||
|
||||
// Suppress the click that fires at the end of a drag-release so it doesn't
|
||||
// open the modal or follow a link.
|
||||
function onClickCapture(e: React.MouseEvent) {
|
||||
if (didDrag.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── RENDER ──────────────────────────────────────────────────
|
||||
const cardPercent = 100 / cardsToShow;
|
||||
|
||||
return (
|
||||
<section id="projects" className="relative w-full py-24 sm:py-32">
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.06)}
|
||||
className="mb-12 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-end"
|
||||
>
|
||||
<div>
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="mb-4 inline-flex items-center gap-2 rounded-full border border-purple-400/30 bg-purple-500/10 px-3.5 py-1.5 font-mono text-xs tracking-tight text-purple-300 backdrop-blur-md"
|
||||
>
|
||||
{dict.projects.badge}
|
||||
</motion.span>
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-lg font-medium tracking-tight text-gradient-fade"
|
||||
>
|
||||
{dict.projects.title}
|
||||
</motion.h2>
|
||||
</div>
|
||||
|
||||
<motion.div variants={fadeUp} className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/60 bg-surface/40 text-text-secondary transition-all hover:border-purple-400/40 hover:bg-purple-500/10 hover:text-purple-300"
|
||||
aria-label="Previous"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/60 bg-surface/40 text-text-secondary transition-all hover:border-purple-400/40 hover:bg-purple-500/10 hover:text-purple-300"
|
||||
aria-label="Next"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
{featured && (
|
||||
<Link
|
||||
href={`/${locale}/projects`}
|
||||
className="group ml-2 inline-flex items-center gap-2 font-mono text-sm text-purple-300 transition-colors hover:text-purple-200"
|
||||
>
|
||||
{dict.projects.viewAll}
|
||||
<span className="transition-transform group-hover:translate-x-0.5">→</span>
|
||||
</Link>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Carousel track */}
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl"
|
||||
onMouseEnter={pauseAuto}
|
||||
onMouseLeave={() => { if (!isDragging.current) resumeAuto(); }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onClickCapture={onClickCapture}
|
||||
style={{ cursor: grabbing ? "grabbing" : "grab", touchAction: "pan-y" }}
|
||||
>
|
||||
<motion.div
|
||||
ref={trackRef}
|
||||
className="flex"
|
||||
style={{ gap: `${gapPx}px`, x }}
|
||||
>
|
||||
{projects.map((project) => {
|
||||
const Icon = projectIcons[project.slug] || Building;
|
||||
const colorClass =
|
||||
projectColors[project.slug] ||
|
||||
"from-purple-500/20 to-purple-600/5 text-purple-300";
|
||||
const gradientClasses = colorClass.split(" ").slice(0, 2).join(" ");
|
||||
const iconColor = colorClass.split(" ").slice(2).join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.slug}
|
||||
className="shrink-0 select-none"
|
||||
style={{
|
||||
width: `calc(${cardPercent}% - ${(gapPx * (cardsToShow - 1)) / cardsToShow}px)`,
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="group h-full"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setSelectedProject(project)}
|
||||
>
|
||||
<GlassCard className="flex h-full flex-col overflow-hidden p-0">
|
||||
{project.screenshots[0] ? (
|
||||
<div className="relative h-44 w-full overflow-hidden">
|
||||
<Image
|
||||
src={project.screenshots[0]}
|
||||
alt={project.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex h-36 items-center justify-center bg-gradient-to-br ${gradientClasses}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-10 w-10 ${iconColor} transition-transform duration-500 group-hover:scale-110`}
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-md px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider ${project.type === "client"
|
||||
? "border border-purple-400/30 bg-purple-500/15 text-purple-300"
|
||||
: "border border-border/50 bg-white/5 text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{project.type === "client"
|
||||
? dict.projects.clientWork
|
||||
: dict.projects.personal}
|
||||
</span>
|
||||
{project.live && <LiveDot />}
|
||||
{project.inProgress && <InProgressBadge label={dict.projects.inProgress} />}
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium text-text-primary">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="mb-4 line-clamp-2 text-sm leading-relaxed text-text-secondary">
|
||||
{project.description}
|
||||
</p>
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
{project.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-md bg-surface/60 px-2 py-0.5 font-mono text-[11px] text-text-secondary"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-auto">
|
||||
<ProjectLinks project={project} dict={dict} />
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-3 bg-gradient-to-r from-background/40 to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-3 bg-gradient-to-l from-background/40 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Dots */}
|
||||
<div className="mt-6 flex items-center justify-center gap-1.5">
|
||||
{Array.from({ length: maxIndex + 1 }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleDot(i)}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${i === index
|
||||
? "w-6 bg-purple-400"
|
||||
: "w-1.5 bg-border hover:bg-text-secondary"
|
||||
}`}
|
||||
aria-label={`Slide ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectModal
|
||||
project={selectedProject}
|
||||
dict={dict}
|
||||
onClose={() => setSelectedProject(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Shared sub-components ----
|
||||
|
||||
export function LiveDot() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-400/30 bg-emerald-500/10 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-emerald-300">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.6)]" />
|
||||
</span>
|
||||
Live
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function InProgressBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-md border border-amber-400/30 bg-amber-500/10 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-amber-300">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectLinks({
|
||||
project,
|
||||
dict,
|
||||
}: {
|
||||
project: { live: boolean; url?: string; github?: string; download?: string; slug: string };
|
||||
dict: Dictionary;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{project.live && project.url && (
|
||||
<a
|
||||
href={project.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-3 py-1.5 text-xs font-medium text-emerald-300 transition-all hover:bg-emerald-500/20 hover:shadow-[0_0_12px_rgba(52,211,153,0.15)]"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{dict.projects.viewProject}
|
||||
</a>
|
||||
)}
|
||||
{project.github && (
|
||||
<a
|
||||
href={project.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-white/5 px-3 py-1.5 text-xs text-text-secondary transition-all hover:border-purple-400/30 hover:bg-white/10 hover:text-text-primary"
|
||||
>
|
||||
<SiGitea className="h-3 w-3" />
|
||||
{dict.projects.source}
|
||||
</a>
|
||||
)}
|
||||
{project.download && (
|
||||
<a
|
||||
href={project.download}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-white/5 px-3 py-1.5 text-xs text-text-secondary transition-all hover:border-purple-400/30 hover:bg-white/10 hover:text-text-primary"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{dict.projects.download}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ShoppingCart,
|
||||
Calendar,
|
||||
Server,
|
||||
Smartphone,
|
||||
BarChart3,
|
||||
Building,
|
||||
} from "lucide-react";
|
||||
import GlassCard from "@/components/ui/GlassCard";
|
||||
import ProjectModal from "@/components/ui/ProjectModal";
|
||||
import { LiveDot, InProgressBadge, ProjectLinks } from "@/components/sections/ProjectsCarousel";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
import type { Dictionary, Locale } from "@/lib/dictionaries";
|
||||
|
||||
const projectIcons: Record<string, typeof ShoppingCart> = {
|
||||
"client-ecommerce": ShoppingCart,
|
||||
"booking-platform": Calendar,
|
||||
"infra-dashboard": Server,
|
||||
"mobile-companion": Smartphone,
|
||||
"analytics-service": BarChart3,
|
||||
"business-landing": Building,
|
||||
"etf-oglasi": Smartphone,
|
||||
};
|
||||
|
||||
const projectColors: Record<string, string> = {
|
||||
"client-ecommerce": "from-blue-500/20 to-blue-600/5 text-blue-300",
|
||||
"booking-platform": "from-emerald-500/20 to-emerald-600/5 text-emerald-300",
|
||||
"infra-dashboard": "from-purple-500/20 to-purple-600/5 text-purple-300",
|
||||
"mobile-companion": "from-rose-500/20 to-rose-600/5 text-rose-300",
|
||||
"analytics-service": "from-amber-500/20 to-amber-600/5 text-amber-300",
|
||||
"business-landing": "from-slate-400/20 to-slate-500/5 text-slate-300",
|
||||
"etf-oglasi": "from-violet-500/20 to-violet-600/5 text-violet-300",
|
||||
};
|
||||
|
||||
interface ProjectsShowcaseProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
filter?: "client" | "personal";
|
||||
sectionTitle?: string;
|
||||
}
|
||||
|
||||
export default function ProjectsShowcase({
|
||||
dict,
|
||||
filter,
|
||||
sectionTitle,
|
||||
}: ProjectsShowcaseProps) {
|
||||
let projects = [...dict.projects.items];
|
||||
if (filter) projects = projects.filter((p) => p.type === filter);
|
||||
projects.sort((a, b) => a.order - b.order);
|
||||
|
||||
type Project = (typeof projects)[number];
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
|
||||
if (projects.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="relative w-full py-16 sm:py-20">
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
{sectionTitle && (
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={viewportConfig}
|
||||
className="mb-10 font-display text-2xl font-medium tracking-tight text-gradient-fade sm:text-3xl"
|
||||
>
|
||||
{sectionTitle}
|
||||
</motion.h2>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.08)}
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{projects.map((project) => {
|
||||
const Icon = projectIcons[project.slug] || Building;
|
||||
const colorClass =
|
||||
projectColors[project.slug] ||
|
||||
"from-purple-500/20 to-purple-600/5 text-purple-300";
|
||||
const gradientClasses = colorClass.split(" ").slice(0, 2).join(" ");
|
||||
const iconColor = colorClass.split(" ").slice(2).join(" ");
|
||||
const previewImage = project.screenshots[0];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={project.slug}
|
||||
variants={fadeUp}
|
||||
className="group h-full cursor-pointer"
|
||||
onClick={() => setSelectedProject(project)}
|
||||
>
|
||||
<GlassCard className="flex h-full flex-col overflow-hidden p-0">
|
||||
{previewImage ? (
|
||||
<div className="relative h-44 w-full overflow-hidden">
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt={project.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex h-36 items-center justify-center bg-gradient-to-br ${gradientClasses}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-10 w-10 ${iconColor} transition-transform duration-500 group-hover:scale-110`}
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-md px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider ${
|
||||
project.type === "client"
|
||||
? "border border-purple-400/30 bg-purple-500/15 text-purple-300"
|
||||
: "border border-border/50 bg-white/5 text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{project.type === "client"
|
||||
? dict.projects.clientWork
|
||||
: dict.projects.personal}
|
||||
</span>
|
||||
{project.live && <LiveDot />}
|
||||
{project.inProgress && <InProgressBadge label={dict.projects.inProgress} />}
|
||||
</div>
|
||||
|
||||
<h3 className="mb-2 text-base font-medium text-text-primary">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-relaxed text-text-secondary">
|
||||
{project.description}
|
||||
</p>
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
{project.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-md bg-surface/60 px-2 py-0.5 font-mono text-[11px] text-text-secondary"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-auto">
|
||||
<ProjectLinks project={project} dict={dict} />
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<ProjectModal
|
||||
project={selectedProject}
|
||||
dict={dict}
|
||||
onClose={() => setSelectedProject(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Code2,
|
||||
Server,
|
||||
Cloud,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
import GlassCard from "@/components/ui/GlassCard";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
import type { Dictionary, Locale } from "@/lib/dictionaries";
|
||||
|
||||
const icons = [Code2, Server, Cloud, Smartphone];
|
||||
|
||||
interface ServicesProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export default function Services({ dict }: ServicesProps) {
|
||||
return (
|
||||
<section id="features" className="relative w-full py-24 sm:py-32">
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.06)}
|
||||
className="mx-auto mb-16 max-w-3xl text-center"
|
||||
>
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="mb-6 inline-flex items-center gap-2 rounded-full border border-purple-400/30 bg-purple-500/10 px-3.5 py-1.5 font-mono text-xs tracking-tight text-purple-300 backdrop-blur-md"
|
||||
>
|
||||
{dict.services.badge}
|
||||
</motion.span>
|
||||
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-lg font-medium tracking-tight"
|
||||
>
|
||||
<span className="text-gradient-fade">
|
||||
{dict.services.title}
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-gradient-purple">
|
||||
{dict.services.titleAccent}
|
||||
</span>
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary sm:text-lg"
|
||||
>
|
||||
{dict.services.subtitle}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Grid */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.08)}
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||
>
|
||||
{dict.services.items.map((service, i) => {
|
||||
const Icon = icons[i];
|
||||
return (
|
||||
<motion.div key={i} variants={fadeUp} className="group h-full">
|
||||
<GlassCard className="h-full overflow-hidden">
|
||||
{/* Hover glow */}
|
||||
<div className="absolute -right-12 -top-12 h-32 w-32 rounded-full bg-purple-500/0 blur-3xl transition-all duration-700 group-hover:bg-purple-500/25" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="mb-5 inline-flex h-12 w-12 items-center justify-center rounded-xl border border-purple-400/20 bg-purple-500/10 text-purple-300 transition-all duration-500 group-hover:border-purple-400/50 group-hover:bg-purple-500/20 group-hover:text-purple-200 group-hover:shadow-glow-sm">
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<h3 className="mb-2 text-lg font-medium text-text-primary">
|
||||
{service.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm leading-relaxed text-text-secondary">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Layers, Cpu, Database, Server } from "lucide-react";
|
||||
import GlassCard from "@/components/ui/GlassCard";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
import type { Dictionary, Locale } from "@/lib/dictionaries";
|
||||
|
||||
const skillGroups = [
|
||||
{
|
||||
icon: Layers,
|
||||
category: "Frontend",
|
||||
items: ["Next.js", "React", "React Native", "Tailwind CSS"],
|
||||
color: "from-blue-400/20 to-blue-500/5",
|
||||
border: "group-hover:border-blue-400/40",
|
||||
iconBg: "bg-blue-500/10 border-blue-400/20 text-blue-300",
|
||||
iconHover: "group-hover:bg-blue-500/20 group-hover:border-blue-400/50",
|
||||
},
|
||||
{
|
||||
icon: Cpu,
|
||||
category: "Backend",
|
||||
items: ["Java", "Spring Boot", "Node.js", "TypeScript", "C++", "Bash"],
|
||||
color: "from-emerald-400/20 to-emerald-500/5",
|
||||
border: "group-hover:border-emerald-400/40",
|
||||
iconBg: "bg-emerald-500/10 border-emerald-400/20 text-emerald-300",
|
||||
iconHover:
|
||||
"group-hover:bg-emerald-500/20 group-hover:border-emerald-400/50",
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
category: "Databases",
|
||||
items: ["PostgreSQL", "MySQL"],
|
||||
color: "from-amber-400/20 to-amber-500/5",
|
||||
border: "group-hover:border-amber-400/40",
|
||||
iconBg: "bg-amber-500/10 border-amber-400/20 text-amber-300",
|
||||
iconHover: "group-hover:bg-amber-500/20 group-hover:border-amber-400/50",
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
category: "Infrastructure & DevOps",
|
||||
items: ["Linux", "CI/CD", "Docker", "Traefik", "NGINX", "VPS"],
|
||||
color: "from-purple-400/20 to-purple-500/5",
|
||||
border: "group-hover:border-purple-400/40",
|
||||
iconBg: "bg-purple-500/10 border-purple-400/20 text-purple-300",
|
||||
iconHover: "group-hover:bg-purple-500/20 group-hover:border-purple-400/50",
|
||||
},
|
||||
];
|
||||
|
||||
interface TechStackProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export default function TechStack({ dict }: TechStackProps) {
|
||||
return (
|
||||
<section className="relative w-full py-24 sm:py-32">
|
||||
{/* Subtle grid background */}
|
||||
<div className="pointer-events-none absolute inset-0 grid-bg [mask-image:radial-gradient(ellipse_at_center,#000_20%,transparent_70%)] opacity-30" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.06)}
|
||||
className="mx-auto mb-16 max-w-3xl text-center"
|
||||
>
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="mb-6 inline-flex items-center gap-2 rounded-full border border-purple-400/30 bg-purple-500/10 px-3.5 py-1.5 font-mono text-xs tracking-tight text-purple-300 backdrop-blur-md"
|
||||
>
|
||||
{dict.techStack.badge}
|
||||
</motion.span>
|
||||
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-lg font-medium tracking-tight"
|
||||
>
|
||||
<span className="text-gradient-fade">
|
||||
{dict.techStack.title}
|
||||
</span>{" "}
|
||||
<span className="text-gradient-purple">
|
||||
{dict.techStack.titleAccent}
|
||||
</span>
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-6 max-w-xl text-base leading-relaxed text-text-secondary"
|
||||
>
|
||||
{dict.techStack.subtitle}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Skill cards */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.1)}
|
||||
className="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||
>
|
||||
{skillGroups.map((group) => (
|
||||
<motion.div
|
||||
key={group.category}
|
||||
variants={fadeUp}
|
||||
className="group h-full"
|
||||
>
|
||||
<GlassCard className={`h-full ${group.border}`}>
|
||||
{/* Gradient accent on hover */}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-2xl bg-gradient-to-br ${group.color} opacity-0 transition-opacity duration-500 group-hover:opacity-100`}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg border transition-all duration-500 ${group.iconBg} ${group.iconHover}`}
|
||||
>
|
||||
<group.icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-text-primary">
|
||||
{group.category}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.items.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="rounded-lg border border-border/60 bg-surface/60 px-3 py-1.5 font-mono text-xs text-text-secondary transition-all duration-300 hover:border-purple-400/40 hover:text-purple-200"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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