Initial commit
@@ -0,0 +1,44 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,8 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
// Catch-all for unknown paths under a locale (e.g. /en/asdf) so they render
|
||||
// the styled not-found page inside the locale layout instead of the bare
|
||||
// default 404.
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
GitBranch,
|
||||
Lock,
|
||||
Zap,
|
||||
Sparkles,
|
||||
MapPin,
|
||||
Server,
|
||||
Globe,
|
||||
Briefcase,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import GlassCard from "@/components/ui/GlassCard";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
import type { Dictionary, Locale } from "@/lib/dictionaries";
|
||||
|
||||
const highlightIcons = [GitBranch, Lock, Zap, Sparkles];
|
||||
|
||||
const factIcons = [MapPin, Server, Globe, Briefcase, Clock];
|
||||
|
||||
interface AboutContentProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
interface TextParagraph {
|
||||
type: "text";
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface LinkParagraph {
|
||||
type: "linkParagraph";
|
||||
before: string;
|
||||
linkText: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
type ApproachParagraph = TextParagraph | LinkParagraph;
|
||||
|
||||
export default function AboutContent({ locale, dict }: AboutContentProps) {
|
||||
const t = dict.aboutPage;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="relative isolate flex min-h-[50svh] items-center overflow-hidden pt-32">
|
||||
<div className="pointer-events-none absolute inset-0 grid-bg [mask-image:radial-gradient(ellipse_at_center,#000_30%,transparent_70%)] opacity-30" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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"
|
||||
>
|
||||
{t.badge}
|
||||
</motion.span>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="font-display text-display-xl font-medium tracking-tight"
|
||||
>
|
||||
<span className="text-gradient-fade">{t.title}</span>
|
||||
<br />
|
||||
<span className="text-gradient-purple">{t.titleAccent}</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary sm:text-lg"
|
||||
>
|
||||
{t.subtitle}
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Approach + Quick facts */}
|
||||
<section className="relative w-full py-12 sm:py-16">
|
||||
<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.1)}
|
||||
className="grid grid-cols-1 gap-8 lg:grid-cols-3"
|
||||
>
|
||||
{/* Main text */}
|
||||
<motion.div variants={fadeUp} className="lg:col-span-2">
|
||||
<h2 className="mb-6 font-display text-3xl font-medium tracking-tight text-gradient-fade sm:text-4xl">
|
||||
{t.approachTitle}
|
||||
</h2>
|
||||
<div className="space-y-5 text-base leading-relaxed text-text-secondary">
|
||||
{t.approachParagraphs.map((p : ApproachParagraph, i : number) => {
|
||||
if (p.type === "text") {
|
||||
return <p key={i}>{p.content}</p>;
|
||||
}
|
||||
|
||||
if (p.type === "linkParagraph") {
|
||||
return (
|
||||
<p key={i}>
|
||||
{p.before}
|
||||
<Link
|
||||
href={`/${locale}${p.href}`}
|
||||
className="text-purple-300 underline"
|
||||
>
|
||||
{p.linkText}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Quick facts */}
|
||||
<motion.div variants={fadeUp}>
|
||||
<GlassCard className="h-fit" hover={false}>
|
||||
<h3 className="mb-5 font-mono text-xs uppercase tracking-widest text-text-secondary">
|
||||
{t.quickFactsTitle}
|
||||
</h3>
|
||||
<dl className="space-y-3.5 text-sm">
|
||||
{t.facts.map((fact, i) => {
|
||||
const Icon = factIcons[i] || MapPin;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-3 border-b border-border/40 pb-3 last:border-0 last:pb-0"
|
||||
>
|
||||
<dt className="flex items-center gap-2 text-text-secondary">
|
||||
<Icon className="h-3.5 w-3.5 text-purple-400/60" strokeWidth={1.5} />
|
||||
{fact.label}
|
||||
</dt>
|
||||
<dd className="font-mono text-text-primary">{fact.value}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Highlights */}
|
||||
<section className="relative w-full py-20 sm:py-24">
|
||||
<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.06)}
|
||||
className="mx-auto mb-14 max-w-3xl text-center"
|
||||
>
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-lg font-medium tracking-tight text-gradient-fade"
|
||||
>
|
||||
{t.highlightsTitle}
|
||||
</motion.h2>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.1)}
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||
>
|
||||
{t.highlights.map((item, i) => {
|
||||
const Icon = highlightIcons[i] || Sparkles;
|
||||
return (
|
||||
<motion.div key={i} variants={fadeUp} className="group">
|
||||
<GlassCard className="h-full">
|
||||
<div className="flex gap-5">
|
||||
<div className="flex h-12 w-12 shrink-0 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:shadow-glow-sm">
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-medium text-text-primary">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-text-secondary">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getDictionary, type Locale } from "@/lib/dictionaries";
|
||||
import CTA from "@/components/sections/CTA";
|
||||
import AboutContent from "./AboutContent";
|
||||
import { localeAlternates } from "@/lib/seo";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const isSR = locale === "sr";
|
||||
return {
|
||||
title: isSR ? "O meni" : "About",
|
||||
description: isSR
|
||||
? "Full-stack inženjer koji gradi, deployuje i hostuje na vlastitoj infrastrukturi."
|
||||
: "End-to-end engineer who builds, deploys, and hosts on self-managed infrastructure.",
|
||||
alternates: localeAlternates(locale, "/about"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AboutPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const dict = getDictionary(locale);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AboutContent locale={locale as Locale} dict={dict} />
|
||||
<CTA locale={locale as Locale} dict={dict} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { getDictionary } from "@/lib/dictionaries";
|
||||
import ContactForm from "@/components/sections/ContactForm";
|
||||
import { localeAlternates } from "@/lib/seo";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const isSR = locale === "sr";
|
||||
return {
|
||||
title: isSR ? "Kontakt" : "Contact",
|
||||
description: isSR
|
||||
? "Kontaktirajte me za vaš sljedeći projekat."
|
||||
: "Get in touch about your next project.",
|
||||
alternates: localeAlternates(locale, "/contact"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ContactPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const dict = getDictionary(locale);
|
||||
const t = dict.contactPage;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="relative isolate flex min-h-[40svh] items-center overflow-hidden pt-32">
|
||||
<div className="pointer-events-none absolute inset-0 grid-bg [mask-image:radial-gradient(ellipse_at_center,#000_30%,transparent_70%)] opacity-30" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<span className="mb-6 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>
|
||||
{t.badge}
|
||||
</span>
|
||||
|
||||
<h1 className="font-display text-display-xl font-medium tracking-tight">
|
||||
<span className="text-gradient-fade">{t.title}</span>
|
||||
<br />
|
||||
<span className="text-gradient-purple">{t.titleAccent}</span>
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary sm:text-lg">
|
||||
{t.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Form section */}
|
||||
<section className="relative w-full pb-24 pt-12 sm:pb-32">
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<ContactForm dict={dict} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Sora, JetBrains_Mono } from "next/font/google";
|
||||
import "../globals.css";
|
||||
import {
|
||||
locales,
|
||||
getDictionary,
|
||||
isValidLocale,
|
||||
defaultLocale,
|
||||
type Locale,
|
||||
} from "@/lib/dictionaries";
|
||||
import { SITE_URL, localeAlternates } from "@/lib/seo";
|
||||
import Navbar from "@/components/layout/Navbar";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import ScanlineOverlay from "@/components/ui/ScanlineOverlay";
|
||||
import LangBanner from "@/components/ui/LangBanner";
|
||||
|
||||
const geistSans = Geist({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const sora = Sora({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sora",
|
||||
display: "swap",
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
const jetbrains = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains",
|
||||
display: "swap",
|
||||
weight: ["400", "500"],
|
||||
});
|
||||
|
||||
// Pre-render both locale shells at build time
|
||||
export function generateStaticParams() {
|
||||
return locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
// Tell the browser the document is dark before any CSS/JS runs. This sets the
|
||||
// <meta name="color-scheme"> / theme-color in the SSR <head> so the initial
|
||||
// canvas (and mobile browser chrome) paints dark, never white.
|
||||
export const viewport: Viewport = {
|
||||
colorScheme: "dark",
|
||||
themeColor: "#0F1117",
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const isSR = locale === "sr";
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: isSR
|
||||
? "Name — Full-stack inženjering, od početka do kraja"
|
||||
: "Name — Full-stack engineering, end to end",
|
||||
template: "%s · Name",
|
||||
},
|
||||
description: isSR
|
||||
? "Premium full-stack razvoj, deployment i self-hosted infrastruktura."
|
||||
: "Premium full-stack development, deployment, and self-hosted infrastructure.",
|
||||
metadataBase: new URL(SITE_URL),
|
||||
|
||||
// Canonical — self-referencing within each locale.
|
||||
// Subpages override this with their own path via localeAlternates().
|
||||
alternates: localeAlternates(locale),
|
||||
|
||||
openGraph: {
|
||||
title: isSR
|
||||
? "Name — Full-stack inženjering"
|
||||
: "Name — Full-stack engineering, end to end",
|
||||
description: isSR
|
||||
? "Premium full-stack razvoj i self-hosted infrastruktura."
|
||||
: "Premium full-stack development and self-hosted infrastructure.",
|
||||
type: "website",
|
||||
locale: isSR ? "sr_RS" : "en_US",
|
||||
alternateLocale: isSR ? ["en_US"] : ["sr_RS"],
|
||||
siteName: "ksan.dev",
|
||||
url: `${SITE_URL}/${locale}`,
|
||||
},
|
||||
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: isSR ? "Name — Full-stack inženjering" : "Name — Full-stack engineering",
|
||||
},
|
||||
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale: rawLocale } = await params;
|
||||
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : defaultLocale;
|
||||
const dict = getDictionary(locale);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
className={`${geistSans.variable} ${sora.variable} ${jetbrains.variable}`}
|
||||
// Inline so it is present in the very first HTML bytes — applied before
|
||||
// the external stylesheet loads and before hydration. This is what kills
|
||||
// the one-frame white flash (esp. in dev, where CSS is JS-injected).
|
||||
style={{ colorScheme: "dark", backgroundColor: "#0F1117" }}
|
||||
>
|
||||
<body className="relative min-h-screen overflow-x-hidden">
|
||||
<ScanlineOverlay />
|
||||
<LangBanner locale={locale} dict={dict} />
|
||||
<Navbar locale={locale} dict={dict} />
|
||||
<main className="relative z-10">{children}</main>
|
||||
<Footer locale={locale} dict={dict} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { headers } from "next/headers";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import GlowButton from "@/components/ui/GlowButton";
|
||||
import { isValidLocale, defaultLocale } from "@/lib/dictionaries";
|
||||
|
||||
export default async function NotFound() {
|
||||
const headerList = await headers();
|
||||
const segment = headerList.get("x-locale") ?? "";
|
||||
const locale = isValidLocale(segment) ? segment : defaultLocale;
|
||||
const isSR = locale === "sr";
|
||||
|
||||
return (
|
||||
<section className="relative isolate flex min-h-[80svh] items-center overflow-hidden pt-24">
|
||||
<div className="pointer-events-none absolute inset-0 grid-bg [mask-image:radial-gradient(ellipse_at_center,#000_30%,transparent_70%)] opacity-30" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<div className="font-display text-display-2xl font-medium tracking-tight text-gradient-purple">
|
||||
404
|
||||
</div>
|
||||
<h1 className="mt-4 font-display text-2xl font-medium text-gradient-fade sm:text-3xl">
|
||||
{isSR ? "Stranica nije pronađena." : "Page not found."}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-md text-base leading-relaxed text-text-secondary">
|
||||
{isSR
|
||||
? "Stranica koju tražite ne postoji ili je premještena."
|
||||
: "The page you're looking for doesn't exist or has been moved."}
|
||||
</p>
|
||||
<div className="mt-10 flex justify-center">
|
||||
<GlowButton
|
||||
href={`/${locale}`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<ArrowRight className="h-4 w-4" />}
|
||||
>
|
||||
{isSR ? "Nazad na početnu" : "Back to home"}
|
||||
</GlowButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
export const alt = "ksan.dev — Full-stack engineering, end to end";
|
||||
|
||||
export default async function Image() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#1c0a3d",
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 50% 34%, #7c4dd6 0%, #5a2ca6 34%, #2e1257 72%, #190836 100%)",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{/* Kd monogram */}
|
||||
<svg
|
||||
width={300}
|
||||
height={300}
|
||||
viewBox="0 0 135.46666 135.46667"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* K stem */}
|
||||
<rect
|
||||
x={10.583333}
|
||||
y={8.20364}
|
||||
width={13.804729}
|
||||
height={118.32624}
|
||||
fill="#F5F7FF"
|
||||
/>
|
||||
{/* d ascender */}
|
||||
<rect
|
||||
x={112.60719}
|
||||
y={8.2020836}
|
||||
width={13.804729}
|
||||
height={118.32624}
|
||||
fill="#F5F7FF"
|
||||
/>
|
||||
{/* 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>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 36,
|
||||
fontSize: 46,
|
||||
fontWeight: 600,
|
||||
color: "rgba(255,255,255,0.95)",
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
www.ksan.dev
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getDictionary, type Locale } from "@/lib/dictionaries";
|
||||
import Hero from "@/components/sections/Hero";
|
||||
import Services from "@/components/sections/Services";
|
||||
import TechStack from "@/components/sections/TechStack";
|
||||
import ProjectsCarousel from "@/components/sections/ProjectsCarousel";
|
||||
import CTA from "@/components/sections/CTA";
|
||||
|
||||
export default async function HomePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const dict = getDictionary(locale);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero locale={locale as Locale} dict={dict} />
|
||||
<Services locale={locale as Locale} dict={dict} />
|
||||
<TechStack locale={locale as Locale} dict={dict} />
|
||||
<ProjectsCarousel locale={locale as Locale} dict={dict} featured />
|
||||
<CTA locale={locale as Locale} dict={dict} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, Check, Lock, Sparkles } from "lucide-react";
|
||||
import GlassCard from "@/components/ui/GlassCard";
|
||||
import GlowButton from "@/components/ui/GlowButton";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
import type { Locale } from "@/lib/dictionaries";
|
||||
import type { PlansDict } from "./pricing";
|
||||
|
||||
interface PlansContentProps {
|
||||
locale: Locale;
|
||||
plans: PlansDict;
|
||||
}
|
||||
|
||||
export default function PlansContent({ locale, plans }: PlansContentProps) {
|
||||
const t = plans;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="relative isolate flex min-h-[40svh] items-center overflow-hidden pt-32">
|
||||
<div className="pointer-events-none absolute inset-0 grid-bg [mask-image:radial-gradient(ellipse_at_center,#000_30%,transparent_70%)] opacity-30" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={stagger(0.1)}
|
||||
className="mx-auto 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"
|
||||
>
|
||||
<Lock className="h-3 w-3" strokeWidth={2} />
|
||||
{t.badge}
|
||||
</motion.span>
|
||||
|
||||
<motion.h1
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-xl font-medium tracking-tight"
|
||||
>
|
||||
<span className="text-gradient-fade">{t.title}</span>{" "}
|
||||
<span className="text-gradient-purple">{t.titleAccent}</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary sm:text-lg"
|
||||
>
|
||||
{t.subtitle}
|
||||
</motion.p>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-4 max-w-xl font-mono text-xs text-text-secondary/70"
|
||||
>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Project tiers */}
|
||||
<section className="relative w-full pb-12 pt-16 sm:pb-16">
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<motion.h2
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={fadeUp}
|
||||
className="mb-10 text-center font-display text-display-lg font-medium tracking-tight text-gradient-fade"
|
||||
>
|
||||
{t.tiersTitle}
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.1)}
|
||||
className="grid grid-cols-1 gap-5 lg:grid-cols-3"
|
||||
>
|
||||
{t.tiers.map((tier) => (
|
||||
<motion.div key={tier.name} variants={fadeUp} className="group h-full">
|
||||
<GlassCard
|
||||
variant={tier.highlighted ? "strong" : "default"}
|
||||
className={`relative flex h-full flex-col p-8 ${
|
||||
tier.highlighted
|
||||
? "border-purple-400/40 shadow-glow-md"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{tier.highlighted && (
|
||||
<span className="absolute -top-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-purple-400/40 bg-purple-500/20 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-purple-200 backdrop-blur-md">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t.popularBadge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h3 className="text-lg font-medium text-text-primary">
|
||||
{tier.name}
|
||||
</h3>
|
||||
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<span className="font-display text-3xl font-medium tracking-tight text-text-primary">
|
||||
{tier.price}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{tier.period}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm leading-relaxed text-text-secondary">
|
||||
{tier.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-2.5">
|
||||
{tier.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-2.5 text-sm">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-500/15 text-purple-300">
|
||||
<Check className="h-3 w-3" strokeWidth={2.5} />
|
||||
</span>
|
||||
<span className="text-text-secondary">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Care plans */}
|
||||
<section className="relative w-full py-12 sm:py-16">
|
||||
<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.06)}
|
||||
className="mx-auto mb-10 max-w-2xl text-center"
|
||||
>
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-lg font-medium tracking-tight text-gradient-fade"
|
||||
>
|
||||
{t.careTitle}
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mt-4 text-base leading-relaxed text-text-secondary"
|
||||
>
|
||||
{t.careSubtitle}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.1)}
|
||||
className="grid grid-cols-1 gap-5 sm:grid-cols-3"
|
||||
>
|
||||
{t.care.map((plan) => (
|
||||
<motion.div key={plan.name} variants={fadeUp} className="h-full">
|
||||
<GlassCard className="flex h-full flex-col p-6">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-base font-medium text-text-primary">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<span className="font-display text-xl font-medium tracking-tight text-purple-300">
|
||||
{plan.price}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-2.5 text-sm">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-500/15 text-purple-300">
|
||||
<Check className="h-3 w-3" strokeWidth={2.5} />
|
||||
</span>
|
||||
<span className="text-text-secondary">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Disclaimer + CTA */}
|
||||
<section className="relative w-full pb-24 pt-8 sm:pb-32">
|
||||
<div className="relative mx-auto w-full max-w-3xl px-6 text-center lg:px-8">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.08)}
|
||||
>
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="text-sm leading-relaxed text-text-secondary"
|
||||
>
|
||||
{t.disclaimer}
|
||||
</motion.p>
|
||||
<motion.div variants={fadeUp} className="mt-8 flex justify-center">
|
||||
<GlowButton
|
||||
href={`/${locale}/contact`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<ArrowRight className="h-4 w-4" />}
|
||||
>
|
||||
{t.cta}
|
||||
</GlowButton>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { locales, type Locale } from "@/lib/dictionaries";
|
||||
import { getPlanPage, getPlanPages, getPlansDict } from "./pricing";
|
||||
import PlansContent from "./PlansContent";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return locales.flatMap((locale) =>
|
||||
getPlanPages().map((page) => ({ locale, code: page.code })),
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const isSR = locale === "sr";
|
||||
return {
|
||||
title: isSR ? "Paketi i cijene" : "Plans & pricing",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
noarchive: true,
|
||||
nosnippet: true,
|
||||
googleBot: { index: false, follow: false },
|
||||
},
|
||||
alternates: { canonical: null },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PlansPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; code: string }>;
|
||||
}) {
|
||||
const { locale, code } = await params;
|
||||
|
||||
const page = getPlanPage(code);
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<PlansContent
|
||||
locale={locale as Locale}
|
||||
plans={getPlansDict(locale, page)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
export type Price = number | string;
|
||||
|
||||
export interface PlanPage {
|
||||
code: string;
|
||||
landing: Price;
|
||||
webApp: Price;
|
||||
care: [Price, Price, Price];
|
||||
}
|
||||
|
||||
export function getPlanPages(): PlanPage[] {
|
||||
const raw = process.env.PLANS_PAGES;
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(p): p is PlanPage =>
|
||||
!!p && typeof p.code === "string" && Array.isArray(p.care),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"PLANS_PAGES is not valid JSON — no pricing pages will render.",
|
||||
err,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlanPage(code: string): PlanPage | null {
|
||||
return getPlanPages().find((p) => p.code === code) ?? null;
|
||||
}
|
||||
|
||||
function euro(value: Price, isSR: boolean): string {
|
||||
if (typeof value !== "number") return value;
|
||||
const n = new Intl.NumberFormat(isSR ? "sr-RS" : "en-US", {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
return `€${n}`;
|
||||
}
|
||||
|
||||
function fromPrice(value: Price, isSR: boolean): string {
|
||||
if (typeof value !== "number") return value;
|
||||
return `${isSR ? "od " : "from "}${euro(value, isSR)}`;
|
||||
}
|
||||
|
||||
function carePrice(value: Price, isSR: boolean): string {
|
||||
if (typeof value !== "number") return value;
|
||||
return `${euro(value, isSR)}${isSR ? "/mj" : "/mo"}`;
|
||||
}
|
||||
|
||||
export function getPlansDict(locale: string, page: PlanPage) {
|
||||
const isSR = locale === "sr";
|
||||
|
||||
const landing = fromPrice(page.landing, isSR);
|
||||
const webApp = fromPrice(page.webApp, isSR);
|
||||
const care = page.care.map((c) => carePrice(c, isSR)) as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
|
||||
if (isSR) {
|
||||
return {
|
||||
badge: "Za klijente",
|
||||
title: "Paketi i",
|
||||
titleAccent: "cijene.",
|
||||
subtitle:
|
||||
"Okvirne početne cijene za najčešće tipove projekata. Svaki projekat dobija prilagođenu ponudu nakon kratkog razgovora — ova stranica služi da dobijete realan okvir.",
|
||||
tiersTitle: "Cijene projekata",
|
||||
popularBadge: "Najtraženije",
|
||||
tiers: [
|
||||
{
|
||||
name: "Landing / prezentacioni sajt",
|
||||
price: landing,
|
||||
period: "jednokratno",
|
||||
description:
|
||||
"Brz, SEO-spreman sajt za vaš biznis — dizajniran, izrađen, deployovan i hostovan.",
|
||||
features: [
|
||||
"Custom dizajn, responzivan na svim uređajima",
|
||||
"SEO podešavanje, analitika, kontakt forma",
|
||||
"Deploy sa SSL-om na upravljanoj infrastrukturi",
|
||||
"Isporuka za 1–2 sedmice",
|
||||
],
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: "Web aplikacija",
|
||||
price: webApp,
|
||||
period: "jednokratno",
|
||||
description:
|
||||
"Custom web aplikacija sa backendom, bazom podataka i admin panelom — prilagođena vašem načinu rada.",
|
||||
features: [
|
||||
"Frontend, backend i dizajn baze podataka",
|
||||
"Autentifikacija i upravljanje ulogama",
|
||||
"Admin panel i interni alati",
|
||||
"CI/CD, monitoring i backup uključeni",
|
||||
"Isporuka za 4–8 sedmica u zavisnosti od obima",
|
||||
],
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
name: "Custom platforma",
|
||||
price: "po dogovoru",
|
||||
period: "zajedničko planiranje",
|
||||
description:
|
||||
"Veći proizvodi: e-commerce, SaaS, mobilna + web aplikacija. Obim i cijena se definišu nakon analize.",
|
||||
features: [
|
||||
"Sve iz paketa Web aplikacija",
|
||||
"Mobilna aplikacija (React Native) po potrebi",
|
||||
"Plaćanja, integracije, eksterni API-ji",
|
||||
"Dugoročna razvojna saradnja",
|
||||
],
|
||||
highlighted: false,
|
||||
},
|
||||
],
|
||||
careTitle: "Mjesečni paketi održavanja",
|
||||
careSubtitle:
|
||||
"Opcioni mjesečni paketi nakon lansiranja, da vaš projekat ostane brz, siguran i dostupan.",
|
||||
care: [
|
||||
{
|
||||
name: "Hosting",
|
||||
price: care[0],
|
||||
features: [
|
||||
"Upravljani hosting sa SSL-om",
|
||||
"Dnevni backup",
|
||||
"Monitoring dostupnosti",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Standard",
|
||||
price: care[1],
|
||||
features: [
|
||||
"Sve iz paketa Hosting",
|
||||
"Izmjene sadržaja i manje dorade",
|
||||
"Sigurnosna ažuriranja",
|
||||
"Prioritetna email podrška",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Priority",
|
||||
price: care[2],
|
||||
features: [
|
||||
"Sve iz paketa Standard",
|
||||
"Mjesečni sati za razvoj funkcionalnosti",
|
||||
"Odgovor isti dan",
|
||||
"Pregledi performansi",
|
||||
],
|
||||
},
|
||||
],
|
||||
disclaimer:
|
||||
"Cijene su početne, ne fiksne ponude — konačna cijena zavisi od obima i potvrđuje se pisanom ponudom prije početka rada.",
|
||||
cta: "Razgovarajmo o projektu",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
badge: "For clients",
|
||||
title: "Plans &",
|
||||
titleAccent: "pricing.",
|
||||
subtitle:
|
||||
"Indicative starting prices for the most common project types. Every project gets a tailored quote after a short discovery call — this page is just to give you a realistic frame.",
|
||||
tiersTitle: "Project pricing",
|
||||
popularBadge: "Popular",
|
||||
tiers: [
|
||||
{
|
||||
name: "Landing / marketing site",
|
||||
price: landing,
|
||||
period: "one-time",
|
||||
description:
|
||||
"A fast, SEO-ready site for your business — designed, built, deployed, and hosted.",
|
||||
features: [
|
||||
"Custom design, responsive on all devices",
|
||||
"SEO setup, analytics, contact form",
|
||||
"Deployed with SSL on managed infrastructure",
|
||||
"Delivered in 1–2 weeks",
|
||||
],
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: "Web application",
|
||||
price: webApp,
|
||||
period: "one-time",
|
||||
description:
|
||||
"A custom web app with backend, database, and admin panel — built around your workflow.",
|
||||
features: [
|
||||
"Frontend, backend, and database design",
|
||||
"Authentication and role management",
|
||||
"Admin dashboard and internal tools",
|
||||
"CI/CD, monitoring, and backups included",
|
||||
"Delivered in 4–8 weeks depending on scope",
|
||||
],
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
name: "Custom platform",
|
||||
price: "custom quote",
|
||||
period: "scoped together",
|
||||
description:
|
||||
"Larger products: e-commerce, SaaS, mobile + web. Scoped and priced after discovery.",
|
||||
features: [
|
||||
"Everything in Web application",
|
||||
"Mobile app (React Native) if needed",
|
||||
"Payments, integrations, third-party APIs",
|
||||
"Long-term development partnership",
|
||||
],
|
||||
highlighted: false,
|
||||
},
|
||||
],
|
||||
careTitle: "Ongoing care plans",
|
||||
careSubtitle:
|
||||
"Optional monthly plans after launch, so your project stays fast, secure, and online.",
|
||||
care: [
|
||||
{
|
||||
name: "Hosting",
|
||||
price: care[0],
|
||||
features: [
|
||||
"Managed hosting with SSL",
|
||||
"Daily backups",
|
||||
"Uptime monitoring",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Standard",
|
||||
price: care[1],
|
||||
features: [
|
||||
"Everything in Hosting",
|
||||
"Content and minor updates",
|
||||
"Security and dependency updates",
|
||||
"Priority email support",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Priority",
|
||||
price: care[2],
|
||||
features: [
|
||||
"Everything in Standard",
|
||||
"Monthly feature-development hours",
|
||||
"Same-day response",
|
||||
"Performance reviews",
|
||||
],
|
||||
},
|
||||
],
|
||||
disclaimer:
|
||||
"Prices are starting points, not fixed quotes — final pricing depends on scope and is confirmed in a written proposal before any work begins.",
|
||||
cta: "Discuss your project",
|
||||
};
|
||||
}
|
||||
|
||||
export type PlansDict = ReturnType<typeof getPlansDict>;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { getDictionary, type Locale } from "@/lib/dictionaries";
|
||||
import ProjectsShowcase from "@/components/sections/ProjectsShowcase";
|
||||
import CTA from "@/components/sections/CTA";
|
||||
import { localeAlternates } from "@/lib/seo";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const isSR = locale === "sr";
|
||||
return {
|
||||
title: isSR ? "Projekti" : "Projects",
|
||||
description: isSR
|
||||
? "Pogledajte projekte koje sam razvio i deployovao."
|
||||
: "Browse the projects I've built and deployed.",
|
||||
alternates: localeAlternates(locale, "/projects"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const dict = getDictionary(locale);
|
||||
const t = dict.projectsPage;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="relative isolate flex min-h-[45svh] items-center overflow-hidden pt-32">
|
||||
<div className="pointer-events-none absolute inset-0 grid-bg [mask-image:radial-gradient(ellipse_at_center,#000_30%,transparent_70%)] opacity-30" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<span 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">
|
||||
{t.badge}
|
||||
</span>
|
||||
|
||||
<h1 className="font-display text-display-xl font-medium tracking-tight">
|
||||
<span className="text-gradient-fade">{t.title}</span>
|
||||
<br />
|
||||
<span className="text-gradient-purple">{t.titleAccent}</span>
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary sm:text-lg">
|
||||
{t.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Deployed client projects */}
|
||||
<ProjectsShowcase
|
||||
locale={locale as Locale}
|
||||
dict={dict}
|
||||
filter="client"
|
||||
sectionTitle={t.deployedTitle}
|
||||
/>
|
||||
|
||||
{/* Personal / featured projects */}
|
||||
<ProjectsShowcase
|
||||
locale={locale as Locale}
|
||||
dict={dict}
|
||||
filter="personal"
|
||||
sectionTitle={t.featuredTitle}
|
||||
/>
|
||||
|
||||
{/* Build with me CTA */}
|
||||
<CTA locale={locale as Locale} dict={dict} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Code2,
|
||||
Server,
|
||||
Smartphone,
|
||||
Wrench,
|
||||
MessageSquare,
|
||||
FileSearch,
|
||||
Rocket,
|
||||
HeartHandshake,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Clock,
|
||||
Users,
|
||||
Target,
|
||||
} 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, Locale } from "@/lib/dictionaries";
|
||||
|
||||
interface ServicesContentProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export default function ServicesContent({
|
||||
locale,
|
||||
dict,
|
||||
}: ServicesContentProps) {
|
||||
const t = dict.servicesPage;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ──── HERO ──── */}
|
||||
<section className="relative isolate flex min-h-[75svh] items-center overflow-hidden pt-32">
|
||||
<div className="pointer-events-none absolute inset-0 grid-bg [mask-image:radial-gradient(ellipse_at_center,#000_30%,transparent_70%)] opacity-30" />
|
||||
|
||||
{/* Floating orbs */}
|
||||
<motion.div
|
||||
className="pointer-events-none absolute left-[15%] top-[20%] h-[500px] w-[500px] rounded-full"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(139,92,246,0.4) 0%, transparent 70%)",
|
||||
filter: "blur(60px)",
|
||||
}}
|
||||
animate={{ x: [0, 30, -20, 0], y: [0, -25, 15, 0] }}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute right-[10%] top-[40%] h-[400px] w-[400px] rounded-full"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(167,139,250,0.25) 0%, transparent 70%)",
|
||||
filter: "blur(70px)",
|
||||
}}
|
||||
animate={{ x: [0, -40, 25, 0], y: [0, 30, -20, 0] }}
|
||||
transition={{ duration: 24, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={stagger(0.1)}
|
||||
className="mx-auto 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"
|
||||
>
|
||||
{t.badge}
|
||||
</motion.span>
|
||||
|
||||
<motion.h1
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-xl font-medium tracking-tight"
|
||||
>
|
||||
<span className="text-gradient-fade">{t.heroTitle}</span>
|
||||
<br />
|
||||
<span className="text-gradient-purple">{t.heroTitleAccent}</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary sm:text-lg"
|
||||
>
|
||||
{t.heroSubtitle}
|
||||
</motion.p>
|
||||
|
||||
<motion.div variants={fadeUp} className="mt-10 flex flex-wrap items-center justify-center gap-4">
|
||||
<GlowButton
|
||||
href={`/${locale}/contact`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<ArrowRight className="h-4 w-4" />}
|
||||
>
|
||||
{t.heroCta}
|
||||
</GlowButton>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ──── SERVICE OFFERINGS ──── */}
|
||||
<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.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"
|
||||
>
|
||||
{t.offeringsLabel}
|
||||
</motion.span>
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-lg font-medium tracking-tight"
|
||||
>
|
||||
<span className="text-gradient-fade">{t.offeringsTitle}</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary"
|
||||
>
|
||||
{t.offeringsSubtitle}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.1)}
|
||||
className="grid grid-cols-1 gap-5 md:grid-cols-2"
|
||||
>
|
||||
{t.offerings.map((offering, i) => {
|
||||
const icons = [Code2, Server, Smartphone, Wrench];
|
||||
const Icon = icons[i] || Code2;
|
||||
|
||||
return (
|
||||
<motion.div key={i} variants={fadeUp} className="group">
|
||||
<GlassCard className="h-full">
|
||||
<div className="absolute -right-16 -top-16 h-40 w-40 rounded-full bg-purple-500/0 blur-3xl transition-all duration-700 group-hover:bg-purple-500/20" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="mb-5 flex items-center gap-4">
|
||||
<div className="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:shadow-glow-sm">
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium text-text-primary">
|
||||
{offering.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="mb-6 text-sm leading-relaxed text-text-secondary">
|
||||
{offering.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{offering.includes.map((item, j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="flex items-start gap-2.5 text-sm"
|
||||
>
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-500/15 text-purple-300">
|
||||
<Check className="h-3 w-3" strokeWidth={2.5} />
|
||||
</span>
|
||||
<span className="text-text-secondary">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
{/*
|
||||
<section className="relative w-full py-24 sm:py-32">
|
||||
<div className="pointer-events-none absolute inset-0 grid-bg [mask-image:radial-gradient(ellipse_at_center,#000_20%,transparent_65%)] opacity-20" />
|
||||
|
||||
<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.1)}
|
||||
className="grid grid-cols-1 gap-8 lg:grid-cols-5"
|
||||
>
|
||||
<motion.div variants={fadeUp} className="lg:col-span-3">
|
||||
<span className="mb-4 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-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>
|
||||
{t.infraBadge}
|
||||
</span>
|
||||
|
||||
<h2 className="mt-4 font-display text-3xl font-medium tracking-tight text-gradient-fade sm:text-4xl">
|
||||
{t.infraTitle}
|
||||
</h2>
|
||||
|
||||
<div className="mt-6 space-y-4 text-base leading-relaxed text-text-secondary">
|
||||
{t.infraParagraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeUp} className="space-y-3 lg:col-span-2">
|
||||
{t.infraPoints.map((point, i) => {
|
||||
const pointIcons = [Shield, Truck, Clock];
|
||||
const PIcon = pointIcons[i] || Shield;
|
||||
return (
|
||||
<GlassCard key={i} className="flex items-start gap-4 py-5">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-emerald-400/20 bg-emerald-500/10 text-emerald-300">
|
||||
<PIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{point.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-text-secondary">
|
||||
{point.description}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
*/}
|
||||
|
||||
<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.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"
|
||||
>
|
||||
{t.processLabel}
|
||||
</motion.span>
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-lg font-medium tracking-tight"
|
||||
>
|
||||
<span className="text-gradient-fade">{t.processTitle}</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-6 max-w-xl text-base leading-relaxed text-text-secondary"
|
||||
>
|
||||
{t.processSubtitle}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.12)}
|
||||
className="relative grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-5"
|
||||
>
|
||||
{/* Connecting line — desktop only */}
|
||||
<div className="pointer-events-none absolute top-12 hidden h-px bg-gradient-to-r from-transparent via-purple-400/30 to-transparent lg:inset-x-16 lg:block" />
|
||||
|
||||
{t.processSteps.map((step, i) => {
|
||||
const stepIcons = [MessageSquare, FileSearch, Code2, Rocket, HeartHandshake];
|
||||
const SIcon = stepIcons[i] || Code2;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
variants={fadeUp}
|
||||
className="group relative text-center"
|
||||
>
|
||||
{/* Step number */}
|
||||
<div className="relative mx-auto mb-4 flex h-12 w-12 items-center justify-center">
|
||||
<div className="absolute inset-0 rounded-full border border-purple-400/30 bg-purple-500/10 transition-all duration-500 group-hover:border-purple-400/60 group-hover:bg-purple-500/20 group-hover:shadow-glow-sm" />
|
||||
<SIcon className="relative h-5 w-5 text-purple-300" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<div className="mb-1 font-mono text-[10px] uppercase tracking-widest text-purple-400/60">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium text-text-primary">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-xs leading-relaxed text-text-secondary">
|
||||
{step.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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-border/60 bg-surface/40 backdrop-blur-md"
|
||||
>
|
||||
<div className="absolute inset-0 grid-bg opacity-20 [mask-image:radial-gradient(ellipse_at_center,#000_20%,transparent_70%)]" />
|
||||
<div className="absolute -left-32 top-1/2 h-64 w-64 -translate-y-1/2 rounded-full bg-purple-500/10 blur-3xl" />
|
||||
|
||||
<div className="relative grid grid-cols-1 gap-8 px-8 py-12 sm:px-16 sm:py-16 lg:grid-cols-2 lg:gap-16">
|
||||
<motion.div variants={fadeUp}>
|
||||
<span 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">
|
||||
{t.pricingLabel}
|
||||
</span>
|
||||
<h2 className="mt-4 font-display text-3xl font-medium tracking-tight text-gradient-fade sm:text-4xl">
|
||||
{t.pricingTitle}
|
||||
</h2>
|
||||
<p className="mt-6 text-base leading-relaxed text-text-secondary">
|
||||
{t.pricingDescription}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeUp} className="space-y-4">
|
||||
{t.pricingPoints.map((point, i) => {
|
||||
const pIcons = [Target, Users, Clock];
|
||||
const PIcon = pIcons[i] || Target;
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-purple-400/20 bg-purple-500/10 text-purple-300">
|
||||
<PIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{point.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-text-secondary">
|
||||
{point.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pt-4">
|
||||
<GlowButton
|
||||
href={`/${locale}/contact`}
|
||||
variant="primary"
|
||||
size="md"
|
||||
icon={<ArrowRight className="h-4 w-4" />}
|
||||
>
|
||||
{t.pricingCta}
|
||||
</GlowButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getDictionary, type Locale } from "@/lib/dictionaries";
|
||||
import ServicesContent from "./ServicesContent";
|
||||
import CTA from "@/components/sections/CTA";
|
||||
import { localeAlternates } from "@/lib/seo";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const isSR = locale === "sr";
|
||||
return {
|
||||
title: isSR ? "Usluge" : "Services",
|
||||
description: isSR
|
||||
? "Full-stack web razvoj, self-hosted infrastruktura i deployment iz jednih ruku."
|
||||
: "Full-stack web development, self-hosted infrastructure, and deployment under one roof.",
|
||||
alternates: localeAlternates(locale, "/services"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ServicesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const dict = getDictionary(locale);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ServicesContent locale={locale as Locale} dict={dict} />
|
||||
<CTA locale={locale as Locale} dict={dict} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
// Validate environment variables at startup
|
||||
const GMAIL_USER = process.env.GMAIL_USER;
|
||||
const GMAIL_APP_PASSWORD = process.env.GMAIL_APP_PASSWORD;
|
||||
const CONTACT_TO_EMAIL = process.env.CONTACT_TO_EMAIL || GMAIL_USER;
|
||||
|
||||
// Field length limits — keeps abuse and mail-size problems in check
|
||||
const MAX_LENGTHS = { name: 100, email: 200, subject: 200, message: 5000 };
|
||||
|
||||
// Simple in-memory rate limit: max 5 messages per IP per 10 minutes.
|
||||
// Resets on server restart, which is fine for a portfolio contact form.
|
||||
const RATE_LIMIT = 5;
|
||||
const RATE_WINDOW_MS = 10 * 60 * 1000;
|
||||
const submissions = new Map<string, number[]>();
|
||||
|
||||
function isRateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const recent = (submissions.get(ip) || []).filter(
|
||||
(t) => now - t < RATE_WINDOW_MS,
|
||||
);
|
||||
if (recent.length >= RATE_LIMIT) return true;
|
||||
recent.push(now);
|
||||
submissions.set(ip, recent);
|
||||
// Prune old entries so the map doesn't grow unbounded
|
||||
if (submissions.size > 1000) {
|
||||
for (const [key, times] of submissions) {
|
||||
if (times.every((t) => now - t >= RATE_WINDOW_MS)) submissions.delete(key);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, email, subject, message, company } = body;
|
||||
|
||||
// Honeypot: real users never fill this hidden field. Pretend success
|
||||
// so bots don't learn they were filtered.
|
||||
if (company) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
"unknown";
|
||||
if (isRateLimited(ip)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many messages — please try again later" },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !email || !subject || !message) {
|
||||
return NextResponse.json(
|
||||
{ error: "All fields are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
for (const [field, max] of Object.entries(MAX_LENGTHS)) {
|
||||
const value = body[field];
|
||||
if (typeof value !== "string" || value.length > max) {
|
||||
return NextResponse.json(
|
||||
{ error: `Field "${field}" is too long (max ${max} characters)` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email address" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check environment
|
||||
if (!GMAIL_USER || !GMAIL_APP_PASSWORD) {
|
||||
console.error(
|
||||
"Missing GMAIL_USER or GMAIL_APP_PASSWORD environment variables",
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Server configuration error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create transporter
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: "gmail",
|
||||
auth: {
|
||||
user: GMAIL_USER,
|
||||
pass: GMAIL_APP_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Escape user input before interpolating into the HTML body
|
||||
const safe = {
|
||||
name: escapeHtml(name),
|
||||
email: escapeHtml(email),
|
||||
subject: escapeHtml(subject),
|
||||
message: escapeHtml(message),
|
||||
};
|
||||
|
||||
// Send email
|
||||
await transporter.sendMail({
|
||||
from: `"Portfolio Contact Form" <${GMAIL_USER}>`,
|
||||
to: CONTACT_TO_EMAIL,
|
||||
replyTo: email,
|
||||
subject: `[Ksan.dev contact] ${subject}`,
|
||||
text: `Name: ${name}\nEmail: ${email}\nSubject: ${subject}\n\nMessage:\n${message}`,
|
||||
html: `
|
||||
<!-- Dark wrapper: without an explicit dark background the theme's
|
||||
light text colors are unreadable on white inbox backgrounds -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#0F1117" style="background-color: #0F1117; padding: 32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width: 600px; width: 100%; font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 0 8px 20px 8px;">
|
||||
<span style="display: inline-block; border: 1px solid rgba(167, 139, 250, 0.4); background-color: #221A38; color: #C4B5FD; border-radius: 999px; padding: 6px 14px; font-size: 12px; letter-spacing: 1px; text-transform: uppercase;">
|
||||
ksan.dev contact form
|
||||
</span>
|
||||
<h1 style="margin: 16px 0 0 0; color: #F5F7FF; font-size: 22px; font-weight: 600; letter-spacing: -0.5px;">
|
||||
New message from your portfolio
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Sender card -->
|
||||
<tr>
|
||||
<td>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#1A1D29" style="background-color: #1A1D29; border: 1px solid #34384A; border-radius: 14px;">
|
||||
<tr>
|
||||
<td style="padding: 20px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td width="90" style="padding: 6px 0; color: #A1A8C3; font-size: 13px;">Name</td>
|
||||
<td style="padding: 6px 0; color: #F5F7FF; font-size: 14px; font-weight: 600;">${safe.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="90" style="padding: 6px 0; color: #A1A8C3; font-size: 13px;">Email</td>
|
||||
<td style="padding: 6px 0; font-size: 14px;">
|
||||
<a href="mailto:${safe.email}" style="color: #A78BFA; text-decoration: none;">${safe.email}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="90" style="padding: 6px 0; color: #A1A8C3; font-size: 13px;">Subject</td>
|
||||
<td style="padding: 6px 0; color: #F5F7FF; font-size: 14px;">${safe.subject}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Message card -->
|
||||
<tr>
|
||||
<td style="padding-top: 12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#1A1D29" style="background-color: #1A1D29; border: 1px solid #34384A; border-left: 3px solid #8B5CF6; border-radius: 14px;">
|
||||
<tr>
|
||||
<td style="padding: 20px 24px;">
|
||||
<p style="margin: 0 0 10px 0; color: #A1A8C3; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase;">
|
||||
Message
|
||||
</p>
|
||||
<p style="margin: 0; color: #F5F7FF; font-size: 15px; line-height: 1.7; white-space: pre-wrap;">${safe.message}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Reply button -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 24px 0 8px 0;">
|
||||
<a href="mailto:${safe.email}?subject=Re:%20${encodeURIComponent(subject)}" style="display: inline-block; background-color: #8B5CF6; color: #FFFFFF; font-size: 14px; font-weight: 600; text-decoration: none; border-radius: 12px; padding: 12px 28px;">
|
||||
Reply to ${safe.name} →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 16px;">
|
||||
<p style="margin: 0; color: #6B7290; font-size: 12px;">
|
||||
Sent from the contact form at ksan.dev
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to send message" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,225 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ───── THEME ───── */
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-background: #0F1117;
|
||||
--color-surface: #1A1D29;
|
||||
--color-elevated: #252A3A;
|
||||
--color-border: #34384A;
|
||||
|
||||
--color-text-primary: #F5F7FF;
|
||||
--color-text-secondary: #A1A8C3;
|
||||
--color-text-muted: #6B7290;
|
||||
|
||||
--color-purple-50: #F5F3FF;
|
||||
--color-purple-100: #EDE9FE;
|
||||
--color-purple-200: #DDD6FE;
|
||||
--color-purple-300: #C4B5FD;
|
||||
--color-purple-400: #A78BFA;
|
||||
--color-purple-500: #8B5CF6;
|
||||
--color-purple-600: #7C3AED;
|
||||
--color-purple-700: #6D28D9;
|
||||
--color-purple-800: #5B21B6;
|
||||
--color-purple-900: #4C1D95;
|
||||
|
||||
--color-accent: #C4B5FD;
|
||||
--color-error: #F43F5E;
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-display: var(--font-sora), system-ui, sans-serif;
|
||||
--font-mono: var(--font-jetbrains), monospace;
|
||||
|
||||
/* Font sizes */
|
||||
--text-display-2xl: clamp(3rem, 8vw, 6.5rem);
|
||||
--text-display-2xl--line-height: 1.02;
|
||||
--text-display-2xl--letter-spacing: -0.04em;
|
||||
|
||||
--text-display-xl: clamp(2.5rem, 6vw, 5rem);
|
||||
--text-display-xl--line-height: 1.05;
|
||||
--text-display-xl--letter-spacing: -0.035em;
|
||||
|
||||
--text-display-lg: clamp(2rem, 4.5vw, 3.5rem);
|
||||
--text-display-lg--line-height: 1.08;
|
||||
--text-display-lg--letter-spacing: -0.03em;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-glow-sm: 0 0 20px -2px rgba(139, 92, 246, 0.25);
|
||||
--shadow-glow-md: 0 0 40px -4px rgba(139, 92, 246, 0.35);
|
||||
--shadow-glow-lg: 0 0 80px -8px rgba(139, 92, 246, 0.45);
|
||||
--shadow-glow-xl: 0 0 120px -12px rgba(139, 92, 246, 0.5);
|
||||
--shadow-inner-glow: inset 0 0 30px -8px rgba(139, 92, 246, 0.3);
|
||||
--shadow-card: 0 1px 0 0 rgba(255, 255, 255, 0.04) inset,
|
||||
0 0 0 0.5px rgba(255, 255, 255, 0.06);
|
||||
--shadow-card-hover: 0 1px 0 0 rgba(255, 255, 255, 0.08) inset,
|
||||
0 0 0 0.5px rgba(167, 139, 250, 0.3),
|
||||
0 20px 60px -20px rgba(139, 92, 246, 0.4);
|
||||
|
||||
/* Animations */
|
||||
--animate-fade-up: fade-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
--animate-fade-in: fade-in 0.6s ease-out forwards;
|
||||
--animate-float: float 8s ease-in-out infinite;
|
||||
--animate-float-slow: float 14s ease-in-out infinite;
|
||||
--animate-scanline: scanline 8s linear infinite;
|
||||
--animate-pulse-glow: pulse-glow 4s ease-in-out infinite;
|
||||
--animate-shimmer: shimmer 2.5s ease-in-out infinite;
|
||||
--animate-grid-flow: grid-flow 20s linear infinite;
|
||||
}
|
||||
|
||||
/* ───── KEYFRAMES ───── */
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) translateX(0); }
|
||||
33% { transform: translateY(-15px) translateX(10px); }
|
||||
66% { transform: translateY(10px) translateX(-10px); }
|
||||
}
|
||||
|
||||
@keyframes scanline {
|
||||
from { transform: translateY(-100%); }
|
||||
to { transform: translateY(100vh); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { opacity: 0.4; filter: blur(40px); }
|
||||
50% { opacity: 0.7; filter: blur(60px); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@keyframes grid-flow {
|
||||
from { transform: translate(0, 0); }
|
||||
to { transform: translate(40px, 40px); }
|
||||
}
|
||||
|
||||
/* ───── BASE ───── */
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply antialiased bg-background;
|
||||
scroll-behavior: smooth;
|
||||
/* Make the root canvas dark so the first painted frame is never white. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-text-primary font-sans;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Decorative gradient as a composited fixed layer instead of
|
||||
`background-attachment: fixed`, which Firefox paints on a slower,
|
||||
non-composited path that flashes on first paint. The solid
|
||||
`bg-background` on html/body already covers the canvas, so this
|
||||
layer only adds the gradient — and never causes a white frame. */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(139, 92, 246, 0.12), transparent),
|
||||
linear-gradient(180deg, #0F1117 0%, #0A0C12 100%);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
color: #F5F7FF;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 10px; }
|
||||
::-webkit-scrollbar-track { background: #0F1117; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #34384A;
|
||||
border-radius: 5px;
|
||||
border: 2px solid #0F1117;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: #5B21B6; }
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-display tracking-tight;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───── UTILITIES ───── */
|
||||
|
||||
@utility glass {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.025) 0%,
|
||||
rgba(255, 255, 255, 0.01) 100%
|
||||
);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
@utility glass-strong {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(37, 42, 58, 0.6) 0%,
|
||||
rgba(26, 29, 41, 0.4) 100%
|
||||
);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@utility text-gradient-purple {
|
||||
background: linear-gradient(135deg, #C4B5FD 0%, #A78BFA 50%, #8B5CF6 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@utility text-gradient-fade {
|
||||
background: linear-gradient(180deg, #F5F7FF 0%, #A1A8C3 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@utility grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(139, 92, 246, 0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(139, 92, 246, 0.07) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
@utility scanline-bg {
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(139, 92, 246, 0.04) 2px,
|
||||
rgba(139, 92, 246, 0.04) 4px
|
||||
);
|
||||
}
|
||||
|
||||
@utility vignette {
|
||||
box-shadow: inset 0 0 200px 50px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ───── REDUCED MOTION ───── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg width="135.46666" height="135.46667" viewBox="0 0 135.46666 135.46667" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="135.46666" height="135.46667" rx="12" fill="#1b172d"/>
|
||||
<rect x="0.75" y="0.75" width="133.96666" height="133.96667" rx="11.25" fill="none" stroke="#a78bfa" stroke-opacity="0.25" stroke-width="1.5"/>
|
||||
<!-- K stem -->
|
||||
<rect x="10.583333" y="8.20364" width="13.804729" height="118.32624" fill="#f5f7ff"/>
|
||||
<!-- d ascender -->
|
||||
<rect x="112.60719" y="8.2020836" width="13.804729" height="118.32624" fill="#f5f7ff"/>
|
||||
<!-- 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>
|
||||
|
After Width: | Height: | Size: 877 B |
@@ -0,0 +1,16 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
// Don't index bare (non-locale) paths — they redirect
|
||||
disallow: ["/api/", "/_next/"],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { locales } from "@/lib/dictionaries";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
|
||||
// All public pages on the site (without locale prefix).
|
||||
// The private /plans/[code] page is deliberately excluded.
|
||||
const pages = [
|
||||
"", // homepage
|
||||
"/about",
|
||||
"/services",
|
||||
"/projects",
|
||||
"/contact",
|
||||
];
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const entries: MetadataRoute.Sitemap = [];
|
||||
|
||||
for (const page of pages) {
|
||||
for (const locale of locales) {
|
||||
const languages: Record<string, string> = {};
|
||||
for (const l of locales) {
|
||||
languages[l] = `${SITE_URL}/${l}${page}`;
|
||||
}
|
||||
// x-default points to the English version
|
||||
languages["x-default"] = `${SITE_URL}/en${page}`;
|
||||
|
||||
entries.push({
|
||||
url: `${SITE_URL}/${locale}${page}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: page === "" ? "weekly" : "monthly",
|
||||
priority: page === "" ? 1.0 : 0.8,
|
||||
alternates: {
|
||||
languages,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 267 KiB |
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Variants } from "framer-motion";
|
||||
|
||||
export const fadeUp: Variants = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.7, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
export const fadeIn: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 0.6, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
|
||||
export const scaleIn: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.96 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.7, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
export const stagger = (delay = 0.08): Variants => ({
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: delay,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const viewportConfig = {
|
||||
once: true,
|
||||
margin: "-100px",
|
||||
} as const;
|
||||
@@ -0,0 +1,503 @@
|
||||
export const locales = ["en", "sr"] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
export const defaultLocale: Locale = "en";
|
||||
|
||||
export function isValidLocale(value: string): value is Locale {
|
||||
return locales.includes(value as Locale);
|
||||
}
|
||||
|
||||
export const localeNames: Record<Locale, string> = { en: "English", sr: "Srpski" };
|
||||
export const localeFlags: Record<Locale, string> = { en: "🇬🇧", sr: "🇷🇸" };
|
||||
export const langToLocale: Record<string, Locale> = { en: "en", sr: "sr", bs: "sr", hr: "sr" };
|
||||
|
||||
export type Dictionary = typeof dictionaries.en;
|
||||
|
||||
// `order` controls position on the projects page (within its client/personal
|
||||
// section). `carouselOrder` controls position in the homepage carousel,
|
||||
// independently of type. Lower numbers come first. `inCarousel: false` hides
|
||||
// a project from the homepage carousel while keeping it on the projects page.
|
||||
const projectsMeta = {
|
||||
"security-consultancy": { order: 1, carouselOrder: 3, inCarousel: true, tags: ["React", "Node.js", "Express", "JavaScript"], live: true, type: "client" as const, url: "https://obzor.rs/", github: "", download: "", inProgress: false, screenshots: ["/site1.png"] as string[] },
|
||||
"ecommerce-platform": { order: 2, carouselOrder: 4, inCarousel: true, tags: ["React", "Express.js", "Docker", "CI/CD"], live: true, type: "client" as const, url: "https://kupitri.com", github: "", download: "", inProgress: false, screenshots: ["/site2.png"] as string[] },
|
||||
"mobile-companion": { order: 3, carouselOrder: 3, inCarousel: false, tags: ["Java", "Spring Boot", "React Native", "PostgreSQL"], live: false, type: "client" as const, url: "", github: "", download: "", inProgress: true, screenshots: ["/tba.png"] as string[] },
|
||||
"business-landing": { order: 4, carouselOrder: 4, inCarousel: false, tags: ["Next.js", "Tailwind CSS"], live: false, type: "client" as const, url: "", github: "", download: "", inProgress: true, screenshots: ["/tba.png"] as string[] },
|
||||
"etf-oglasi": { order: 1, carouselOrder: 1, inCarousel: true, tags: ["React Native", "Node.js"], live: false, type: "personal" as const, url: "", github: "https://git.ksan.dev/ksan/etf-oglasi", download: "https://git.ksan.dev/ksan/etf-oglasi/releases/tag/mobile-v6", inProgress: false, screenshots: ["/etfoglasi.png", "/etf2.png", "/etfoglasiapp.jpg", "/etf1.png"] as string[] },
|
||||
"ssh-portfolio": { order: 2, carouselOrder: 6, inCarousel: true, tags: ["Go", "Bubble Tea", "Wish", "SSH"], live: false, type: "personal" as const, url: "", github: "https://git.ksan.dev/ksan/ssh-portfolio", download: "", inProgress: true, screenshots: ["/sshksan.png"] as string[] },
|
||||
"botinator": { order: 3, carouselOrder: 5, inCarousel: true, tags: ["Java", "JDA", "Lavalink"], live: false, type: "personal" as const, url: "", github: "https://git.ksan.dev/ksan/Botinator", download: "", inProgress: false, screenshots: ["/botinator.png"] as string[] },
|
||||
"ksan-dev": { order: 4, carouselOrder: 2, inCarousel: true, tags: ["Next.js", "TypeScript", "Tailwind CSS"], live: true, type: "personal" as const, url: "https://ksan.dev", github: "https://git.ksan.dev/ksan/ksan.dev", download: "", inProgress: false, screenshots: ["/kd.svg"] as string[] },
|
||||
"gpufand": { order: 5, carouselOrder: 9, inCarousel: false, tags: ["C++", "Qt", "Linux", "NVIDIA"], live: false, type: "personal" as const, url: "", github: "https://git.ksan.dev/ksan/gpufand", download: "", inProgress: true, screenshots: ["/tba.png"] as string[] },
|
||||
"e-voting": { order: 6, carouselOrder: 10, inCarousel: false, tags: ["Java", "BouncyCastle", "JavaFX", "Gradle"], live: false, type: "personal" as const, url: "", github: "https://git.ksan.dev/ksan/e-voting", download: "", inProgress: false, screenshots: ["/evoting.png"] as string[] },
|
||||
};
|
||||
|
||||
export const dictionaries = {
|
||||
en: {
|
||||
nav: { features: "Features", projects: "Projects", services: "Services", about: "About", contact: "Contact" },
|
||||
hero: {
|
||||
badge: "Available for freelance & full-time",
|
||||
titleLine1: "Full-stack engineering,", titleLine2: "end to end.",
|
||||
subtitle: "From idea to production-ready software. I build and deploy end-to-end systems — including web applications, backend services, and APIs — into production.",
|
||||
cta: "Start a project", ctaSecondary: "See what I do",
|
||||
stats: { projects: "Projects shipped", clients: "Live client sites", infra: "Cloud & self-hosted systems", response: "Response time" },
|
||||
},
|
||||
services: {
|
||||
badge: "What I do",
|
||||
title: "Everything your project needs,",
|
||||
titleAccent: "from one person.",
|
||||
|
||||
subtitle:
|
||||
"Design, development, deployment, and maintenance — handled end-to-end. No unnecessary complexity, no handoffs, and minimal external dependencies.",
|
||||
|
||||
items: [
|
||||
{
|
||||
title: "Full-Stack Development",
|
||||
description:
|
||||
"Production-ready web applications built with modern technologies, focused on performance, maintainability, and fast iteration.",
|
||||
},
|
||||
|
||||
{
|
||||
title: "Backend & APIs",
|
||||
description:
|
||||
"Scalable services, authentication, payments, integrations, and business logic built for production.",
|
||||
},
|
||||
|
||||
{
|
||||
title: "Infrastructure & Deployment",
|
||||
description:
|
||||
"Cloud or self-managed deployments with Docker, CI/CD, monitoring, backups, SSL, and reliable release workflows.",
|
||||
},
|
||||
|
||||
{
|
||||
title: "Mobile Applications",
|
||||
description:
|
||||
"Cross-platform React Native apps that share business logic with your web platform.",
|
||||
},
|
||||
],
|
||||
},
|
||||
techStack: { badge: "Tech stack", title: "Tools I work with", titleAccent: "daily.", subtitle: "Languages, frameworks, and infrastructure I use to ship production software." },
|
||||
projects: {
|
||||
badge: "Work", title: "Featured projects", viewAll: "View all projects",
|
||||
clientWork: "Client", personal: "Personal", live: "Live", source: "Source", viewProject: "View", download: "Download", inProgress: "In progress",
|
||||
items: [
|
||||
{ slug: "security-consultancy", title: "Security Consultancy Website", description: "A modern website built for a professional security consultancy specializing in protecting confidential conversations and advising businesses on information security.", about: "A modern website built for a professional security consultancy specializing in protecting confidential conversations and advising businesses on information security. Designed to clearly present services, build trust, and make it easy for clients to get in touch.", ...projectsMeta["security-consultancy"] },
|
||||
{ slug: "ecommerce-platform", title: "E-Commerce Platform", description: "A full-stack e-commerce website built with React and Express.js, featuring product browsing, shopping cart management, and a streamlined checkout experience.", about: "A full-stack e-commerce website built with React and Express.js, featuring product browsing, shopping cart management, and a streamlined checkout experience. Containerized with Docker and deployed through a CI/CD pipeline for reliable updates.", ...projectsMeta["ecommerce-platform"] },
|
||||
{ slug: "botinator", title: "Botinator", description: "A hobby Discord bot for personal use. A small JDA bot with a few fun slash commands and music playback powered by Lavalink.", about: "A hobby Discord bot built for personal use with Java and JDA. Handles a handful of fun slash commands alongside music playback powered by Lavalink, running as a small self-hosted service.", ...projectsMeta["botinator"] },
|
||||
{ slug: "mobile-companion", title: "Client meeting manager", description: "A Java Spring Boot app for note-taking, scheduling, and tracking client meetings, with a React Native companion app.", about: "A Java Spring Boot backend paired with a React Native app for managing client relationships — take notes, schedule meetings, and track follow-ups in one place. Uses PostgreSQL for storage. Currently in progress.", ...projectsMeta["mobile-companion"] },
|
||||
{ slug: "ssh-portfolio", title: "SSH Portfolio", description: "A terminal-based portfolio you can explore over SSH — no browser required. Being updated with new stuff I've learned.", about: "A TUI portfolio built with Go and Bubble Tea, served through a Wish SSH server. Just `ssh` in from any terminal and navigate projects, skills, and contact info entirely with the keyboard — no browser, no JavaScript, no web server. Still being updated with new stuff I've learned along the way.", ...projectsMeta["ssh-portfolio"] },
|
||||
{ slug: "business-landing", title: "Business landing page", description: "A modern marketing site for a local company, built for speed and SEO with automatic SSL certificates.", about: "A modern marketing site for a local company. Built with Next.js and Tailwind CSS, optimised for performance and search visibility with automatic SSL certificates. Currently in progress.", ...projectsMeta["business-landing"] },
|
||||
{ slug: "etf-oglasi", title: "ETF Oglasi", description: "Classifieds app for students of the Faculty of Electrical Engineering. Follow specific courses and get push notifications for their updates.", about: "Classifieds app for students of the Faculty of Electrical Engineering. Follow specific courses and get push notifications for their updates. [More details coming soon.]", ...projectsMeta["etf-oglasi"] },
|
||||
{ slug: "ksan-dev", title: "ksan.dev", description: "My personal website and portfolio, built with Next.js.", about: "This site — a personal website and portfolio built with Next.js, TypeScript, and Tailwind CSS. Showcases my work and skills, self-hosted with automatic SSL and zero-downtime deploys.", ...projectsMeta["ksan-dev"] },
|
||||
{ slug: "gpufand", title: "GPUFand", description: "A Linux GPU fan control and monitoring tool with a secure background daemon and a Qt-based interface.", about: "An open-source Linux GPU fan control and monitoring tool, currently under active development. Runs as a secure background daemon paired with a Qt-based interface, and currently supports NVIDIA GPUs, with AMD and Intel support planned for future releases. Features customizable fan curves, real-time telemetry, and a modular architecture built for easy expansion.", ...projectsMeta["gpufand"] },
|
||||
{ slug: "e-voting", title: "E-Voting", description: "A secure e-voting application for creating and participating in elections. It uses certificates, encryption, and digital signatures to protect identity, privacy, and data integrity. ", about: "A university project: a secure e-voting application for creating and participating in elections. Uses certificates, encryption, and digital signatures to protect voter identity, privacy, and data integrity throughout the voting process. [Not for general use]", ...projectsMeta["e-voting"] },
|
||||
],
|
||||
},
|
||||
projectsPage: {
|
||||
badge: "Portfolio", title: "Projects I've built", titleAccent: "and deployed.",
|
||||
subtitle: "Production client work and personal projects focused on improving my development workflow and tools.",
|
||||
deployedTitle: "Deployed for clients", featuredTitle: "Personal & open source",
|
||||
},
|
||||
servicesPage: {
|
||||
badge: "Services",
|
||||
heroTitle: "I solve problems,",
|
||||
heroTitleAccent: "not just write code.",
|
||||
heroSubtitle: "You need software that works and a developer who takes responsibility for the entire system. I handle the full lifecycle — from first commit to production deployment — so you can focus on your business.",
|
||||
heroCta: "Discuss your project",
|
||||
|
||||
offeringsLabel: "What I offer",
|
||||
offeringsTitle: "Built for real business needs.",
|
||||
offeringsSubtitle: "Every service covers the full path from idea to production deployment and long-term maintenance. No handoffs, no gaps, no unnecessary middlemen.",
|
||||
offerings: [
|
||||
{
|
||||
title: "Web applications",
|
||||
description: " Custom web applications designed around your workflow — not generic templates adapted after the fact. I write the frontend, backend, and database layer, then deploy it on infrastructure I manage.",
|
||||
includes: [
|
||||
"Responsive, fast-loading interfaces",
|
||||
"Secure authentication and role management",
|
||||
"Admin dashboards and internal tools",
|
||||
"Database design, backups, and migrations",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Infrastructure & deployment",
|
||||
description: "I handle deployment, infrastructure, monitoring, and maintenance — whether your project runs on my own servers or trusted cloud providers. You work with a single technical partner responsible for both the application and the environment it runs in.",
|
||||
includes: [
|
||||
"Docker-based containerized deployment",
|
||||
"Cloud and self-hosted infrastructure options",
|
||||
"Automatic SSL and reverse proxy setup",
|
||||
"Monitoring, backups, and incident response",
|
||||
"Portable, vendor-independent infrastructure",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Mobile development",
|
||||
description: "Cross-platform mobile apps built with React Native, sharing the same backend and business logic as your web application. Shared business logic and consistent behavior across platforms.",
|
||||
includes: [
|
||||
"iOS and Android from a single codebase",
|
||||
"Shared API layer with your web app",
|
||||
"Push notifications and offline support",
|
||||
"App store submission and updates",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Ongoing support",
|
||||
description: "Software doesn't end at launch. I provide ongoing development, bug fixes, security updates, and feature additions — with a single point of contact with support from the same developer who built the system.",
|
||||
includes: [
|
||||
"Priority bug fixes and patches",
|
||||
"Feature development and iterations",
|
||||
"Security updates and dependency management",
|
||||
"Performance monitoring and optimization",
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
infraBadge: "Infrastructure I manage",
|
||||
infraTitle: "Your project runs on infrastructure I configure, deploy, and maintain.",
|
||||
infraParagraphs: [
|
||||
"Most developers write code and hand it off. I also handle the deployment environment the application runs on — whether that’s my own infrastructure or trusted cloud providers. Projects are deployed using Docker, routed through Traefik, and updated through automated deployment pipelines designed to minimize downtime.",
|
||||
"This gives clients a single technical contact who understands both the application and the infrastructure underneath it — without the usual back-and-forth between developers, hosting providers, and external DevOps contractors.",
|
||||
],
|
||||
infraPoints: [
|
||||
{ title: "End-to-end infrastructure management", description: "I handle deployment configuration, containers, SSL certificates, backups, monitoring, and maintenance. If something breaks, you contact the same person who built the system." },
|
||||
{ title: "Portable, vendor-independent deployments", description: "Applications are containerized and infrastructure is standardized. If you ever want to migrate to another provider or your own servers, the stack can be transferred without rebuilding the project from scratch." },
|
||||
{ title: "Reliable production environments", description: "Client projects are monitored, backed up, and maintained with stability in mind. The goal is simple: predictable deployments, fast recovery, and minimal downtime." },
|
||||
],
|
||||
|
||||
processLabel: "Process",
|
||||
processTitle: "How we work together.",
|
||||
processSubtitle: "A straightforward process with clear communication and defined milestones from start to launch. ",
|
||||
processSteps: [
|
||||
{ title: "Discovery", description: "We discuss your goals, users, workflows, and technical requirements to define the project scope and priorities." },
|
||||
{ title: "Proposal", description: "You receive a clear project outline with deliverables, timeline, pricing, and technical direction before development begins." },
|
||||
{ title: "Development", description: "I build in iterative stages with regular updates and feedback cycles, so you can review progress throughout the project." },
|
||||
{ title: "Launch", description: "I handle deployment, infrastructure configuration, monitoring, backups, and production readiness checks before release." },
|
||||
{ title: "Support", description: "After launch, I remain available for maintenance, updates, improvements, and long-term technical support." },
|
||||
],
|
||||
|
||||
pricingLabel: "Pricing",
|
||||
pricingTitle: "Transparent, scope-based pricing.",
|
||||
pricingDescription:
|
||||
"Every project is different, so pricing is based on scope, complexity, and long-term requirements. Before any work begins, you receive a detailed proposal with clear deliverables, timelines, and costs.",
|
||||
pricingPoints: [
|
||||
{
|
||||
title: "Tailored to the project",
|
||||
description:
|
||||
"A marketing website and a custom SaaS platform have very different requirements. Pricing reflects the actual scope and technical complexity of your project.",
|
||||
},
|
||||
{
|
||||
title: "Direct collaboration",
|
||||
description:
|
||||
"You work directly with the person designing, building, and deploying the system — with clear communication and no unnecessary layers.",
|
||||
},
|
||||
{
|
||||
title: "Flexible engagement",
|
||||
description:
|
||||
"Projects can be structured as fixed-scope builds, ongoing retainers, or long-term development partnerships depending on your needs.",
|
||||
},
|
||||
],
|
||||
pricingCta: "Request a project estimate",
|
||||
},
|
||||
aboutPage: {
|
||||
badge: "About",
|
||||
title: "Full-Stack",
|
||||
titleAccent: "developer.",
|
||||
subtitle:
|
||||
"Experienced across the entire software development process — from idea to production-ready product.",
|
||||
approachTitle: "My approach.",
|
||||
approachParagraphs: [
|
||||
{
|
||||
type: "text",
|
||||
content:
|
||||
"I build complete products — frontend, backend, databases, and the infrastructure they run on. When you work with me, you're collaborating directly with a developer familiar with every stage of the software development process, from planning to deployment.",
|
||||
},
|
||||
{
|
||||
type: "linkParagraph",
|
||||
before:
|
||||
"I maintain a home Linux server where I test projects, automate backups, and manage deployments. Client applications are hosted and maintained using Docker and Traefik on industry-standard cloud services. You can explore selected work on the ",
|
||||
linkText: "Projects page",
|
||||
href: "/projects",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
content:
|
||||
"I use AI-assisted development tools to accelerate prototyping, research, debugging, and repetitive tasks while maintaining a strong focus on code quality, stability, and long-term maintainability.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
content:
|
||||
"Currently studying Software Engineering at the Faculty of Electrical Engineering, University of Banja Luka, while continuously improving my skills in technologies such as Go and C#.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
content:
|
||||
"I'm always interested in ambitious ideas, technically challenging projects, and collaborations that create real value.",
|
||||
},
|
||||
],
|
||||
quickFactsTitle: "Quick facts",
|
||||
facts: [
|
||||
{ label: "Based in", value: "Banja Luka, BA" }, { label: "Server", value: "Home Linux" },
|
||||
{ label: "Languages", value: "EN / SR" }, { label: "Open to", value: "Freelance, full-time" },
|
||||
{ label: "Response", value: "< 24h" },
|
||||
],
|
||||
highlightsTitle: "What I bring.",
|
||||
highlights: [
|
||||
{
|
||||
title: "End-to-end ownership",
|
||||
description:
|
||||
"From design to deployment, I handle the entire stack — frontend, backend, and infrastructure — providing a single point of responsibility for the product.",
|
||||
},
|
||||
{
|
||||
title: "Security & reliability",
|
||||
description:
|
||||
"My focus is on keeping systems up to date, stable, and built following modern security best practices.",
|
||||
},
|
||||
{
|
||||
title: "Optimized for speed",
|
||||
description:
|
||||
"Fast-loading interfaces with optimized assets, efficient frontend code, and performance-focused architecture for a smooth user experience.",
|
||||
},
|
||||
{
|
||||
title: "Production-grade quality",
|
||||
description:
|
||||
"Type-safe code, automated testing where it adds value, CI/CD pipelines, and monitoring. Built with long-term maintainability in mind.",
|
||||
},
|
||||
],
|
||||
},
|
||||
contactPage: {
|
||||
badge: "Open for projects", title: "Let's build", titleAccent: "something good.",
|
||||
subtitle: "Tell me about your project. I'll get back to you within a day with next steps.",
|
||||
nameLabel: "Your name", namePlaceholder: "John Doe",
|
||||
emailLabel: "Email", emailPlaceholder: "john@example.com",
|
||||
subjectLabel: "Subject", subjectPlaceholder: "Project inquiry",
|
||||
messageLabel: "Message", messagePlaceholder: "Tell me about your project...",
|
||||
send: "Send message", sending: "Sending...", avgResponse: "Average response time: under 24h",
|
||||
successTitle: "Message sent.", successMessage: "Thanks for reaching out — I'll get back to you within 24 hours.", sendAnother: "Send another message",
|
||||
responseTime: "Response time", responseValue: "Usually within 24h",
|
||||
location: "Location", locationValue: "Banja Luka, Bosnia and Herzegovina",
|
||||
},
|
||||
cta: { title: "Have a project in mind?", titleAccent: "Let's build it together.", subtitle: "From a quick landing page to a full custom platform — I'd love to hear what you're working on. Usually reply within 24 hours.", contact: "Get in touch", email: "contact@ksan.dev" },
|
||||
ipInfo: { title: "Visitor info", region: "Region", city: "City", isp: "ISP", connection: "Connection", secure: "Secure", notSecure: "Not secure", loading: "Detecting...", error: "Could not detect" },
|
||||
langBanner: { message: "This page is also available in", switchTo: "Srpski", dismiss: "Stay in English" },
|
||||
footer: { product: "Product", company: "Company", connect: "Connect", rights: "Built & self-hosted by me", status: "Available for work" },
|
||||
},
|
||||
|
||||
sr: {
|
||||
nav: { features: "Vještine", projects: "Projekti", services: "Usluge", about: "O meni", contact: "Kontakt" },
|
||||
hero: {
|
||||
badge: "Dostupan za freelance i stalni posao",
|
||||
titleLine1: "Full-stack inženjering,", titleLine2: "end-to-end",
|
||||
subtitle: "Od ideje do produkcijski spremnog softvera. Razvijam i deploy-ujem end-to-end sisteme — od web aplikacija i backend servisa do API-ja — u produkcijsko okruženje.",
|
||||
cta: "Započni projekat", ctaSecondary: "Pogledaj usluge",
|
||||
stats: { projects: "Projekata pokrenuto", clients: "Aktivnih klijentskih sajtova", infra: "Hybrid infrastruktura (self-hosted + cloud)", response: "Vrijeme odgovora" },
|
||||
},
|
||||
services: {
|
||||
badge: "Šta radim",
|
||||
title: "Sve što vaš projekat treba,",
|
||||
titleAccent: "od jedne osobe.",
|
||||
|
||||
subtitle:
|
||||
"Dizajn, razvoj, deployment i održavanje — sve u jednom procesu. Bez agencija, bez posrednika i sa minimalnom zavisnošću od eksternih platformi.",
|
||||
|
||||
items: [
|
||||
{
|
||||
title: "Full-Stack razvoj",
|
||||
description:
|
||||
"Produkcijski spremne web aplikacije izrađene modernim tehnologijama, sa fokusom na performanse, održivost i brzu isporuku.",
|
||||
},
|
||||
|
||||
{
|
||||
title: "Backend i API-jevi",
|
||||
description:
|
||||
"Skalabilni servisi, autentifikacija, plaćanja, integracije i poslovna logika spremni za produkciju.",
|
||||
},
|
||||
|
||||
{
|
||||
title: "Infrastruktura i Deployment",
|
||||
description:
|
||||
"Cloud ili samostalni deployment sa Docker-om, CI/CD-om, monitoringom, backup-om, SSL-om i pouzdanim procesima isporuke.",
|
||||
},
|
||||
|
||||
{
|
||||
title: "Mobilne aplikacije",
|
||||
description:
|
||||
"React Native aplikacije za više platformi koje dijele poslovnu logiku sa vašom web platformom.",
|
||||
},
|
||||
],
|
||||
},
|
||||
techStack: { badge: "Tech stack", title: "Alati sa kojima radim", titleAccent: "svaki dan.", subtitle: "Jezici, frameworkovi i infrastruktura koju koristim za isporuku produkcijskog softvera." },
|
||||
projects: {
|
||||
badge: "Radovi", title: "Istaknuti projekti", viewAll: "Pogledaj sve projekte",
|
||||
clientWork: "Klijent", personal: "Lični", live: "Uživo", source: "Kod", viewProject: "Pogledaj", download: "Preuzmi", inProgress: "U izradi",
|
||||
items: [
|
||||
{ slug: "security-consultancy", title: "Sajt za sigurnosno savjetovanje", description: "Moderan sajt napravljen za profesionalnu sigurnosnu konsultantsku firmu specijalizovanu za zaštitu povjerljivih razgovora i savjetovanje biznisa o informacionoj sigurnosti.", about: "Moderan sajt napravljen za profesionalnu sigurnosnu konsultantsku firmu specijalizovanu za zaštitu povjerljivih razgovora i savjetovanje biznisa o informacionoj sigurnosti. Napravljen da jasno predstavi usluge, izgradi povjerenje i olakša klijentima da stupe u kontakt.", ...projectsMeta["security-consultancy"] },
|
||||
{ slug: "ecommerce-platform", title: "E-Commerce platforma", description: "Full-stack e-commerce sajt napravljen u React-u i Express.js-u, sa pregledom proizvoda, upravljanjem korpom i pojednostavljenim checkout procesom.", about: "Full-stack e-commerce sajt napravljen u React-u i Express.js-u, sa pregledom proizvoda, upravljanjem korpom i pojednostavljenim checkout procesom. Kontejnerizovan sa Docker-om i deployovan kroz CI/CD pipeline za pouzdana ažuriranja.", ...projectsMeta["ecommerce-platform"] },
|
||||
{ slug: "botinator", title: "Botinator", description: "Hobi Discord bot za ličnu upotrebu. Mali JDA bot sa nekoliko zabavnih slash komandi i puštanjem muzike preko Lavalink-a.", about: "Hobi Discord bot napravljen za ličnu upotrebu u Javi i JDA-u. Ima nekoliko zabavnih slash komandi i puštanje muzike preko Lavalink-a, radi kao mali self-hosted servis.", ...projectsMeta["botinator"] },
|
||||
{ slug: "mobile-companion", title: "Menadžer klijentskih sastanaka", description: "Java Spring Boot aplikacija za beleške, zakazivanje i praćenje sastanaka sa klijentima, uz React Native prateću aplikaciju.", about: "Java Spring Boot backend uparen sa React Native aplikacijom za upravljanje odnosima sa klijentima — beleške, zakazivanje sastanaka i praćenje follow-up-ova na jednom mestu. Koristi PostgreSQL za skladištenje podataka. Trenutno u izradi.", ...projectsMeta["mobile-companion"] },
|
||||
{ slug: "ssh-portfolio", title: "SSH Portfolio", description: "Terminalski portfolio koji možeš istražiti preko SSH-a — bez potrebe za browserom. Trenutno se ažurira novim stvarima koje sam naučio.", about: "TUI portfolio napravljen u Go-u i Bubble Tea-u, servovan kroz Wish SSH server. Samo se konektuj preko `ssh` iz bilo kog terminala i pregledaj projekte, vještine i kontakt informacije potpuno preko tastature — bez browsera, bez JavaScript-a, bez web servera. I dalje se ažurira novim stvarima koje sam usput naučio.", ...projectsMeta["ssh-portfolio"] },
|
||||
{ slug: "business-landing", title: "Poslovna landing stranica", description: "Moderan marketing sajt za lokalnu kompaniju, napravljen za brzinu i SEO, sa automatskim SSL sertifikatima.", about: "Moderan marketing sajt za lokalnu kompaniju. Napravljen u Next.js-u i Tailwind CSS-u, optimizovan za performanse i vidljivost u pretraživačima, sa automatskim SSL sertifikatima. Trenutno u izradi.", ...projectsMeta["business-landing"] },
|
||||
{ slug: "etf-oglasi", title: "ETF Oglasi", description: "Aplikacija za oglase namijenjena studentima Elektrotehničkog fakulteta. Prati specificne predmete, i dobijaj push notificakije za njihove novosti.", about: "Aplikacija za oglase namijenjena studentima Elektrotehničkog fakulteta. Prati specificne predmete, i dobijaj push notificakije za njihove novosti. [Više detalja uskoro]", ...projectsMeta["etf-oglasi"] },
|
||||
{ slug: "ksan-dev", title: "ksan.dev", description: "Moj lični sajt i portfolio, napravljen u Next.js-u.", about: "Ovaj sajt — lični sajt i portfolio napravljen u Next.js-u, TypeScript-u i Tailwind CSS-u. Prikazuje moje radove i vještine, self-hostovan sa automatskim SSL-om i zero-downtime deployevima.", ...projectsMeta["ksan-dev"] },
|
||||
{ slug: "gpufand", title: "GPUFand", description: "Alat za kontrolu i monitoring GPU ventilatora na Linuxu, sa sigurnim background daemonom i Qt interfejsom.", about: "Open-source alat za kontrolu i monitoring GPU ventilatora na Linuxu, trenutno u aktivnom razvoju. Radi kao siguran background daemon uparen sa Qt interfejsom, i trenutno podržava NVIDIA grafičke kartice, sa AMD i Intel podrškom planiranom za buduća izdanja. Ima podesive fan krive, telemetriju u realnom vremenu i modularnu arhitekturu napravljenu za lako proširivanje.", ...projectsMeta["gpufand"] },
|
||||
{ slug: "e-voting", title: "E-Voting", description: "Sigurna e-voting aplikacija za kreiranje i učešće u izborima. Koristi sertifikate, enkripciju i digitalne potpise za zaštitu identiteta, privatnosti i integriteta podataka.", about: "Univerzitetski projekat: sigurna e-voting aplikacija za kreiranje i učešće u izborima. Koristi sertifikate, enkripciju i digitalne potpise za zaštitu identiteta glasača, privatnosti i integriteta podataka tokom cijelog procesa glasanja.", ...projectsMeta["e-voting"] },
|
||||
],
|
||||
},
|
||||
projectsPage: {
|
||||
badge: "Portfolio", title: "Projekti koje sam napravio", titleAccent: "i deployovao.",
|
||||
subtitle: "Produkcijski klijentski projekti i lični projekti koje razvijam za unapređenje svog development workflow-a i alata.",
|
||||
deployedTitle: "Deployovano za klijente", featuredTitle: "Lični i open source",
|
||||
},
|
||||
servicesPage: {
|
||||
badge: "Usluge",
|
||||
heroTitle: "Rješavam probleme,",
|
||||
heroTitleAccent: "ne samo pišem kod.",
|
||||
heroSubtitle: "Trebate softver koji radi i osobu koji preuzima odgovornost za cijeli sistem. Vodim kompletan proces — od prve ideje do produkcijskog deploya — kako biste se mogli fokusirati na svoj posao.",
|
||||
heroCta: "Razgovarajmo o projektu",
|
||||
|
||||
offeringsLabel: "Šta nudim",
|
||||
offeringsTitle: "Napravljeno za stvarne poslovne potrebe.",
|
||||
offeringsSubtitle: "Svaka usluga pokriva puni put od ideje do gotovog proizvoda. Bez prebacivanja odgovornosti, bez praznina i bez nepotrebnih posrednika.",
|
||||
offerings: [
|
||||
{
|
||||
title: "Web aplikacije",
|
||||
description: "Custom web aplikacije prilagođene vašem načinu rada — ne generički šabloni prilagođeni naknadno. Razvijam frontend, backend i bazu podataka, a zatim aplikaciju postavljam na infrastrukturu kojom upravljam.",
|
||||
includes: [
|
||||
"Responzivni, brzi interfejsi",
|
||||
"Sigurna autentifikacija i upravljanje ulogama",
|
||||
"Admin panel i interni alati",
|
||||
"Dizajn baze podataka, backupi i migracije",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Infrastruktura i deployment",
|
||||
description: "Brinem o deployu, infrastrukturi, monitoringu i održavanju — bilo da projekat radi na vlastitim serverima ili na cloud platformama. Radite sa jednom tehničkom osobom koja je odgovorna i za aplikaciju i za sistem na kojem radi.",
|
||||
includes: [
|
||||
"Docker kontejnerizovan deployment",
|
||||
"Cloud i vlastite server opcije",
|
||||
"Automatski SSL i reverse proxy setup",
|
||||
"Monitoring, backupi i reakcija na incidente",
|
||||
"Prenosiva, vendor-independent infrastruktura",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Mobilni razvoj",
|
||||
description: "Cross-platform mobilne aplikacije napravljene u React Native, koje dijele istu poslovnu logiku kao web aplikacija. Konzistentno ponašanje na svim platformama.",
|
||||
includes: [
|
||||
"iOS i Android iz jedne baze koda",
|
||||
"Zajednički API sloj sa vašom web aplikacijom",
|
||||
"Push notifikacije i offline podrška",
|
||||
"Objavljivanje i ažuriranje aplikacija",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Kontinuirana podrška",
|
||||
description: "Softver ne završava sa lansiranjem. Pružam kontinuirani razvoj, popravke grešaka, sigurnosna ažuriranja i dodavanje funkcionalnosti — sa jednom osobom koja već poznaje sistem.",
|
||||
includes: [
|
||||
"Prioritetne popravke grešaka",
|
||||
"Razvoj novih funkcionalnosti i iteracije",
|
||||
"Sigurnosna ažuriranja i upravljanje zavisnostima",
|
||||
"Monitoring performansi i optimizacija",
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
infraBadge: "Infrastruktura koju pokrećem",
|
||||
infraTitle: "Vaš projekat radi na infrastrukturi koju ja konfigurišem, deployujem i održavam.",
|
||||
infraParagraphs: [
|
||||
"Većina developera napiše kod i preda ga dalje. Ja vodim računa i o okruženju u kojem aplikacija radi — bilo da je to moja infrastruktura ili pouzdani cloud provajderi. Projekti se deployuju kroz Docker, rutiraju kroz Traefik i ažuriraju kroz automatizovane pipeline-ove dizajnirane da minimizuju downtime.",
|
||||
"Time klijenti dobijaju jednog tehničkog kontakta koji razumije i aplikaciju i infrastrukturu na kojoj radi — bez uobičajenog prebacivanja između developera, hosting provajdera i eksternih DevOps saradnika.",
|
||||
],
|
||||
infraPoints: [
|
||||
{ title: "Kompletno upravljanje infrastrukturom", description: "Vodim računa o deploy konfiguraciji, kontejnerima, SSL sertifikatima, backupima, monitoringu i održavanju. Ako nešto zakaže, kontaktirate istu osobu koja je izgradila sistem." },
|
||||
{ title: "Prenosivi, vendor-independent deployment", description: "Sve je kontejnerizovano i infrastruktura je standardizovana. Ako ikada poželite prebaciti na drugog provajdera ili vlastite servere, cijeli stack se prenosi bez ponovne izrade projekta." },
|
||||
{ title: "Pouzdana produkcijska okruženja", description: "Klijentski projekti se prate, backupuju i održavaju sa fokusom na stabilnost. Cilj je jednostavan: predvidljivi deployovi, brz oporavak i minimalan downtime." },
|
||||
],
|
||||
|
||||
processLabel: "Proces",
|
||||
processTitle: "Kako radimo zajedno.",
|
||||
processSubtitle: "Jednostavan proces sa jasnom komunikacijom i definisanim fazama od početka do produkcije.",
|
||||
processSteps: [
|
||||
{ title: "Analiza", description: "Razgovaramo o vašim ciljevima, korisnicima, workflow-u i tehničkim zahtjevima kako bismo definisali obim i prioritete." },
|
||||
{ title: "Ponuda", description: "Dobijate jasan prijedlog sa funkcionalnostima, rokovima i cijenom prije početka razvoja." },
|
||||
{ title: "Razvoj", description: "Razvoj se odvija kroz iteracije uz redovne povratne informacije i pregled napretka." },
|
||||
{ title: "Lansiranje", description: "Aplikaciju postavljam u produkciju, konfiguriram monitoring i backupe, te provjeravam stabilnost sistema prije puštanja." },
|
||||
{ title: "Podrška", description: "Nakon lansiranja ostajem dostupan za održavanje, dorade i dalji razvoj sistema." },
|
||||
],
|
||||
|
||||
pricingLabel: "Cijene",
|
||||
pricingTitle: "Transparentne cijene bazirane na obimu.",
|
||||
pricingDescription: "Svaki projekat je drugačiji, zato se cijena formira prema obimu, kompleksnosti i dugoročnim zahtjevima. Prije početka dobijate jasan prijedlog sa definisanim funkcionalnostima, rokovima i troškovima.",
|
||||
pricingPoints: [
|
||||
{ title: "Prilagođeno projektu", description: "Marketing sajt i kompleksna SaaS platforma nemaju istu cijenu. Ponuda odražava stvarni obim i tehničku kompleksnost." },
|
||||
{ title: "Direktna saradnja", description: "Radite direktno sa osobom koja dizajnira, razvija i deploya sistem — bez nepotrebnih posrednika." },
|
||||
{ title: "Fleksibilan angažman", description: "Projekti mogu biti jednokratni, mjesečni ili dugoročna saradnja, u zavisnosti od potreba." },
|
||||
],
|
||||
pricingCta: "Zatražite besplatnu procjenu",
|
||||
},
|
||||
aboutPage: {
|
||||
badge: "O meni", title: "Full-Stack", titleAccent: "developer.",
|
||||
subtitle: "Upoznat sa procesom od početka do kraja. Od ideje do gotovog proizvoda.",
|
||||
approachTitle: "Moj pristup.",
|
||||
approachParagraphs: [
|
||||
{
|
||||
type: "text",
|
||||
content:
|
||||
"Pravim kompletne proizvode — frontend, backend, bazu podataka i server na kojem sve radi. Kada me angažujete, možete očekivati da će mi vaš projekat biti prioritet i povjerenje da sam upoznat sa svim vještinama koje su potrebne za realizaciju vašeg projekta, od početka do kraja.",
|
||||
},
|
||||
{
|
||||
type: "linkParagraph",
|
||||
before:
|
||||
"Imam kućni Linux server na kojem testiram sve projekte i vršim backup podataka. Hostujem i održavam klijentske sajtove koristeći Docker i Traefik na third party servisima koji su industrijski standard. Već realizovane projekte možete pronaći na stranici ",
|
||||
linkText: "Projekti",
|
||||
href: "/projects",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
content:
|
||||
"Upoznat sam sa AI alatima za bržu izradu prototipa i efikasniji razvoj aplikacija. Pošto sam već upoznat sa kompletnim procesom razvoja softvera, mogu garantovati kvalitetan i stabilan proizvod.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
content:
|
||||
"Kako bih dodatno unaprijedio svoje znanje, pohađam smjer Softversko inženjerstvo na Elektrotehničkom fakultetu Univerziteta u Banjoj Luci. Pored toga produbljujem svoje vještine u Go i C#.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
content:
|
||||
"Uvijek tražim zanimljive ideje i projekte za saradnju i razvoj.",
|
||||
},
|
||||
],
|
||||
quickFactsTitle: "Brze činjenice",
|
||||
facts: [
|
||||
{ label: "Lokacija", value: "Banja Luka, BA" }, { label: "Server", value: "Kućni Linux" },
|
||||
{ label: "Jezici", value: "EN / SR" }, { label: "Otvoren za", value: "Freelance, puno radno" },
|
||||
{ label: "Odgovor", value: "< 24h" },
|
||||
],
|
||||
highlightsTitle: "Šta donosim.",
|
||||
highlights: [
|
||||
{ title: "End-to-end vlasništvo", description: "Od ideje do gotovog proizvoda. Radite s jednom osobom koja razumije cijeli stack i sve zahtjeve projekta, bez potrebe za posrednicima ili dijeljenjem odgovornosti." },
|
||||
{ title: "Sigurnost i Pouzdanost", description: "Fokus na stabilne i sigurne sisteme koji su redovno ažurirani i prate moderne sigurnosne standarde i najbolje prakse." },
|
||||
{ title: "Optimizovano za brzinu", description: "Brzi i responzivni interfejsi, optimizovani resursi i efikasan frontend kod, uz arhitekturu fokusiranu na performanse i glatko korisničko iskustvo." },
|
||||
{ title: "Kvalitet izrade", description: "Type-safe kod, automatsko testiranje, CI/CD pipeline, monitoring. Fokus na dugoročno održive sisteme, ne kratkoročna rješenja." },
|
||||
],
|
||||
},
|
||||
contactPage: {
|
||||
badge: "Otvoren za projekte", title: "Hajde da napravimo", titleAccent: "nešto dobro.",
|
||||
subtitle: "Ispričajte mi o svom projektu. Javiću vam se u roku od jednog dana sa sljedećim koracima.",
|
||||
nameLabel: "Vaše ime", namePlaceholder: "Marko Marković",
|
||||
emailLabel: "Email", emailPlaceholder: "marko@primjer.com",
|
||||
subjectLabel: "Predmet", subjectPlaceholder: "Upit za projekat",
|
||||
messageLabel: "Poruka", messagePlaceholder: "Ispričajte mi o svom projektu...",
|
||||
send: "Pošalji poruku", sending: "Slanje...", avgResponse: "Prosječno vrijeme odgovora: ispod 24h",
|
||||
successTitle: "Poruka poslata.", successMessage: "Hvala na poruci — javiću vam se u roku od 24 sata.", sendAnother: "Pošalji novu poruku",
|
||||
responseTime: "Vrijeme odgovora", responseValue: "Obično ispod 24h",
|
||||
location: "Lokacija", locationValue: "Banja Luka, Bosna i Hercegovina",
|
||||
},
|
||||
cta: { title: "Imate projekat o kojem razmišljate?", titleAccent: "Realizujmo ga zajedno", subtitle: "Od osnovne stranice do potpuno custom platforme — volio bih da čujem na čemu radite.", contact: "Kontaktirajte me", email: "contact@ksan.dev" },
|
||||
ipInfo: { title: "Info o posjetiocu", region: "Regija", city: "Grad", isp: "ISP", connection: "Konekcija", secure: "Sigurna", notSecure: "Nije sigurna", loading: "Detektovanje...", error: "Nije moguće detektovati" },
|
||||
langBanner: { message: "This page is also available in", switchTo: "English", dismiss: "Ostani na srpskom" },
|
||||
footer: { product: "Proizvod", company: "Kompanija", connect: "Kontakt", rights: "Napravljeno i self-hostovano", status: "Dostupan za posao" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function getDictionary(locale: string): Dictionary {
|
||||
if (isValidLocale(locale)) return <Dictionary>dictionaries[locale];
|
||||
return <Dictionary>dictionaries[defaultLocale];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { locales, defaultLocale } from "@/lib/dictionaries";
|
||||
|
||||
export const SITE_URL = "https://ksan.dev";
|
||||
|
||||
// Canonical + hreflang alternates for a page, e.g. localeAlternates("sr", "/about").
|
||||
export function localeAlternates(locale: string, path: string = "") {
|
||||
const languages: Record<string, string> = {};
|
||||
for (const l of locales) {
|
||||
languages[l] = `${SITE_URL}/${l}${path}`;
|
||||
}
|
||||
languages["x-default"] = `${SITE_URL}/${defaultLocale}${path}`;
|
||||
|
||||
return {
|
||||
canonical: `${SITE_URL}/${locale}${path}`,
|
||||
languages,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function countryCodeToEmoji(code: string): string {
|
||||
return [...code.toUpperCase()]
|
||||
.map((c) => String.fromCodePoint(c.charCodeAt(0) + 127397))
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["192.168.100.6"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "ksan.dev",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"next": "^16.2.12",
|
||||
"nodemailer": "^9.0.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-config-next": "^0.2.4",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,66 @@
|
||||
// proxy.ts (was middleware.ts)
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { defaultLocale, isValidLocale } from "@/lib/dictionaries";
|
||||
|
||||
// RENAMED: middleware → proxy (Next.js 16 convention)
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Skip static files, API routes, Next.js internals
|
||||
if (
|
||||
pathname.startsWith("/_next") ||
|
||||
pathname.startsWith("/api") ||
|
||||
pathname.startsWith("/favicon") ||
|
||||
pathname.includes(".")
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check if path already has a valid locale prefix
|
||||
const pathLocale = pathname.split("/")[1];
|
||||
if (isValidLocale(pathLocale)) {
|
||||
// Serve directly — NEVER redirect locale-prefixed URLs.
|
||||
// Forward the URL locale as a request header so server components
|
||||
// without params access (e.g. not-found.tsx) can read it.
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set("x-locale", pathLocale);
|
||||
requestHeaders.set("x-preferred-locale", detectPreferredLocale(request));
|
||||
return NextResponse.next({ request: { headers: requestHeaders } });
|
||||
}
|
||||
|
||||
// No locale prefix — redirect to default (or cookie preference)
|
||||
const cookieLocale = request.cookies.get("NEXT_LOCALE")?.value;
|
||||
const targetLocale =
|
||||
cookieLocale && isValidLocale(cookieLocale) ? cookieLocale : defaultLocale;
|
||||
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = `/${targetLocale}${pathname}`;
|
||||
return NextResponse.redirect(url, 307);
|
||||
}
|
||||
|
||||
function detectPreferredLocale(request: NextRequest): string {
|
||||
const acceptLang = request.headers.get("accept-language") || "";
|
||||
|
||||
const segments = acceptLang
|
||||
.split(",")
|
||||
.map((part) => {
|
||||
const [lang, q] = part.trim().split(";q=");
|
||||
return {
|
||||
lang: lang.toLowerCase().trim(),
|
||||
quality: q ? parseFloat(q) : 1.0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.quality - a.quality);
|
||||
|
||||
for (const { lang } of segments) {
|
||||
const base = lang.split("-")[0];
|
||||
if (["sr", "bs", "hr"].includes(base)) return "sr";
|
||||
if (base === "en") return "en";
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next|api|favicon\\.ico|.*\\..*).*)"],
|
||||
};
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 566 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="512"
|
||||
height="512"
|
||||
viewBox="0 0 135.46666 135.46667"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
sodipodi:docname="kd.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="px"
|
||||
showguides="true"
|
||||
showgrid="true"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="28"
|
||||
inkscape:cy="204.5"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1052"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1">
|
||||
<inkscape:grid
|
||||
id="grid11"
|
||||
units="px"
|
||||
originx="0"
|
||||
originy="0"
|
||||
spacingx="0.26458332"
|
||||
spacingy="0.26458334"
|
||||
empcolor="#0099e5"
|
||||
empopacity="0.30196078"
|
||||
color="#0099e5"
|
||||
opacity="0.14901961"
|
||||
empspacing="5"
|
||||
enabled="true"
|
||||
visible="true" />
|
||||
</sodipodi:namedview>
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke-width:0.572782"
|
||||
id="rect1"
|
||||
width="13.804729"
|
||||
height="118.32624"
|
||||
x="13.890624"
|
||||
y="8.2025957" />
|
||||
<rect
|
||||
style="fill:#f5f7ff;fill-opacity:1;stroke-width:0.572782"
|
||||
id="rect2"
|
||||
width="13.804729"
|
||||
height="118.32624"
|
||||
x="13.890624"
|
||||
y="8.2025957" />
|
||||
<rect
|
||||
style="fill:#f5f7ff;fill-opacity:1;stroke-width:0.572782"
|
||||
id="rect3"
|
||||
width="13.804729"
|
||||
height="118.32624"
|
||||
x="106.00045"
|
||||
y="8.2025957" />
|
||||
<path
|
||||
style="fill:#a78bfa;fill-opacity:1;stroke-width:0.394421"
|
||||
d="m 27.699968,67.363045 42.48344,59.162715 H 83.988137 L 41.364497,67.365711 83.985235,8.2049153 70.183408,8.2010387 Z"
|
||||
id="path6" />
|
||||
<path
|
||||
style="fill:#a78bfa;fill-opacity:1;stroke-width:0.394421"
|
||||
d="m 105.67819,126.52421 -13.803179,0.002 L 64.830768,88.862875 91.875011,51.459355 H 105.67973 L 78.546866,88.860876 Z"
|
||||
id="path11" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 982 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||