+
setOpen(false)} />
+
+
+
+
+ setQuery(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1));
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setActiveIndex((prev) => Math.max(prev - 1, 0));
+ }
+ if (event.key === "Enter") {
+ event.preventDefault();
+ const action = filtered[activeIndex];
+ if (action) runAction(action);
+ }
+ }}
+ />
+ Esc
+
+
+
+ {filtered.length === 0 ? (
+ - No results
+ ) : (
+ filtered.map((action, index) => {
+ const Icon = action.icon;
+ return (
+ -
+
+
+ );
+ })
+ )}
+
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/components/DevSettings.tsx b/src/components/DevSettings.tsx
new file mode 100644
index 0000000000..9b80b72af2
--- /dev/null
+++ b/src/components/DevSettings.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { trackEvent } from "@/lib/analytics";
+import { getContactSendEnabled, setContactSendEnabled } from "@/lib/contact-settings";
+
+export default function DevSettings() {
+ const [sendEnabled, setSendEnabled] = useState(true);
+
+ useEffect(() => {
+ setSendEnabled(getContactSendEnabled());
+ }, []);
+
+ const handleToggle = () => {
+ const next = !sendEnabled;
+ setSendEnabled(next);
+ setContactSendEnabled(next);
+ trackEvent("dev_contact_send_toggle", { enabled: next });
+ };
+
+ return (
+
+
+
+
+
+
+
Send contact emails
+
+ Disable to avoid using API credits while testing.
+
+
+
+
+
+
+ {sendEnabled
+ ? "Emails will be sent when the contact form submits."
+ : "Emails are disabled. The form will show a dry-run popup only."}
+
+
+
+ );
+}
diff --git a/src/components/Home.tsx b/src/components/Home.tsx
new file mode 100644
index 0000000000..8888777a80
--- /dev/null
+++ b/src/components/Home.tsx
@@ -0,0 +1,526 @@
+"use client";
+
+import {
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useCallback,
+ memo,
+ useRef,
+ useState,
+ type MouseEvent as ReactMouseEvent,
+ type PointerEvent as ReactPointerEvent,
+} from "react";
+import {
+ animate,
+ motion,
+ type PanInfo,
+ type MotionStyle,
+ type ValueAnimationTransition,
+ useDragControls,
+ useMotionValue,
+ useSpring,
+ useTransform,
+} from "framer-motion";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import type { TabKey } from "@/lib/types";
+import { hasBlogPosts } from "@/lib/data";
+import { trackEvent } from "@/lib/analytics";
+import Sidebar from "./Sidebar";
+import Tabs from "./Tabs";
+import CommandPalette from "./CommandPalette";
+
+import About from "@/components/sections/About";
+import Resume from "@/components/sections/Resume";
+import Portfolio from "@/components/sections/Portfolio";
+import Blog from "@/components/sections/Blog";
+import Contact from "@/components/sections/Contact";
+
+const BASE_TABS: TabKey[] = ["about", "resume", "portfolio", "blog", "contact"];
+const AVAILABLE_TABS: TabKey[] = hasBlogPosts
+ ? BASE_TABS
+ : BASE_TABS.filter((tab) => tab !== "blog");
+const SNAP_DISTANCE_RATIO = 0.25;
+const SNAP_VELOCITY_THRESHOLD = 600;
+const SNAP_TRANSITION = {
+ type: "spring",
+ stiffness: 320,
+ damping: 32,
+ mass: 0.7,
+} satisfies ValueAnimationTransition
;
+
+type TransitionDirection = -1 | 0 | 1;
+
+type TransitionSource = "click" | "drag";
+
+const MemoAbout = memo(About);
+const MemoResume = memo(Resume);
+const MemoPortfolio = memo(Portfolio);
+const MemoBlog = memo(Blog);
+const MemoContact = memo(Contact);
+
+const isProjectModalOpen = () =>
+ typeof document !== "undefined" &&
+ document.body.classList.contains("is-project-modal-open");
+
+const shouldIgnoreSwipeTarget = (target: EventTarget | null) => {
+ if (!(target instanceof Element)) {
+ return false;
+ }
+ return Boolean(target.closest("[data-no-swipe]"));
+};
+
+export default function Home() {
+ const sp = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+ const dragX = useMotionValue(0);
+ const smoothX = useSpring(dragX, { stiffness: 420, damping: 48, mass: 0.8 });
+ const dragControls = useDragControls();
+ const panelsRef = useRef(null);
+ const panelWidthRef = useRef(0);
+ const isTransitioningRef = useRef(false);
+ const dragActiveRef = useRef(false);
+ const dragDirectionRef = useRef(0);
+ const pendingResetRef = useRef(false);
+ const pendingUrlTabRef = useRef(null);
+ const suppressClickRef = useRef(false);
+ const suppressClickTimeoutRef = useRef | number | null>(null);
+
+ const urlTab = useMemo(() => {
+ const t = sp.get("tab") as TabKey | null;
+ return t && AVAILABLE_TABS.includes(t) ? t : "about";
+ }, [sp]);
+
+ const [currentTab, setCurrentTab] = useState(urlTab);
+ const [backTab, setBackTab] = useState(null);
+ const [dragTab, setDragTab] = useState(null);
+ const [panelWidth, setPanelWidth] = useState(0);
+ const [isMobile, setIsMobile] = useState(false);
+ const [dragDirection, setDragDirection] = useState(0);
+
+ const currentIndex = AVAILABLE_TABS.indexOf(currentTab);
+ const prevTab = currentIndex > 0 ? AVAILABLE_TABS[currentIndex - 1] : null;
+ const nextTab = currentIndex < AVAILABLE_TABS.length - 1 ? AVAILABLE_TABS[currentIndex + 1] : null;
+ const frontTab = dragTab ?? currentTab;
+ const updateDragDirection = useCallback((direction: TransitionDirection) => {
+ if (dragDirectionRef.current === direction) {
+ return;
+ }
+ dragDirectionRef.current = direction;
+ setDragDirection(direction);
+ }, []);
+ const frontX = useTransform(smoothX, (x) => (dragDirection === 1 ? x : 0));
+ const backX = useTransform(smoothX, (x) =>
+ dragDirection === -1 ? x - panelWidthRef.current : 0
+ );
+
+ useLayoutEffect(() => {
+ if (!panelsRef.current) {
+ return;
+ }
+ const updateWidth = () => {
+ if (!panelsRef.current) {
+ return;
+ }
+ const nextWidth = panelsRef.current.getBoundingClientRect().width;
+ panelWidthRef.current = nextWidth;
+ setPanelWidth(nextWidth);
+ };
+ updateWidth();
+
+ const resizeObserver =
+ typeof ResizeObserver !== "undefined" ? new ResizeObserver(updateWidth) : null;
+ if (resizeObserver) {
+ resizeObserver.observe(panelsRef.current);
+ }
+ window.addEventListener("resize", updateWidth);
+ return () => {
+ if (resizeObserver) {
+ resizeObserver.disconnect();
+ }
+ window.removeEventListener("resize", updateWidth);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ const media = window.matchMedia("(max-width: 1023px)");
+ const update = () => setIsMobile(media.matches);
+ update();
+ media.addEventListener("change", update);
+ return () => media.removeEventListener("change", update);
+ }, []);
+
+ useEffect(() => {
+ if (isMobile) {
+ return;
+ }
+ dragX.set(0);
+ setBackTab(null);
+ setDragTab(null);
+ dragActiveRef.current = false;
+ updateDragDirection(0);
+ }, [isMobile, dragX, updateDragDirection]);
+
+ useLayoutEffect(() => {
+ if (!pendingResetRef.current) {
+ return;
+ }
+ pendingResetRef.current = false;
+ dragX.set(0);
+ }, [currentTab, dragX]);
+
+ useEffect(() => {
+ const pending = pendingUrlTabRef.current;
+ if (pending) {
+ if (urlTab === pending) {
+ pendingUrlTabRef.current = null;
+ } else if (currentTab === pending) {
+ return;
+ } else {
+ pendingUrlTabRef.current = null;
+ }
+ }
+ if (isTransitioningRef.current) {
+ return;
+ }
+ if (urlTab !== currentTab) {
+ setCurrentTab(urlTab);
+ setBackTab(null);
+ setDragTab(null);
+ dragX.set(0);
+ dragActiveRef.current = false;
+ updateDragDirection(0);
+ }
+ }, [urlTab, currentTab, dragX, updateDragDirection]);
+
+ useEffect(() => {
+ trackEvent("tab_view", { tab: currentTab });
+ }, [currentTab]);
+
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ if (isProjectModalOpen()) {
+ return;
+ }
+ let raf1 = 0;
+ let raf2 = 0;
+ raf1 = window.requestAnimationFrame(() => {
+ raf2 = window.requestAnimationFrame(() => {
+ const doc = document.documentElement;
+ const max = doc.scrollHeight - window.innerHeight;
+ if (max <= 0) {
+ return;
+ }
+ if (window.scrollY > max) {
+ window.scrollTo({ top: max, left: 0, behavior: "smooth" });
+ }
+ });
+ });
+ return () => {
+ if (raf1) {
+ window.cancelAnimationFrame(raf1);
+ }
+ if (raf2) {
+ window.cancelAnimationFrame(raf2);
+ }
+ };
+ }, [currentTab]);
+
+ useEffect(() => {
+ return () => {
+ if (suppressClickTimeoutRef.current) {
+ clearTimeout(suppressClickTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ const thresholds = [25, 50, 75, 100];
+ const fired = new Set();
+
+ const handleScroll = () => {
+ const doc = document.documentElement;
+ const max = doc.scrollHeight - window.innerHeight;
+ const percent = max > 0 ? Math.min(100, Math.round((window.scrollY / max) * 100)) : 100;
+
+ thresholds.forEach((mark) => {
+ if (percent >= mark && !fired.has(mark)) {
+ fired.add(mark);
+ trackEvent("scroll_depth", { percent: mark, tab: currentTab });
+ }
+ });
+ };
+
+ handleScroll();
+ window.addEventListener("scroll", handleScroll, { passive: true });
+ return () => window.removeEventListener("scroll", handleScroll);
+ }, [currentTab]);
+
+ const updateUrl = (tab: TabKey) => {
+ pendingUrlTabRef.current = tab;
+ const next = new URLSearchParams(sp.toString());
+ next.set("tab", tab);
+ router.replace(`${pathname}?${next.toString()}`, { scroll: false });
+ };
+
+ const finalizeTab = (tab: TabKey, from: TabKey, source: TransitionSource) => {
+ setCurrentTab(tab);
+ setBackTab(null);
+ setDragTab(null);
+ updateDragDirection(0);
+ pendingResetRef.current = true;
+ isTransitioningRef.current = false;
+ updateUrl(tab);
+ if (source === "drag") {
+ trackEvent("tab_swipe", { from, to: tab });
+ }
+ };
+
+ const startTransition = (tab: TabKey, source: TransitionSource) => {
+ if (!AVAILABLE_TABS.includes(tab)) {
+ return;
+ }
+ if (tab === currentTab) {
+ return;
+ }
+ if (isProjectModalOpen()) {
+ return;
+ }
+ if (isTransitioningRef.current) {
+ return;
+ }
+ const from = currentTab;
+ const fromIndex = AVAILABLE_TABS.indexOf(from);
+ const toIndex = AVAILABLE_TABS.indexOf(tab);
+ if (fromIndex === -1 || toIndex === -1) {
+ finalizeTab(tab, from, source);
+ return;
+ }
+ const direction: TransitionDirection = toIndex > fromIndex ? 1 : -1;
+ if (!isMobile || panelWidth <= 0) {
+ finalizeTab(tab, from, source);
+ return;
+ }
+ setDragTab(from);
+ setBackTab(tab);
+ updateDragDirection(direction);
+ isTransitioningRef.current = true;
+ const targetX = direction > 0 ? -panelWidth : panelWidth;
+ const controls = animate(dragX, targetX, SNAP_TRANSITION);
+ controls.then(() => finalizeTab(tab, from, source));
+ };
+
+ const handlePointerDown = (event: ReactPointerEvent) => {
+ if (!isMobile) {
+ return;
+ }
+ if (isProjectModalOpen()) {
+ return;
+ }
+ if (shouldIgnoreSwipeTarget(event.target)) {
+ return;
+ }
+ if (!prevTab && !nextTab) {
+ return;
+ }
+ if (isTransitioningRef.current) {
+ return;
+ }
+ if (suppressClickTimeoutRef.current) {
+ clearTimeout(suppressClickTimeoutRef.current);
+ }
+ suppressClickRef.current = false;
+ dragControls.start(event.nativeEvent);
+ };
+
+ const handleDragStart = () => {
+ dragActiveRef.current = true;
+ updateDragDirection(0);
+ setDragTab(currentTab);
+ setBackTab(null);
+ };
+
+ const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
+ if (!dragActiveRef.current) {
+ return;
+ }
+ const offsetX = info.offset.x;
+ if (!suppressClickRef.current) {
+ const offsetY = info.offset.y;
+ if (Math.abs(offsetX) > 6 && Math.abs(offsetX) > Math.abs(offsetY)) {
+ suppressClickRef.current = true;
+ }
+ }
+ const direction = offsetX < 0 ? 1 : offsetX > 0 ? -1 : 0;
+ const nextDirection: TransitionDirection =
+ direction === 1 && nextTab
+ ? 1
+ : direction === -1 && prevTab
+ ? -1
+ : 0;
+ if (nextDirection === dragDirectionRef.current) {
+ return;
+ }
+ updateDragDirection(nextDirection);
+ if (nextDirection === 1) {
+ setBackTab(nextTab ?? null);
+ } else if (nextDirection === -1) {
+ setBackTab(prevTab ?? null);
+ } else {
+ setBackTab(null);
+ }
+ };
+
+ const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
+ if (!dragActiveRef.current) {
+ return;
+ }
+ dragActiveRef.current = false;
+ if (suppressClickRef.current) {
+ if (suppressClickTimeoutRef.current) {
+ clearTimeout(suppressClickTimeoutRef.current);
+ }
+ suppressClickTimeoutRef.current = window.setTimeout(() => {
+ suppressClickRef.current = false;
+ }, 120);
+ }
+ const offsetX = info.offset.x;
+ const velocityX = info.velocity.x;
+ const direction = offsetX < 0 ? 1 : offsetX > 0 ? -1 : 0;
+ const targetTab = direction === 1 ? nextTab : direction === -1 ? prevTab : null;
+ const threshold = panelWidth * SNAP_DISTANCE_RATIO;
+ const shouldCommit =
+ Boolean(targetTab) &&
+ (Math.abs(offsetX) > threshold || Math.abs(velocityX) > SNAP_VELOCITY_THRESHOLD);
+
+ if (shouldCommit && targetTab) {
+ isTransitioningRef.current = true;
+ setBackTab(targetTab);
+ const targetX = direction > 0 ? -panelWidth : panelWidth;
+ const from = currentTab;
+ const controls = animate(dragX, targetX, SNAP_TRANSITION);
+ controls.then(() => finalizeTab(targetTab, from, "drag"));
+ return;
+ }
+
+ const controls = animate(dragX, 0, SNAP_TRANSITION);
+ controls.then(() => {
+ setBackTab(null);
+ setDragTab(null);
+ updateDragDirection(0);
+ });
+ };
+
+ const handleClickCapture = (event: ReactMouseEvent) => {
+ if (!suppressClickRef.current) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ return (
+ <>
+
+
+
+
+
startTransition(tab, "click")} />
+
+
+
+ {AVAILABLE_TABS.map((tab) => {
+ const isFront = tab === frontTab;
+ const isBack = tab === backTab;
+ const isVisible = isFront || isBack;
+ const frontZ = dragDirection === -1 ? 1 : 2;
+ const backZ = dragDirection === -1 ? 2 : 1;
+ const content =
+ tab === "about" ? (
+
+ ) : tab === "resume" ? (
+
+ ) : tab === "portfolio" ? (
+
+ ) : tab === "blog" ? (
+
+ ) : (
+
+ );
+ const baseStyle: MotionStyle = {
+ display: isVisible ? "block" : "none",
+ position: isFront ? "relative" : "absolute",
+ inset: isFront ? undefined : 0,
+ pointerEvents: isFront ? "auto" : "none",
+ zIndex: isFront ? frontZ : isBack ? backZ : 0,
+ willChange: isVisible ? "transform" : "auto",
+ };
+ const motionProps = isFront
+ ? {
+ style: {
+ ...baseStyle,
+ x: frontX,
+ touchAction: "pan-y",
+ },
+ }
+ : isBack
+ ? {
+ style: {
+ ...baseStyle,
+ x: backX,
+ },
+ }
+ : { style: baseStyle };
+
+ return (
+
+ {content}
+
+ );
+ })}
+
+
+
+
+ startTransition(tab, "click")} />
+ >
+ );
+}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
new file mode 100644
index 0000000000..03ba368a50
--- /dev/null
+++ b/src/components/Sidebar.tsx
@@ -0,0 +1,254 @@
+"use client";
+
+import Image from "next/image";
+import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
+import type { CSSProperties } from "react";
+import {
+ CalendarDays,
+ Github,
+ Instagram,
+ Linkedin,
+ Mail,
+ MapPin,
+ Phone,
+ UserRound,
+} from "lucide-react";
+import { profile, socials } from "@/lib/data";
+import { trackEvent } from "@/lib/analytics";
+
+const socialIcons: Record = {
+ GitHub: Github,
+ LinkedIn: Linkedin,
+ Instagram: Instagram,
+};
+
+export default function Sidebar() {
+ const sidebarRef = useRef(null);
+ const infoRef = useRef(null);
+ const infoMoreInnerRef = useRef(null);
+ const openHeightRef = useRef(null);
+ const infoMoreHeightRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [openHeight, setOpenHeight] = useState(null);
+ const [infoMoreHeight, setInfoMoreHeight] = useState(null);
+ const available = useMemo(() => profile.status.available, []);
+
+ const updateOpenHeight = useCallback(() => {
+ if (!sidebarRef.current || !infoRef.current || !infoMoreInnerRef.current) {
+ return;
+ }
+ const headerHeight = infoRef.current.getBoundingClientRect().height;
+ const moreHeight = infoMoreInnerRef.current.scrollHeight;
+ const styles = window.getComputedStyle(sidebarRef.current);
+ const paddingTop = Number.parseFloat(styles.paddingTop) || 0;
+ const paddingBottom = Number.parseFloat(styles.paddingBottom) || 0;
+ const next = Math.ceil(headerHeight + moreHeight + paddingTop + paddingBottom);
+ const nextMore = Math.ceil(moreHeight);
+ if (openHeightRef.current !== next) {
+ openHeightRef.current = next;
+ setOpenHeight(next);
+ }
+ if (infoMoreHeightRef.current !== nextMore) {
+ infoMoreHeightRef.current = nextMore;
+ setInfoMoreHeight(nextMore);
+ }
+ }, []);
+
+ useLayoutEffect(() => {
+ updateOpenHeight();
+ if (typeof document !== "undefined" && "fonts" in document) {
+ void (document as Document & { fonts?: FontFaceSet }).fonts?.ready?.then(() => {
+ updateOpenHeight();
+ });
+ }
+ const resizeObserver =
+ typeof ResizeObserver !== "undefined" ? new ResizeObserver(updateOpenHeight) : null;
+ if (resizeObserver) {
+ if (infoRef.current) {
+ resizeObserver.observe(infoRef.current);
+ }
+ if (infoMoreInnerRef.current) {
+ resizeObserver.observe(infoMoreInnerRef.current);
+ }
+ }
+ window.addEventListener("resize", updateOpenHeight);
+ return () => {
+ if (resizeObserver) {
+ resizeObserver.disconnect();
+ }
+ window.removeEventListener("resize", updateOpenHeight);
+ };
+ }, [updateOpenHeight]);
+
+ useLayoutEffect(() => {
+ updateOpenHeight();
+ const frame = window.requestAnimationFrame(updateOpenHeight);
+ return () => window.cancelAnimationFrame(frame);
+ }, [open, updateOpenHeight]);
+
+ const sidebarStyle = useMemo<
+ | (CSSProperties & {
+ "--sidebar-open-height"?: string;
+ "--sidebar-info-more-height"?: string;
+ })
+ | undefined
+ >(() => {
+ if (!openHeight && !infoMoreHeight) {
+ return undefined;
+ }
+ const style: CSSProperties & {
+ "--sidebar-open-height"?: string;
+ "--sidebar-info-more-height"?: string;
+ } = {};
+ if (openHeight) {
+ style["--sidebar-open-height"] = `${openHeight}px`;
+ }
+ if (infoMoreHeight) {
+ style["--sidebar-info-more-height"] = `${infoMoreHeight}px`;
+ }
+ return style;
+ }, [openHeight, infoMoreHeight]);
+ return (
+
+ );
+}
diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx
new file mode 100644
index 0000000000..5e92f236d5
--- /dev/null
+++ b/src/components/Tabs.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { Search } from "lucide-react";
+import type { TabKey } from "@/lib/types";
+import { hasBlogPosts } from "@/lib/data";
+import { trackEvent } from "@/lib/analytics";
+
+const labels: Record = {
+ about: "About",
+ resume: "Resume",
+ portfolio: "Portfolio",
+ blog: "Blog",
+ contact: "Contact",
+};
+
+export default function Tabs({
+ active,
+ onChange,
+}: {
+ active: TabKey;
+ onChange: (t: TabKey) => void;
+}) {
+ const items: TabKey[] = ["about", "resume", "portfolio", "blog", "contact"];
+ const visibleItems = hasBlogPosts ? items : items.filter((item) => item !== "blog");
+
+ return (
+
+ );
+}
diff --git a/src/components/Testimonials.tsx b/src/components/Testimonials.tsx
new file mode 100644
index 0000000000..69fe3e018a
--- /dev/null
+++ b/src/components/Testimonials.tsx
@@ -0,0 +1,240 @@
+"use client";
+
+import Image from "next/image";
+import { X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { testimonials } from "@/lib/data";
+
+const quoteIcon = "/assets/images/icon-quote.svg";
+
+const formatDate = (value: string) => {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+ return new Intl.DateTimeFormat("en-GB", {
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+ }).format(date);
+};
+
+export default function Testimonials() {
+ if (testimonials.length === 0) {
+ return null;
+ }
+
+ const [modalOpen, setModalOpen] = useState(false);
+ const [modalContent, setModalContent] = useState({
+ avatar: "",
+ name: "",
+ text: "",
+ date: "",
+ });
+ const [activeIndex, setActiveIndex] = useState(0);
+ const listRef = useRef(null);
+ const scrollRaf = useRef(null);
+
+ const openModal = (avatar: string, name: string, text: string, date: string) => {
+ setModalContent({ avatar, name, text, date });
+ setModalOpen(true);
+ };
+
+ const closeModal = () => {
+ setModalOpen(false);
+ };
+
+ const scrollToIndex = (index: number) => {
+ const list = listRef.current;
+ if (!list) return;
+ const items = Array.from(list.children) as HTMLElement[];
+ const target = items[index];
+ if (!target) return;
+ target.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
+ };
+
+ const updateActiveFromScroll = () => {
+ const list = listRef.current;
+ if (!list) return;
+ const items = Array.from(list.children) as HTMLElement[];
+ if (!items.length) return;
+ const listRect = list.getBoundingClientRect();
+ const listCenter = listRect.left + listRect.width / 2;
+ let closestIndex = 0;
+ let closestDistance = Infinity;
+
+ items.forEach((item, index) => {
+ const rect = item.getBoundingClientRect();
+ const itemCenter = rect.left + rect.width / 2;
+ const distance = Math.abs(listCenter - itemCenter);
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closestIndex = index;
+ }
+ });
+
+ setActiveIndex(closestIndex);
+ };
+
+ const handleScroll = () => {
+ if (scrollRaf.current !== null) {
+ cancelAnimationFrame(scrollRaf.current);
+ }
+ scrollRaf.current = requestAnimationFrame(updateActiveFromScroll);
+ };
+
+ useEffect(() => {
+ const list = listRef.current;
+ if (!list) return;
+ list.addEventListener("scroll", handleScroll, { passive: true });
+ updateActiveFromScroll();
+ const handleResize = () => updateActiveFromScroll();
+ window.addEventListener("resize", handleResize);
+ return () => {
+ list.removeEventListener("scroll", handleScroll);
+ window.removeEventListener("resize", handleResize);
+ if (scrollRaf.current !== null) {
+ cancelAnimationFrame(scrollRaf.current);
+ }
+ };
+ }, []);
+
+ return (
+
+ Testimonials
+
+ {
+ if (event.key === "ArrowRight") {
+ event.preventDefault();
+ const next = Math.min(activeIndex + 1, testimonials.length - 1);
+ setActiveIndex(next);
+ scrollToIndex(next);
+ }
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ const prev = Math.max(activeIndex - 1, 0);
+ setActiveIndex(prev);
+ scrollToIndex(prev);
+ }
+ if (event.key === "Home") {
+ event.preventDefault();
+ setActiveIndex(0);
+ scrollToIndex(0);
+ }
+ if (event.key === "End") {
+ event.preventDefault();
+ const last = testimonials.length - 1;
+ setActiveIndex(last);
+ scrollToIndex(last);
+ }
+ }}
+ >
+ {testimonials.map((testimonial) => (
+ -
+ openModal(
+ testimonial.avatar,
+ testimonial.name,
+ testimonial.text,
+ testimonial.date
+ )
+ }
+ >
+
+
+
+
+
+
+ {testimonial.name}
+
+
+
+
+
+ ))}
+
+
+
+ {testimonials.map((testimonial, index) => (
+
+
+ {modalOpen && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {modalContent.name}
+
+
+ {modalContent.date ? (
+
+ ) : null}
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/sections/About.tsx b/src/components/sections/About.tsx
new file mode 100644
index 0000000000..29581242b1
--- /dev/null
+++ b/src/components/sections/About.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { Brush, Camera, Code, Database, Smartphone, Users } from "lucide-react";
+import Clients from "@/components/Clients";
+import Testimonials from "@/components/Testimonials";
+import { profile, services } from "@/lib/data";
+
+export default function About() {
+ const icons = {
+ design: Brush,
+ dev: Code,
+ app: Smartphone,
+ photo: Camera,
+ data: Database,
+ leadership: Users,
+ } as const;
+
+ return (
+ <>
+
+
+
+ {profile.about.map((p) => (
+ {p}
+ ))}
+
+
+
+ What i'm doing
+
+
+ {services.map((service) => {
+ const Icon = icons[service.icon];
+ return (
+ -
+
+
+
+
+
+
{service.title}
+
{service.description}
+
+
+ );
+ })}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/sections/Blog.tsx b/src/components/sections/Blog.tsx
new file mode 100644
index 0000000000..65aae8e398
--- /dev/null
+++ b/src/components/sections/Blog.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import Image from "next/image";
+import { blogPosts } from "@/lib/data";
+
+const formatDate = (value: string) => {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+ return new Intl.DateTimeFormat("en-GB", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ }).format(date);
+};
+
+export default function Blog() {
+ if (blogPosts.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/components/sections/Contact.tsx b/src/components/sections/Contact.tsx
new file mode 100644
index 0000000000..c7c1befed1
--- /dev/null
+++ b/src/components/sections/Contact.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import { useEffect, useRef, useState, type FormEvent } from "react";
+import { Send } from "lucide-react";
+import { trackEvent } from "@/lib/analytics";
+import { getContactSendEnabled } from "@/lib/contact-settings";
+
+type FormStatus = "idle" | "sending" | "success" | "error";
+
+export default function Contact() {
+ const formRef = useRef(null);
+ const startedRef = useRef(false);
+ const [isValid, setIsValid] = useState(false);
+ const [status, setStatus] = useState("idle");
+ const [statusMessage, setStatusMessage] = useState(null);
+ const [isCoarsePointer, setIsCoarsePointer] = useState(false);
+ const [mapInteractive, setMapInteractive] = useState(false);
+ const toastTimerRef = useRef(null);
+
+ const handleInput = () => {
+ const valid = formRef.current?.checkValidity() ?? false;
+ setIsValid(valid);
+ if (status !== "idle" && status !== "sending") {
+ setStatus("idle");
+ setStatusMessage(null);
+ }
+ };
+
+ const handleStart = () => {
+ if (!startedRef.current) {
+ startedRef.current = true;
+ trackEvent("contact_form_start");
+ }
+ };
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ const form = formRef.current;
+ if (!form) return;
+ const valid = form.checkValidity();
+ setIsValid(valid);
+ if (!valid || status === "sending") return;
+
+ const formData = new FormData(form);
+ const payload = {
+ name: String(formData.get("fullname") || "").trim(),
+ email: String(formData.get("email") || "").trim(),
+ message: String(formData.get("message") || "").trim(),
+ company: String(formData.get("company") || "").trim(),
+ };
+
+ if (!getContactSendEnabled()) {
+ trackEvent("contact_form_dry_run");
+ setStatus("success");
+ setStatusMessage("Email sending is disabled (dry run).");
+ form.reset();
+ setIsValid(false);
+ return;
+ }
+
+ setStatus("sending");
+ setStatusMessage(null);
+
+ try {
+ const response = await fetch("/api/contact", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => null);
+ throw new Error(data?.message || "Something went wrong. Please try again.");
+ }
+
+ trackEvent("contact_form_submit");
+ setStatus("success");
+ setStatusMessage("I'll get back to you soon.");
+ form.reset();
+ setIsValid(false);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Something went wrong.";
+ trackEvent("contact_form_error");
+ setStatus("error");
+ setStatusMessage(message);
+ }
+ };
+
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ const media = window.matchMedia("(hover: none) and (pointer: coarse)");
+ const updatePointer = () => {
+ const coarse = media.matches;
+ setIsCoarsePointer(coarse);
+ setMapInteractive(!coarse);
+ };
+ updatePointer();
+ media.addEventListener("change", updatePointer);
+ return () => media.removeEventListener("change", updatePointer);
+ }, []);
+
+ useEffect(() => {
+ if (status !== "success" && status !== "error") return;
+ if (toastTimerRef.current) {
+ window.clearTimeout(toastTimerRef.current);
+ }
+ toastTimerRef.current = window.setTimeout(() => {
+ setStatus("idle");
+ setStatusMessage(null);
+ }, 4000);
+
+ return () => {
+ if (toastTimerRef.current) {
+ window.clearTimeout(toastTimerRef.current);
+ }
+ };
+ }, [status]);
+
+ const mapIsInteractive = !isCoarsePointer || mapInteractive;
+
+ return (
+ <>
+
+
+
+
+
+
+ {isCoarsePointer ? (
+ mapIsInteractive ? (
+
+ ) : (
+
+ )
+ ) : null}
+
+
+
+
+ {statusMessage && (status === "success" || status === "error") ? (
+
+
+ {status === "success" ? "Message sent" : "Message failed"}
+
+ {statusMessage}
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/components/sections/Portfolio.tsx b/src/components/sections/Portfolio.tsx
new file mode 100644
index 0000000000..a6d0bfbe2f
--- /dev/null
+++ b/src/components/sections/Portfolio.tsx
@@ -0,0 +1,575 @@
+"use client";
+
+import Image from "next/image";
+import MarkdownIt from "markdown-it";
+import { ChevronDown, ChevronLeft, ChevronRight, Eye, Link as LinkIcon, X } from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import {
+ IconAssembly,
+ IconBoxModel,
+ IconBrain,
+ IconBrandCpp,
+ IconBrandCss3,
+ IconBrandHtml5,
+ IconBrandJavascript,
+ IconBrandOpenai,
+ IconBrandPython,
+ IconBrandReact,
+ IconChartBar,
+ IconCloudUpload,
+ IconCoffee,
+ IconCode,
+ IconDeviceGamepad2,
+ IconRefresh,
+ IconSql,
+ IconTable,
+ type TablerIcon,
+} from "@tabler/icons-react";
+import { projects } from "@/lib/data";
+import type { Project } from "@/lib/types";
+import { trackEvent } from "@/lib/analytics";
+
+const categories = ["All", "Web development", "Web design", "Applications", "Other"] as const;
+const markdown = new MarkdownIt({ html: true, linkify: true, typographer: true });
+const techIconRules: Array<{ test: RegExp; Icon: TablerIcon }> = [
+ { test: /react/i, Icon: IconBrandReact },
+ { test: /javascript|js\b/i, Icon: IconBrandJavascript },
+ { test: /html\/css|html/i, Icon: IconBrandHtml5 },
+ { test: /\bcss\b/i, Icon: IconBrandCss3 },
+ { test: /\bjava\b/i, Icon: IconCoffee },
+ { test: /c\+\+|cplusplus/i, Icon: IconBrandCpp },
+ { test: /python/i, Icon: IconBrandPython },
+ { test: /pandas/i, Icon: IconTable },
+ { test: /scikit|sklearn/i, Icon: IconBrain },
+ { test: /openai|gpt/i, Icon: IconBrandOpenai },
+ { test: /netlify/i, Icon: IconCloudUpload },
+ { test: /sql/i, Icon: IconSql },
+ { test: /assembler/i, Icon: IconAssembly },
+ { test: /agile/i, Icon: IconRefresh },
+ { test: /oop|object/i, Icon: IconBoxModel },
+ { test: /game/i, Icon: IconDeviceGamepad2 },
+ { test: /data|analysis|analytics/i, Icon: IconChartBar },
+];
+const getTechIcon = (label: string): TablerIcon => {
+ const rule = techIconRules.find((item) => item.test.test(label));
+ return rule ? rule.Icon : IconCode;
+};
+
+export default function Portfolio() {
+ const [cat, setCat] = useState<(typeof categories)[number]>("All");
+ const [selectOpen, setSelectOpen] = useState(false);
+ const [selected, setSelected] = useState(null);
+ const [loadedShots, setLoadedShots] = useState>({});
+ const [shotIndex, setShotIndex] = useState(0);
+ const [caseStudyHtml, setCaseStudyHtml] = useState("");
+ const [caseStudyStatus, setCaseStudyStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
+ const [caseStudyOpen, setCaseStudyOpen] = useState(false);
+ const [mounted, setMounted] = useState(false);
+ const touchStartRef = useRef<{ x: number; y: number } | null>(null);
+ const touchDeltaRef = useRef(0);
+ const scrollLockRef = useRef<{
+ scrollY: number;
+ position: string;
+ top: string;
+ width: string;
+ overflow: string;
+ } | null>(null);
+ const shots = useMemo(() => {
+ if (!selected) return [];
+ return selected.screenshots?.length ? selected.screenshots : [{ src: selected.image }];
+ }, [selected]);
+ const singleShot = shots.length <= 1;
+ const activeShot = shots[shotIndex];
+ const advanceShot = (direction: -1 | 1) => {
+ if (shots.length <= 1) {
+ return;
+ }
+ setShotIndex((prev) => (prev + direction + shots.length) % shots.length);
+ };
+
+ const handleCategory = (next: (typeof categories)[number]) => {
+ if (next !== cat) {
+ trackEvent("portfolio_filter", { category: next });
+ }
+ setCat(next);
+ };
+
+ const openProject = (project: Project) => {
+ trackEvent("project_modal_open", { project: project.title, category: project.category });
+ setLoadedShots({});
+ setShotIndex(0);
+ setCaseStudyOpen(false);
+ setSelected(project);
+ };
+
+ const closeProject = () => {
+ if (selected) {
+ trackEvent("project_modal_close", { project: selected.title });
+ }
+ setCaseStudyOpen(false);
+ setSelected(null);
+ };
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ useEffect(() => {
+ if (!selected) return;
+ const handleKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ closeProject();
+ }
+ if (event.key === "ArrowRight" && shots.length > 1) {
+ advanceShot(1);
+ }
+ if (event.key === "ArrowLeft" && shots.length > 1) {
+ advanceShot(-1);
+ }
+ };
+ window.addEventListener("keydown", handleKey);
+ return () => window.removeEventListener("keydown", handleKey);
+ }, [selected, shots.length]);
+
+ useEffect(() => {
+ if (!selected) return;
+ const { body } = document;
+ const scrollY = window.scrollY;
+ scrollLockRef.current = {
+ scrollY,
+ position: body.style.position,
+ top: body.style.top,
+ width: body.style.width,
+ overflow: body.style.overflow,
+ };
+ body.classList.add("is-project-modal-open");
+ body.style.position = "fixed";
+ body.style.top = `-${scrollY}px`;
+ body.style.width = "100%";
+ body.style.overflow = "hidden";
+ return () => {
+ body.classList.remove("is-project-modal-open");
+ const snapshot = scrollLockRef.current;
+ if (!snapshot) {
+ return;
+ }
+ body.style.position = snapshot.position;
+ body.style.top = snapshot.top;
+ body.style.width = snapshot.width;
+ body.style.overflow = snapshot.overflow;
+ window.scrollTo(0, snapshot.scrollY);
+ };
+ }, [selected]);
+
+ useEffect(() => {
+ const path = selected?.caseStudyPath;
+ if (!path) {
+ setCaseStudyHtml("");
+ setCaseStudyStatus("idle");
+ return;
+ }
+ let active = true;
+ setCaseStudyStatus("loading");
+ fetch(path)
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error("Failed to load case study");
+ }
+ return res.text();
+ })
+ .then((text) => {
+ if (!active) return;
+ setCaseStudyHtml(markdown.render(text));
+ setCaseStudyStatus("ready");
+ })
+ .catch(() => {
+ if (!active) return;
+ setCaseStudyHtml("");
+ setCaseStudyStatus("error");
+ });
+ return () => {
+ active = false;
+ };
+ }, [selected?.caseStudyPath]);
+
+ useEffect(() => {
+ if (!shots.length) {
+ return;
+ }
+ if (shotIndex >= shots.length) {
+ setShotIndex(0);
+ }
+ }, [shots.length, shotIndex]);
+
+ return (
+ <>
+
+
+
+
+ {categories.map((c) => (
+ -
+
+
+ ))}
+
+
+
+
+
+
+ {categories.map((c) => (
+ -
+
+
+ ))}
+
+
+
+
+ {projects.map((p) => {
+ const isActive = cat === "All" || p.category === cat;
+ return (
+ -
+
+
+ );
+ })}
+
+
+
+ {mounted && selected ? createPortal(
+
+
+
+
+
+
+
{selected.title}
+
{selected.category}
+
+
+
+
+
+
{selected.description}
+
+
+
{
+ if (shots.length <= 1) {
+ return;
+ }
+ const touch = event.touches[0];
+ touchStartRef.current = { x: touch.clientX, y: touch.clientY };
+ touchDeltaRef.current = 0;
+ }}
+ onTouchMove={(event) => {
+ if (!touchStartRef.current || shots.length <= 1) {
+ return;
+ }
+ const touch = event.touches[0];
+ const deltaX = touch.clientX - touchStartRef.current.x;
+ const deltaY = touch.clientY - touchStartRef.current.y;
+ touchDeltaRef.current = deltaX;
+ if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 8) {
+ event.preventDefault();
+ }
+ }}
+ onTouchEnd={() => {
+ if (shots.length <= 1) {
+ touchStartRef.current = null;
+ touchDeltaRef.current = 0;
+ return;
+ }
+ const deltaX = touchDeltaRef.current;
+ if (Math.abs(deltaX) > 40) {
+ if (deltaX < 0) {
+ setShotIndex((prev) => (prev + 1) % shots.length);
+ } else {
+ setShotIndex((prev) => (prev - 1 + shots.length) % shots.length);
+ }
+ }
+ touchStartRef.current = null;
+ touchDeltaRef.current = 0;
+ }}
+ onTouchCancel={() => {
+ touchStartRef.current = null;
+ touchDeltaRef.current = 0;
+ }}
+ >
+
+ {shots.map((shot, index) => {
+ const shotKey = `${shot.src}-${index}`;
+ const isLoaded = loadedShots[shotKey];
+ return (
+
+
+
+ setLoadedShots((prev) => ({
+ ...prev,
+ [shotKey]: true,
+ }))
+ }
+ onError={() =>
+ setLoadedShots((prev) => ({
+ ...prev,
+ [shotKey]: true,
+ }))
+ }
+ />
+
+
+ );
+ })}
+
+
+ {!singleShot ? (
+
+
+
+
+ ) : null}
+
+
+ {activeShot?.caption ? (
+
{activeShot.caption}
+ ) : null}
+
+ {!singleShot ? (
+
+ {shots.map((shot, index) => (
+
+ ) : null}
+
+
+
+
+
Tech stack
+
+ {selected.tech.map((t) => {
+ const Icon = getTechIcon(t);
+ return (
+ -
+
+
+
+ {t}
+
+ );
+ })}
+
+
+
+
+
+
+ {selected.caseStudyPath ? (
+
+
+ {caseStudyStatus === "ready" ? (
+
+ ) : (
+
+ {caseStudyStatus === "loading"
+ ? "Loading case study..."
+ : "Case study unavailable."}
+
+ )}
+
+
+ ) : null}
+
+
+ {selected.caseStudyPath && !caseStudyOpen ? (
+
+
+
+ ) : null}
+
+
+ ,
+ document.body
+ ) : null}
+ >
+ );
+}
diff --git a/src/components/sections/Resume.tsx b/src/components/sections/Resume.tsx
new file mode 100644
index 0000000000..da86abc2ff
--- /dev/null
+++ b/src/components/sections/Resume.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import Image from "next/image";
+import { useEffect, useState } from "react";
+import { BookOpen, ChevronDown } from "lucide-react";
+import { education, experience, profile, skills } from "@/lib/data";
+import { trackEvent } from "@/lib/analytics";
+
+type SectionKey = "education" | "experience";
+
+const sections: Array<{
+ key: SectionKey;
+ title: string;
+ items: typeof education;
+}> = [
+ { key: "education", title: "Education", items: education },
+ { key: "experience", title: "Leadership & Activities", items: experience },
+];
+
+function Timeline({
+ sectionKey,
+ title,
+ items,
+ open,
+ onToggle,
+}: {
+ sectionKey: SectionKey;
+ title: string;
+ items: typeof education;
+ open: boolean;
+ onToggle: (key: SectionKey) => void;
+}) {
+ const panelId = `${sectionKey}-panel`;
+
+ return (
+
+
+
+
+
+
+
+ {items.map((it) => (
+ -
+
{it.title}
+ {it.range}
+ {Array.isArray(it.details) ? (
+ <>
+ {it.org}
+
+ {it.details.map((detail, index) => (
+ - {detail}
+ ))}
+
+ {it.coursework?.length ? (
+
+ Relevant coursework:{" "}
+
+ {it.coursework.join(" | ")}
+
+
+ ) : null}
+ >
+ ) : (
+ {`${it.org} - ${it.details}`}
+ )}
+
+ ))}
+
+
+
+ );
+}
+
+export default function Resume() {
+ const [openSections, setOpenSections] = useState>({
+ education: true,
+ experience: true,
+ });
+
+ useEffect(() => {
+ const applyHash = () => {
+ const hash = window.location.hash.replace("#", "");
+ if (hash === "education" || hash === "experience") {
+ setOpenSections((prev) => ({ ...prev, [hash]: true }));
+ const el = document.getElementById(hash);
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ }
+ };
+
+ applyHash();
+ window.addEventListener("hashchange", applyHash);
+ return () => window.removeEventListener("hashchange", applyHash);
+ }, []);
+
+ const toggleSection = (key: SectionKey) => {
+ setOpenSections((prev) => {
+ const nextOpen = !prev[key];
+ trackEvent("timeline_toggle", { section: key, open: nextOpen });
+ const next = { ...prev, [key]: nextOpen };
+ if (next[key]) {
+ const url = new URL(window.location.href);
+ url.hash = key;
+ window.history.replaceState(null, "", url.toString());
+ }
+ return next;
+ });
+ };
+
+ return (
+ <>
+
+
+ trackEvent("resume_download")}
+ >
+ Download Resume
+
+
+ {sections.map((section) => (
+
+ ))}
+
+
+ My skills
+
+ {skills.map((s) => (
+ -
+
+
+
+ {s.name}
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
new file mode 100644
index 0000000000..43f529b801
--- /dev/null
+++ b/src/lib/analytics.ts
@@ -0,0 +1,26 @@
+"use client";
+
+type AnalyticsProps = Record;
+
+type PlausibleFn = (event: string, options?: { props?: AnalyticsProps }) => void;
+type UmamiFn = (event: string, props?: AnalyticsProps) => void;
+type GtagFn = (...args: unknown[]) => void;
+
+export function trackEvent(event: string, props?: AnalyticsProps) {
+ if (typeof window === "undefined") return;
+
+ const plausible = (window as typeof window & { plausible?: PlausibleFn }).plausible;
+ if (typeof plausible === "function") {
+ plausible(event, props ? { props } : undefined);
+ }
+
+ const umami = (window as typeof window & { umami?: { track?: UmamiFn } }).umami;
+ if (typeof umami?.track === "function") {
+ umami.track(event, props);
+ }
+
+ const gtag = (window as typeof window & { gtag?: GtagFn }).gtag;
+ if (typeof gtag === "function") {
+ gtag("event", event, props ?? {});
+ }
+}
diff --git a/src/lib/contact-settings.ts b/src/lib/contact-settings.ts
new file mode 100644
index 0000000000..cb765d8528
--- /dev/null
+++ b/src/lib/contact-settings.ts
@@ -0,0 +1,19 @@
+const CONTACT_SEND_STORAGE_KEY = "contact-send-enabled";
+
+export const getContactSendEnabled = () => {
+ if (typeof window === "undefined") {
+ return true;
+ }
+ const stored = window.localStorage.getItem(CONTACT_SEND_STORAGE_KEY);
+ if (!stored) {
+ return true;
+ }
+ return stored !== "false";
+};
+
+export const setContactSendEnabled = (enabled: boolean) => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ window.localStorage.setItem(CONTACT_SEND_STORAGE_KEY, enabled ? "true" : "false");
+};
diff --git a/src/lib/data.ts b/src/lib/data.ts
new file mode 100644
index 0000000000..f86ec81718
--- /dev/null
+++ b/src/lib/data.ts
@@ -0,0 +1,328 @@
+import type {
+ BlogPost,
+ Client,
+ Project,
+ Service,
+ Skill,
+ SocialLink,
+ Testimonial,
+ TimelineItem,
+} from './types'
+
+export const profile = {
+ name: 'Daniel Rajakumar',
+ role: 'Computer Science Student',
+ location: 'Mahwah, NJ',
+ email: 'hello@danielrajakumar.com',
+ phone: '+1 (609) 388-1811',
+ resumeUrl: '/resume.pdf',
+ status: {
+ label: 'Software Developer',
+ available: true,
+ },
+ graduation: {
+ label: 'Expected May 2026',
+ datetime: '2026-05',
+ },
+ avatar: '/assets/images/profile-picture-11.png',
+ ogAvatar: '/assets/images/profile-picture-11-og.png',
+ about: [
+ 'Hi there! I am a Computer Science undergraduate with 7 years of programming experience, building practical and real-world applications.',
+ "I have experience working on full-stack applications, software and mobile projects, and data-focused work through hackathons and coursework. As the founder of my college's Google Developer Student Club and a leader in the Computer Science Club, I organized workshops. I led teams that helped students complete hands-on development projects.",
+ 'I am looking for software developer opportunities that will let me work with teams that practice strong technical principles while building real-world products.',
+ ],
+}
+
+export const socials: SocialLink[] = [
+ { label: 'GitHub', href: 'https://github.com/daniel-rajakumar' },
+ { label: 'LinkedIn', href: 'https://www.linkedin.com/in/daniel-rajakumar/' },
+]
+
+export const education: TimelineItem[] = [
+ {
+ title: 'B.S. Computer Science',
+ org: 'Ramapo College of New Jersey, Mahwah, NJ',
+ range: 'Aug 2022 — May 2026',
+ details: [
+ 'Presidential Scholarship (full-tuition merit award).',
+ ],
+ coursework: [
+ 'Software Design',
+ 'Data Structures & Algorithms',
+ 'Machine Learning',
+ 'Web Application Development',
+ 'Data Analysis & Visualization',
+ ],
+ },
+]
+
+export const experience: TimelineItem[] = [
+ {
+ title: 'Lead',
+ org: 'Google Developer Student Club, Ramapo College',
+ range: 'Aug 2023 — May 2025',
+ details: [
+ 'Organized first DevFest Event on campus, bringing together over 50 students for keynote and hands-on technical workshops.',
+ 'Led a hands-on Android development workshop leveraging Java, teaching students how to develop and deploy apps using Android Studio',
+ 'Directed a team of 8 to deliver diverse workshops and coding events, fostering a vibrant developer community',
+ ],
+ },
+ {
+ title: 'Founding President',
+ org: 'Computer Science Club',
+ range: 'Apr 2022 — Jun 2024',
+ details: [
+ 'Initiated and established the Computer Science Club, creating a student-led hub for campus tech community.',
+ 'Organized and oversaw over 10 workshops on React.js, portfolio building, and collaborative software development practices.',
+ "Built and maintained club's website using HTML, CSS, and JS to showcase events and provide resources to over 150 students.",
+ ],
+ },
+]
+
+export const skills: Skill[] = [
+ { name: 'Java', logo: '/assets/images/skills/java.svg' },
+ { name: 'C++', logo: '/assets/images/skills/cpp.svg' },
+ { name: 'JavaScript', logo: '/assets/images/skills/javascript.svg' },
+ { name: 'TypeScript', logo: '/assets/images/skills/typescript.svg' },
+ { name: 'HTML/CSS', logo: '/assets/images/skills/html-css.svg' },
+ { name: 'Python', logo: '/assets/images/skills/python.svg' },
+ { name: 'Pandas', logo: '/assets/images/skills/pandas.svg' },
+ { name: 'Scikit-learn', logo: '/assets/images/skills/scikit-learn.svg' },
+ { name: 'React.js', logo: '/assets/images/skills/react.svg' },
+ { name: 'OpenAI API', logo: '/assets/images/skills/openai.svg' },
+ { name: 'Netlify', logo: '/assets/images/skills/netlify.svg' },
+ { name: 'SQL', logo: '/assets/images/skills/sql.svg' },
+ { name: 'Google Cloud', logo: '/assets/images/skills/google-cloud.svg' },
+]
+
+export const services: Service[] = [
+ {
+ title: 'Full-stack engineering',
+ description:
+ 'Design and build web apps end-to-end with clean architecture, tested code, and reliable deployments.',
+ icon: 'dev',
+ },
+ {
+ title: 'Mobile development',
+ description:
+ 'Android-first development in Kotlin/Java, from prototypes to production features.',
+ icon: 'app',
+ },
+ {
+ title: 'Data-driven systems',
+ description:
+ 'ETL, analytics, and ML prototypes using Python, SQL, Pandas, and scikit-learn.',
+ icon: 'data',
+ },
+ {
+ title: 'Technical leadership',
+ description:
+ 'Workshops, mentoring, and leading student dev teams with clear communication and collaboration.',
+ icon: 'leadership',
+ },
+]
+
+export const testimonials: Testimonial[] = [
+ // { name: "Daniel Lewis", avatar: "/assets/images/avatar-1.svg", date: "2021-06-14", text: "Daniel was hired to create a corporate identity. We were very pleased with the work done. He has a lot of experience and is very concerned about the needs of the client.", },
+ // { name: "Jessica Miller", avatar: "/assets/images/avatar-2.svg", date: "2021-05-28", text: "Daniel took a complex brief and turned it into a clean product experience. The process was collaborative and the outcome was better than expected.", },
+ // { name: "Emily Evans", avatar: "/assets/images/avatar-3.svg", date: "2021-04-18", text: "The attention to detail was impressive, and the final site loads fast while looking sharp on every screen.", },
+ // { name: "Henry William", avatar: "/assets/images/avatar-4.svg", date: "2021-03-09", text: "Reliable, organized, and thoughtful. Delivered on time and made the whole build feel smooth.", },
+]
+
+export const clients: Client[] = [
+ // { name: "client-1", logo: "/assets/images/logo-1.svg" },
+ // { name: "client-2", logo: "/assets/images/logo-2.svg" },
+ // { name: "client-3", logo: "/assets/images/logo-3.svg" },
+ // { name: "client-4", logo: "/assets/images/logo-4.svg" },
+ // { name: "client-5", logo: "/assets/images/logo-5.svg" },
+ // { name: "client-6", logo: "/assets/images/logo-6.svg" },
+]
+
+export const projects: Project[] = [
+ {
+ title: 'Assembler & Emulator (VC407)',
+ category: 'Applications',
+ description:
+ 'Built a VC407 assembler/emulator in C++ with a two-pass assembly process and modular design.',
+ // caseStudyPath: '/case-study/assembler-emulator.md',
+ tech: ['C++', 'Assembler', 'Agile'],
+ image: '/assets/images/projects/VC370Assem/thumbnail.png',
+ links: [
+ {
+ label: 'Source code',
+ href: 'https://github.com/daniel-rajakumar/VC307',
+ },
+ ],
+ screenshots: [
+ {
+ src: '/assets/images/projects/VC370Assem/one.png',
+ caption: 'Assembler output view',
+ },
+ {
+ src: '/assets/images/projects/VC370Assem/two.png',
+ caption: 'Emulator run results',
+ },
+ {
+ src: '/assets/images/projects/VC370Assem/three.png',
+ caption: 'Assembler output view',
+ },
+ {
+ src: '/assets/images/projects/VC370Assem/four.png',
+ caption: 'Emulator run results',
+ },
+ ],
+ },
+ {
+ title: 'Social Media Engagement Analysis',
+ category: 'Other',
+ description:
+ 'Analyzed 250+ Instagram posts with ML models, reaching up to 88% classification accuracy.',
+ tech: ['Python', 'Pandas', 'Scikit-learn'],
+ image:
+ '/assets/images/projects/SocialMediaEngagementAnalysis/thumbnail.png',
+
+ links: [
+ {
+ label: 'Jupyter Notebook',
+ href: 'https://colab.research.google.com/drive/1hl8U_H2wvaPor3S9I9ANfC1QrbbEEJK-?usp=sharing'
+ }
+ ],
+ screenshots: [
+ {
+ src: '/assets/images/projects/SocialMediaEngagementAnalysis/one.png',
+ caption: 'Project screenshot',
+ },
+ {
+ src: '/assets/images/projects/SocialMediaEngagementAnalysis/two.png',
+ caption: 'Data visualization example',
+ },
+ {
+ src: '/assets/images/projects/SocialMediaEngagementAnalysis/three.png',
+ caption: 'Model accuracy results',
+ },
+ {
+ src: '/assets/images/projects/SocialMediaEngagementAnalysis/four.png',
+ caption: 'Feature importance analysis',
+ },
+ {
+ src: '/assets/images/projects/SocialMediaEngagementAnalysis/five.png',
+ caption: 'Engagement prediction results',
+ },
+ ],
+ },
+ {
+ title: 'Canoga Game',
+ category: 'Applications',
+ description:
+ 'Developed a 2D Canoga game in Java with multiplayer support and AI opponent.',
+ tech: ['Java', 'OOP', 'Game Development'],
+ image: '/assets/images/projects/CanogaGame/thumbnail.png',
+ links: [
+ {
+ label: 'Live site',
+ href: 'https://projects.canogagame.danielrajakumar.com/',
+ },
+ {
+ label: 'Source code',
+ href: 'https://github.com/daniel-rajakumar/CanogaGame',
+ },
+ ],
+ screenshots: [
+ {
+ src: '/assets/images/projects/CanogaGame/one.png',
+ caption: 'Game board view',
+ },
+ {
+ src: '/assets/images/projects/CanogaGame/two.png',
+ caption: 'Multiplayer mode',
+ },
+ {
+ src: '/assets/images/projects/CanogaGame/three.png',
+ caption: 'Multiplayer mode',
+ },
+ {
+ src: '/assets/images/projects/CanogaGame/four.png',
+ caption: 'Multiplayer mode',
+ },
+ {
+ src: '/assets/images/projects/CanogaGame/five.png',
+ caption: 'Multiplayer mode',
+ },
+ {
+ src: '/assets/images/projects/CanogaGame/six.png',
+ caption: 'Multiplayer mode',
+ },
+ ],
+ },
+ {
+ title: 'Ramapo International Street Food Festival 2025 Website',
+ category: 'Web development',
+ description:
+ 'Created a responsive website for the Ramapo International Street Food Festival 2025 using React.js and hosted on Netlify.',
+ tech: ['React.js', 'CSS', 'Netlify'],
+ image: '/assets/images/projects/ISFF25/thumbnail.png',
+ links: [
+ {
+ label: 'Live site',
+ href: 'https://projects.isff25.danielrajakumar.com/',
+ },
+ {
+ label: 'Source code',
+ href: 'https://github.com/RCNJ-Computer-Science-Club/ISFF25',
+ },
+ ],
+ screenshots: [
+ {
+ src: '/assets/images/projects/ISFF25/one.png',
+ caption: 'Homepage view',
+ },
+ {
+ src: '/assets/images/projects/ISFF25/two.png',
+ caption: 'Event schedule section',
+ },
+ {
+ src: '/assets/images/projects/ISFF25/three.png',
+ caption: 'Vendor information page',
+ },
+ {
+ src: '/assets/images/projects/ISFF25/four.png',
+ caption: 'Contact form view',
+ },
+ {
+ src: '/assets/images/projects/ISFF25/five.png',
+ caption: 'Responsive design on mobile',
+ },
+ ],
+ },
+ {
+ title: 'RockyGPT: Ramapo College Chatbot',
+ category: 'Web development',
+ description:
+ "Developed RockyGPT, a chatbot for Ramapo College using OpenAI's GPT-3.5 API to assist students with campus-related queries.",
+ tech: ['JavaScript', 'OpenAI API', 'HTML/CSS'],
+ image: '/assets/images/projects/RockyGPT/thumbnail.png',
+ status: 'In Progress',
+ screenshots: [
+ // {
+ // src: '/assets/images/projects/RockyGPT/thumbnail.png',
+ // caption: "Chat interface",
+ // },
+ // {
+ // src: "/assets/images/projects/RockyGPT/two.png",
+ // caption: "Sample conversation",
+ // },
+ // {
+ // src: "/assets/images/projects/RockyGPT/three.png",
+ // caption: "Mobile view",
+ // }
+ ],
+ },
+]
+
+export const blogPosts: BlogPost[] = [
+ // { title: "Design conferences in 2025", category: "Design", date: "2025-02-23", excerpt: "A quick rundown of the events I am tracking this year.", image: "/assets/images/blog-1.svg", },
+ // { title: "Best fonts every designer uses", category: "Design", date: "2025-02-16", excerpt: "A short list of typefaces that work across web and print.", image: "/assets/images/blog-2.svg", },
+ // { title: "Building with intent", category: "Product", date: "2025-01-30", excerpt: "How I keep projects tight, useful, and easy to ship.", image: "/assets/images/blog-3.svg", },
+]
+
+export const hasBlogPosts = blogPosts.length > 0
diff --git a/src/lib/types.ts b/src/lib/types.ts
new file mode 100644
index 0000000000..1b67ef0e33
--- /dev/null
+++ b/src/lib/types.ts
@@ -0,0 +1,59 @@
+export type TabKey = "about" | "resume" | "portfolio" | "blog" | "contact";
+
+export type SocialLink = {
+ label: string;
+ href: string;
+};
+
+export type Project = {
+ title: string;
+ category: "Web development" | "Web design" | "Applications" | "Other";
+ description: string;
+ caseStudyPath?: string;
+ tech: string[];
+ image: string;
+ screenshots?: ProjectScreenshot[];
+ links?: { label: string; href: string }[];
+ status?: "In Progress" | "Shipped" | "Paused";
+};
+
+export type ProjectScreenshot = {
+ src: string;
+ caption?: string;
+};
+
+export type TimelineItem = {
+ title: string;
+ org: string;
+ range: string;
+ details: string | string[];
+ coursework?: string[];
+};
+
+export type Skill = { name: string; logo: string };
+
+export type Service = {
+ title: string;
+ description: string;
+ icon: "design" | "dev" | "app" | "photo" | "data" | "leadership";
+};
+
+export type Testimonial = {
+ name: string;
+ avatar: string;
+ text: string;
+ date: string;
+};
+
+export type Client = {
+ name: string;
+ logo: string;
+};
+
+export type BlogPost = {
+ title: string;
+ date: string;
+ category: string;
+ excerpt: string;
+ image: string;
+};
diff --git a/src/types/jsx.d.ts b/src/types/jsx.d.ts
new file mode 100644
index 0000000000..de37dfee20
--- /dev/null
+++ b/src/types/jsx.d.ts
@@ -0,0 +1,6 @@
+declare namespace JSX {
+ interface IntrinsicElements {
+ "ion-icon": any;
+ }
+}
+
diff --git a/src/types/markdown-it.d.ts b/src/types/markdown-it.d.ts
new file mode 100644
index 0000000000..1d8bbaf147
--- /dev/null
+++ b/src/types/markdown-it.d.ts
@@ -0,0 +1,12 @@
+declare module "markdown-it" {
+ interface MarkdownItOptions {
+ html?: boolean;
+ linkify?: boolean;
+ typographer?: boolean;
+ }
+
+ export default class MarkdownIt {
+ constructor(options?: MarkdownItOptions);
+ render(markdown: string): string;
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000000..cf9c65d3e0
--- /dev/null
+++ b/tsconfig.json
@@ -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": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/website-demo-image/Thumbs.db b/website-demo-image/Thumbs.db
deleted file mode 100644
index e81e6dd2de..0000000000
Binary files a/website-demo-image/Thumbs.db and /dev/null differ
diff --git a/website-demo-image/desktop.png b/website-demo-image/desktop.png
deleted file mode 100644
index 9752a23ddd..0000000000
Binary files a/website-demo-image/desktop.png and /dev/null differ
diff --git a/website-demo-image/mobile.png b/website-demo-image/mobile.png
deleted file mode 100644
index a4b2ab4504..0000000000
Binary files a/website-demo-image/mobile.png and /dev/null differ