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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user