diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 6e50d95789..0000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,3 +0,0 @@ -# These are supported funding model platforms - -patreon: codewithsadee # Replace with a single Patreon username diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..5ef6a52078 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000000..4ea72a911a --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000000..1f2ea11e7f --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000000..8648f9401a --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000000..03d9549ea8 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000..94a25f7f4c --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000000..fa8cda1daf --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + { + "associatedIndex": 6 +} + + + + + + + { + "keyToString": { + "JavaScript Debug.index.html.executor": "Run", + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager": "true", + "RunOnceActivity.git.unshallow": "true", + "git-widget-placeholder": "preview", + "js.debugger.nextJs.config.created.client": "true", + "last_opened_file_path": "/Users/danielrajakumar/code/personal-portfolio", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "npm.run.executor": "Run", + "settings.editor.selected.configurable": "preferences.pluginManager", + "ts.external.directory.path": "/Users/danielrajakumar/code/personal-portfolio/node_modules/typescript/lib", + "vue.rearranger.settings.migration": "true" + } +} + + + + + + + + + + + ) : null} + {children} + + + ); +} diff --git a/src/app/og-utils.tsx b/src/app/og-utils.tsx new file mode 100644 index 0000000000..614532d460 --- /dev/null +++ b/src/app/og-utils.tsx @@ -0,0 +1,224 @@ +import { ImageResponse } from "next/og"; +import { profile, services } from "@/lib/data"; + +export const ogSize = { width: 1200, height: 630 }; +export const ogContentType = "image/png"; + +const ACCENT = "#25c26a"; +const TEXT_PRIMARY = "#f5f5f5"; +const TEXT_MUTED = "#b0b0b0"; +const TEXT_SOFT = "#8a8a8a"; +const CARD_BG = "#151515"; +const CARD_BORDER = "#2b2b2b"; +const CHIP_BG = "#1f1f1f"; +const CHIP_BORDER = "#343434"; + +const arrayBufferToBase64 = (buffer: ArrayBuffer) => { + let binary = ""; + const bytes = new Uint8Array(buffer); + const chunkSize = 8192; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode(...chunk); + } + return btoa(binary); +}; + +const resolveBaseUrl = (override?: string) => { + if (override) { + return override.startsWith("http") ? override : `https://${override}`; + } + const explicit = process.env.NEXT_PUBLIC_SITE_URL; + if (explicit) { + return explicit.startsWith("http") ? explicit : `https://${explicit}`; + } + const vercelUrl = process.env.VERCEL_URL; + if (vercelUrl) { + return `https://${vercelUrl}`; + } + return "http://localhost:3000"; +}; + +const getDomain = () => { + const email = profile.email ?? ""; + if (email.includes("@")) { + return email.split("@")[1]; + } + return "portfolio"; +}; + +export const createOgImage = async (baseUrl?: string) => { + const initials = profile.name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); + + const avatarPath = profile.ogAvatar ?? profile.avatar; + let avatarSrc: string | null = null; + try { + const avatarUrl = new URL(avatarPath, resolveBaseUrl(baseUrl)); + const response = await fetch(avatarUrl); + const contentType = response.headers.get("content-type") ?? ""; + if (response.ok && contentType.startsWith("image/")) { + const buffer = await response.arrayBuffer(); + const base64 = arrayBufferToBase64(buffer); + avatarSrc = `data:${contentType};base64,${base64}`; + } + } catch { + avatarSrc = null; + } + + const highlights = services.slice(0, 3).map((service) => service.title); + const domain = getDomain(); + + return new ImageResponse( + ( +
+
+
+
+ {avatarSrc ? ( + + ) : ( + + {initials} + + )} +
+
+ +
+
+ Portfolio +
+
+ {profile.name} +
+
{profile.role}
+ +
+ {highlights.map((title) => ( +
+ {title} +
+ ))} +
+ +
+
{profile.location}
+
+
+ {domain} +
+
+
+
+
+ ), + { + width: ogSize.width, + height: ogSize.height, + } + ); +}; diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx new file mode 100644 index 0000000000..5c5eda6044 --- /dev/null +++ b/src/app/opengraph-image.tsx @@ -0,0 +1,14 @@ +import { headers } from "next/headers"; +import { createOgImage, ogContentType, ogSize } from "./og-utils"; + +export const runtime = "edge"; +export const size = ogSize; +export const contentType = ogContentType; + +export default async function OpenGraphImage() { + const headerStore = await headers(); + const host = headerStore.get("x-forwarded-host") ?? headerStore.get("host"); + const proto = headerStore.get("x-forwarded-proto") ?? "http"; + const baseUrl = host ? `${proto}://${host}` : undefined; + return createOgImage(baseUrl); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000000..0e1f75f917 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import Home from "@/components/Home"; + +export default function Page() { + return ( + + + + ); +} diff --git a/src/app/twitter-image.tsx b/src/app/twitter-image.tsx new file mode 100644 index 0000000000..f9f69e3248 --- /dev/null +++ b/src/app/twitter-image.tsx @@ -0,0 +1,14 @@ +import { headers } from "next/headers"; +import { createOgImage, ogContentType, ogSize } from "./og-utils"; + +export const runtime = "edge"; +export const size = ogSize; +export const contentType = ogContentType; + +export default async function TwitterImage() { + const headerStore = await headers(); + const host = headerStore.get("x-forwarded-host") ?? headerStore.get("host"); + const proto = headerStore.get("x-forwarded-proto") ?? "http"; + const baseUrl = host ? `${proto}://${host}` : undefined; + return createOgImage(baseUrl); +} diff --git a/src/components/Clients.tsx b/src/components/Clients.tsx new file mode 100644 index 0000000000..3b611d9b39 --- /dev/null +++ b/src/components/Clients.tsx @@ -0,0 +1,139 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; +import { clients } from "@/lib/data"; + +export default function Clients() { + if (clients.length === 0) { + return null; + } + + const [activeIndex, setActiveIndex] = useState(0); + const listRef = useRef(null); + const scrollRaf = useRef(null); + + 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 ( +
+

Clients

+
    { + if (event.key === "ArrowRight") { + event.preventDefault(); + const next = Math.min(activeIndex + 1, clients.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 = clients.length - 1; + setActiveIndex(last); + scrollToIndex(last); + } + }} + > + {clients.map((client) => ( +
  • + + {client.name} + +
  • + ))} +
+ + {clients.length > 1 ? ( +
+ {clients.map((client, index) => ( +
+ ) : null} +
+ ); +} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000000..84aa29dac2 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ComponentType } from "react"; +import { + Briefcase, + Clipboard, + Download, + FileText, + Github, + Instagram, + Linkedin, + Link2, + Mail, + PenSquare, + Phone, + Search, + UserCircle2, +} from "lucide-react"; +import type { TabKey } from "@/lib/types"; +import { hasBlogPosts, profile, socials } from "@/lib/data"; +import { trackEvent } from "@/lib/analytics"; + +type Action = { + id: string; + label: string; + hint: string; + icon: ComponentType<{ size?: number }>; + run: () => void; +}; + +const navItems: Array<{ key: TabKey; label: string }> = [ + { key: "about", label: "About" }, + { key: "resume", label: "Resume" }, + { key: "portfolio", label: "Portfolio" }, + { key: "contact", label: "Contact" }, +]; + +const navItemsWithBlog: Array<{ key: TabKey; label: string }> = [ + ...navItems, + { key: "blog", label: "Blog" }, +]; + +const navIcons: Record> = { + about: UserCircle2, + resume: FileText, + portfolio: Briefcase, + blog: PenSquare, + contact: Mail, +}; + +const socialIcons: Record> = { + GitHub: Github, + LinkedIn: Linkedin, + Instagram: Instagram, +}; + +export default function CommandPalette({ onNavigate }: { onNavigate: (tab: TabKey) => void }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + + const copyToClipboard = async (value: string) => { + try { + await navigator.clipboard.writeText(value); + } catch { + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); + } + }; + + const actions = useMemo(() => { + const visibleNavItems = hasBlogPosts ? navItemsWithBlog : navItems; + const navActions = visibleNavItems.map((item) => ({ + id: `nav-${item.key}`, + label: `Go to ${item.label}`, + hint: "Navigation", + icon: navIcons[item.key], + run: () => onNavigate(item.key), + })); + + const socialActions = socials.map((social) => ({ + id: `social-${social.label.toLowerCase()}`, + label: `Open ${social.label}`, + hint: "Social", + icon: socialIcons[social.label] ?? Link2, + run: () => window.open(social.href, "_blank", "noreferrer"), + })); + + return [ + ...navActions, + { + id: "resume-download", + label: "Download Resume", + hint: "File", + icon: Download, + run: () => { + const link = document.createElement("a"); + link.href = profile.resumeUrl; + link.download = ""; + document.body.appendChild(link); + link.click(); + link.remove(); + }, + }, + { + id: "contact-email", + label: "Email Me", + hint: profile.email, + icon: Mail, + run: () => { + window.location.href = `mailto:${profile.email}`; + }, + }, + { + id: "copy-email", + label: "Copy Email", + hint: profile.email, + icon: Clipboard, + run: () => copyToClipboard(profile.email), + }, + { + id: "copy-phone", + label: "Copy Phone", + hint: profile.phone, + icon: Phone, + run: () => copyToClipboard(profile.phone), + }, + ...socialActions, + ]; + }, [onNavigate]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return actions; + return actions.filter((action) => action.label.toLowerCase().includes(q) || action.hint.toLowerCase().includes(q)); + }, [actions, query]); + + const showHint = query.trim().length === 0; + + useEffect(() => { + const handleKey = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setOpen((prev) => !prev); + } else if (event.key === "Escape") { + setOpen(false); + } + }; + + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, []); + + useEffect(() => { + const handleOpen = () => setOpen(true); + window.addEventListener("open-command-palette", handleOpen); + return () => window.removeEventListener("open-command-palette", handleOpen); + }, []); + + useEffect(() => { + if (open) { + setQuery(""); + setActiveIndex(0); + requestAnimationFrame(() => inputRef.current?.focus()); + trackEvent("command_palette_open"); + } + }, [open]); + + useEffect(() => { + if (activeIndex >= filtered.length) { + setActiveIndex(0); + } + }, [activeIndex, filtered.length]); + + const runAction = (action: Action) => { + trackEvent("command_palette_action", { action: action.id, label: action.label }); + action.run(); + setOpen(false); + }; + + return ( + <> + + + {open ? ( +
+
setOpen(false)} /> + +
+
+
+ +
    + {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 ( +
+
+
+

Developer Settings

+

Local toggles for testing the site.

+
+ + Back to site + +
+ +
+
+
+

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")} /> + +
+
+
+
+ + 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} +
    + +

    + {testimonial.name} +

    + +
    +

    {testimonial.text}

    +
    +
    +
  • + ))} +
+ +
+ {testimonials.map((testimonial, index) => ( +
+ + {modalOpen && ( +
+
+ +
+ + +
+
+ {modalContent.name} +
+ Quote icon +
+ +
+

+ {modalContent.name} +

+ + {modalContent.date ? ( + + ) : null} + +
+

{modalContent.text}

+
+
+
+
+ )} +
+ ); +} 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 ( + <> +
+

About me

+
+ +
+ {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 ( + <> +
+

Blog

+
+ +
+ +
+ + ); +} 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 ( + <> +
+

Contact

+
+ +
+
+ +
+ {isCoarsePointer ? ( + mapIsInteractive ? ( + + ) : ( + + ) + ) : null} +
+ +
+

Contact Form

+ +
+
+ + + +
+ + + + + + + +
+
+ + {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 ( + <> +
+

Portfolio

+
+ +
+
    + {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 ( +
+
+ {`${selected.title} + 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} +
  • + ); + })} +
+
+ +
+

Links

+
+ {selected.links?.length ? ( + selected.links.map((l) => ( + + trackEvent("project_link_click", { + project: selected.title, + label: l.label, + href: l.href, + }) + } + > + + + {l.label} + + )) + ) : ( + Links coming soon + )} +
+
+
+ + {selected.caseStudyPath ? ( +
+
+ ) : 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) => ( +
  1. +

    {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}`}

    + )} +
  2. + ))} +
+
+
+ ); +} + +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 ( + <> +
+

Resume

+
+ + trackEvent("resume_download")} + > + Download Resume + + + {sections.map((section) => ( + + ))} + +
+

My skills

+
    + {skills.map((s) => ( +
  • +
    + {`${s.name} +
    + {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