Initial commit

This commit is contained in:
2026-08-01 16:07:57 +02:00
commit 20b254e614
68 changed files with 8467 additions and 0 deletions
+77
View File
@@ -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>
);
}
+292
View File
@@ -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>
);
}
+138
View File
@@ -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>
);
}
+517
View File
@@ -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>
);
}
+171
View File
@@ -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>
);
}
+98
View File
@@ -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>
);
}
+146
View File
@@ -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>
);
}