From d681e445b9682e0b0b0b70ac45f25e6bd58743cc Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Fri, 1 May 2026 20:09:02 +0530 Subject: [PATCH 01/12] perf: replace react-icons with lucide-react Swap the 4 react-icons usages (FaLinkedin, FaGithub, FaInstagram, SiX) for lucide-react equivalents (Linkedin, Github, Instagram, Twitter). JSON data keys are preserved so personal.json/contact.json do not change. Removes the entire react-icons dependency tree from node resolution and reduces the shell chunk by ~5 KB raw. --- package.json | 1 - pnpm-lock.yaml | 12 ------------ src/pages/github/CodingProfiles.tsx | 5 ++--- src/pages/portfolio/ProjectCard.tsx | 5 ++--- src/utils/iconMap.ts | 13 +++++++------ vite.config.js | 2 +- 6 files changed, 12 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 1f4bf3e..c082139 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-github-calendar": "^5.0.5", - "react-icons": "^5.5.0", "react-intersection-observer": "^10.0.2", "tailwindcss": "^4.1.18", "three": "^0.183.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a567551..4cd68d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,9 +60,6 @@ importers: react-github-calendar: specifier: ^5.0.5 version: 5.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react-icons: - specifier: ^5.5.0 - version: 5.5.0(react@19.2.4) react-intersection-observer: specifier: ^10.0.2 version: 10.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2463,11 +2460,6 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 - react-icons@5.5.0: - resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} - peerDependencies: - react: '*' - react-intersection-observer@10.0.2: resolution: {integrity: sha512-lAMzxVWrBko6SLd1jx6l84fVrzJu91hpxHlvD2as2Wec9mDCjdYXwc5xNOFBchpeBir0Y7AGBW+C/AYMa7CSFg==} peerDependencies: @@ -5472,10 +5464,6 @@ snapshots: transitivePeerDependencies: - react-dom - react-icons@5.5.0(react@19.2.4): - dependencies: - react: 19.2.4 - react-intersection-observer@10.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 diff --git a/src/pages/github/CodingProfiles.tsx b/src/pages/github/CodingProfiles.tsx index afad679..82990a1 100644 --- a/src/pages/github/CodingProfiles.tsx +++ b/src/pages/github/CodingProfiles.tsx @@ -1,6 +1,5 @@ import { motion } from "motion/react"; -import { ArrowUpRight, Trophy, Code, Star } from "lucide-react"; -import { FaGithub } from "react-icons/fa6"; +import { ArrowUpRight, Trophy, Code, Star, Github } from "lucide-react"; import { getCodingPlatformStats } from "@data/dataLoader"; import type { CodingPlatformStat } from "@/types"; import { staggerContainer, fadeInUp } from "@utils/animations"; @@ -107,7 +106,7 @@ const CodingProfiles = ({ githubUsername }: CodingProfilesProps) => { whileHover={{ y: -4, borderColor: "rgba(165,165,192,0.3)" }} style={cardStyle} > - + { href={data.github} label="Source" ariaLabel={`View ${data.title} on GitHub`} - icon={FaGithub} + icon={Github} accentColor={colors.accent} /> )} diff --git a/src/utils/iconMap.ts b/src/utils/iconMap.ts index d9d2395..19e9fd6 100644 --- a/src/utils/iconMap.ts +++ b/src/utils/iconMap.ts @@ -1,12 +1,13 @@ -import { FaLinkedin, FaGithub, FaInstagram } from "react-icons/fa6"; -import { SiX } from "react-icons/si"; +import { Linkedin, Github, Instagram, Twitter } from "lucide-react"; import type { IconMap } from "@/types"; +// Keys are the icon identifiers stored in personal.json/contact.json so that +// the JSON data doesn't need to change when we swap icon libraries. const ICON_MAP: IconMap = { - BsLinkedin: FaLinkedin, - FaGithub: FaGithub, - FiInstagram: FaInstagram, - SiX: SiX, + BsLinkedin: Linkedin, + FaGithub: Github, + FiInstagram: Instagram, + SiX: Twitter, }; export default ICON_MAP; diff --git a/vite.config.js b/vite.config.js index 6ca65e7..7421d1a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -33,7 +33,7 @@ export default defineConfig(() => ({ output: { manualChunks: { vendor: ["react", "react-dom"], - icons: ["react-icons", "lucide-react"], + icons: ["lucide-react"], animations: ["motion"], threejs: ["three", "@react-three/fiber", "@react-three/drei"], particles: ["@tsparticles/react", "@tsparticles/slim"], From c0b48c81b11c10a12bdb8c4cd7cdd80e9a16538a Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Fri, 1 May 2026 20:11:34 +0530 Subject: [PATCH 02/12] perf: move analytics scripts out of to end of Google Analytics (gtag) and SimpleAnalytics scripts both relocate from to just before . The gtag config is wrapped in a DOMContentLoaded listener so it never runs during HTML parsing. Both preconnect hints stay in so DNS/TLS for the two analytics origins still warms early without blocking. Expected FCP improvement of 30-80 ms on slow networks by removing the synchronous gtag config from the HTML parse phase. --- index.html | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/index.html b/index.html index 442f190..a0cc5be 100644 --- a/index.html +++ b/index.html @@ -1,14 +1,6 @@ - - - @@ -136,13 +128,9 @@ - + - + Sagar Gupta | DevOps & Cloud Consultant at AWS @@ -158,5 +146,26 @@
+ + + + + From f7eb2b7cee45ffc90e4e7f0ed823dd82c8305d52 Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Fri, 1 May 2026 20:20:45 +0530 Subject: [PATCH 03/12] feat(portfolio): show per-category counts on filter buttons Each filter chip (Featured / Community / Collab / Others / All) now carries a small count badge so visitors can see how many projects live in each bucket before clicking. Empty categories are auto-hidden (All is always kept as a fallback). Counts use tabular-nums so width is stable across 1- and 2-digit values. Includes aria-label per button so screen readers announce the count. --- src/pages/portfolio/Portfolio.tsx | 123 +++++++++++++++++++++--------- 1 file changed, 89 insertions(+), 34 deletions(-) diff --git a/src/pages/portfolio/Portfolio.tsx b/src/pages/portfolio/Portfolio.tsx index d80f8c9..b431af3 100644 --- a/src/pages/portfolio/Portfolio.tsx +++ b/src/pages/portfolio/Portfolio.tsx @@ -34,6 +34,33 @@ const Portfolio = () => { const collaborativeProjects = useMemo(() => getCollaborativeProjects(), []); const otherProjects = useMemo(() => getOtherProjects(), []); + // Counts per filter — drives both the badge text and the "hide empty" rule. + const counts = useMemo>(() => { + const f = featuredProjects.length; + const c = communityProjects.length; + const x = collaborativeProjects.length; + const o = otherProjects.length; + return { + Featured: f, + Community: c, + Collab: x, + Others: o, + All: f + c + x + o, + }; + }, [ + featuredProjects, + communityProjects, + collaborativeProjects, + otherProjects, + ]); + + // Skip categories with zero items so the filter bar never shows dead options. + // "All" is kept even at zero so the bar still renders with a fallback. + const visibleFilters = useMemo( + () => FILTERS.filter((f) => f === "All" || (counts[f] ?? 0) > 0), + [counts], + ); + const filteredProjects = useMemo(() => { const featured = featuredProjects.map((p) => ({ ...p, @@ -84,40 +111,68 @@ const Portfolio = () => { }} variants={fadeInUp} > - {FILTERS.map((filter, idx) => ( - handleFilterChange(filter)} - className={activeFilter === filter ? "btn-primary" : ""} - style={ - activeFilter === filter - ? {} - : { - padding: "8px 20px", - borderRadius: 12, - fontSize: 14, - fontFamily: MONO_FONT, - fontWeight: 500, - cursor: "pointer", - border: "1px solid rgba(255, 255, 255, 0.06)", - color: "#a5a5c0", - background: "rgba(255, 255, 255, 0.03)", - backdropFilter: "blur(8px)", - } - } - initial={{ opacity: 0, y: 15 }} - animate={{ opacity: 1, y: 0 }} - transition={{ - duration: 0.7, - ease: [0.4, 0, 0.2, 1], - delay: 0.1 + idx * 0.08, - }} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.97 }} - > - {filter} - - ))} + {visibleFilters.map((filter, idx) => { + const isActive = activeFilter === filter; + const count = counts[filter] ?? 0; + return ( + handleFilterChange(filter)} + className={isActive ? "btn-primary" : ""} + style={ + isActive + ? {} + : { + padding: "8px 20px", + borderRadius: 12, + fontSize: 14, + fontFamily: MONO_FONT, + fontWeight: 500, + cursor: "pointer", + border: + "1px solid rgba(255, 255, 255, 0.06)", + color: "#a5a5c0", + background: "rgba(255, 255, 255, 0.03)", + backdropFilter: "blur(8px)", + display: "inline-flex", + alignItems: "center", + gap: 8, + } + } + initial={{ opacity: 0, y: 15 }} + animate={{ opacity: 1, y: 0 }} + transition={{ + duration: 0.7, + ease: [0.4, 0, 0.2, 1], + delay: 0.1 + idx * 0.08, + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.97 }} + aria-label={`${filter} (${count} project${count === 1 ? "" : "s"})`} + > + {filter} + + + ); + })} {/* Vertical timeline */} From 1ccde2007be4d394690dd8e1786a0cb39699bbe9 Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Fri, 1 May 2026 20:25:59 +0530 Subject: [PATCH 04/12] feat(experience): surface current role with pulsing Present indicator Timeline cards whose date range ends in 'Present' now render a green pulsing dot next to the end-date label, turn the center-track node green with a subtle glow ring (desktop), and switch the card's left border to green (mobile). Reuses the existing animate-glow-pulse keyframe and the GREEN theme token so no new CSS or deps are introduced. Adds an isPresent() helper in dateRange.ts for a single source of truth. Accessible via aria-label on the indicator. --- src/pages/experience/TimelineCardDesktop.tsx | 227 +++++++++++-------- src/pages/experience/TimelineCardMobile.tsx | 133 +++++++---- src/utils/dateRange.ts | 9 + 3 files changed, 229 insertions(+), 140 deletions(-) diff --git a/src/pages/experience/TimelineCardDesktop.tsx b/src/pages/experience/TimelineCardDesktop.tsx index 4fc9078..4220f98 100644 --- a/src/pages/experience/TimelineCardDesktop.tsx +++ b/src/pages/experience/TimelineCardDesktop.tsx @@ -2,8 +2,8 @@ import { motion } from "motion/react"; import { MapPin } from "lucide-react"; import type { ProfessionalExperience, PositionOfResponsibility } from "@/types"; import { slideInLeft, slideInRight } from "@utils/animations"; -import { splitDateRange } from "@utils/dateRange"; -import { MONO_FONT } from "@/constants/theme"; +import { splitDateRange, isPresent } from "@utils/dateRange"; +import { MONO_FONT, GREEN } from "@/constants/theme"; import TimelineCardContent from "./TimelineCardContent"; interface TimelineCardDesktopProps { @@ -18,114 +18,153 @@ const TimelineCardDesktop = ({ index, accentColor, onClick, -}: TimelineCardDesktopProps) => ( - - {/* Left: Date + Location */} -
- - {splitDateRange(item.date).start} - - - {item.date.split(" - ").at(1) ?? ""} - - {item.location && ( -

{ + const { start, end } = splitDateRange(item.date); + const active = isPresent(item.date); + + return ( + + {/* Left: Date + Location */} +

+ - - {item.location} -

- )} -
+ {start} + + {active ? ( + + + ) : ( + + {end ?? ""} + + )} + {item.location && ( +

+ + {item.location} +

+ )} +
- {/* Center: Timeline track */} -
+ {/* Center: Timeline track */}
+
+
+
-
-
- {/* Right: Content card */} -
- -
- -); + {/* Right: Content card */} +
+ +
+ + ); +}; export default TimelineCardDesktop; diff --git a/src/pages/experience/TimelineCardMobile.tsx b/src/pages/experience/TimelineCardMobile.tsx index eed0365..97c8965 100644 --- a/src/pages/experience/TimelineCardMobile.tsx +++ b/src/pages/experience/TimelineCardMobile.tsx @@ -2,7 +2,8 @@ import { motion } from "motion/react"; import { MapPin } from "lucide-react"; import type { ProfessionalExperience, PositionOfResponsibility } from "@/types"; import { staggerItem } from "@utils/animations"; -import { MONO_FONT } from "@/constants/theme"; +import { splitDateRange, isPresent } from "@utils/dateRange"; +import { MONO_FONT, GREEN } from "@/constants/theme"; import TimelineCardContent from "./TimelineCardContent"; interface TimelineCardMobileProps { @@ -17,65 +18,105 @@ const TimelineCardMobile = ({ index, accentColor, onClick, -}: TimelineCardMobileProps) => ( - -
{ + const { start, end } = splitDateRange(item.date); + const active = isPresent(item.date); + + return ( + - {/* Mobile date + location row */}
- - {item.date} - - {item.location && ( - - {item.location} + {active ? ( + <> + {start} -{" "} + + + + ) : ( + <> + {start} + {end ? ` - ${end}` : ""} + + )} - )} + {item.location && ( + + + {item.location} + + )} +
+
- -
- -); + + ); +}; export default TimelineCardMobile; diff --git a/src/utils/dateRange.ts b/src/utils/dateRange.ts index 763e661..a0b4caf 100644 --- a/src/utils/dateRange.ts +++ b/src/utils/dateRange.ts @@ -11,3 +11,12 @@ export const splitDateRange = ( const end = parts.length > 1 ? parts[parts.length - 1] : undefined; return { start, end }; }; + +/** + * True when a date range ends in "Present" (case-insensitive, trimmed). + * Used by timeline cards to surface a live "active" indicator on current roles. + */ +export const isPresent = (range: string): boolean => { + const { end } = splitDateRange(range); + return end?.trim().toLowerCase() === "present"; +}; From a6ad55cd723a20140674d5e383a65321b1d3937b Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Fri, 1 May 2026 21:01:48 +0530 Subject: [PATCH 05/12] feat(portfolio): click-to-open project detail modal Every project card now opens a centered modal showing the full feature list, contributors, and links. Uses createPortal to document.body so transforms on PageSection do not break fixed positioning. Shell styling matches ExperienceModal (backdrop blur, gradient bg, 720px max width, cinematic easing, cyan ambient glow). Interior accent colors vary by category (Featured/Community/Collab/Others). Subtle 'Click for details' hint on cards with detail content. Keyboard: Enter/Space opens, Esc closes, focus trap and body-scroll lock reused via useFocusTrap. Source and Live Demo links remain on the card and stop propagation so they do not trigger the modal. --- src/pages/portfolio/Portfolio.tsx | 19 +- src/pages/portfolio/ProjectCard.tsx | 42 +- src/pages/portfolio/ProjectLink.tsx | 1 + src/pages/portfolio/ProjectModal.tsx | 529 ++++++++++++++++++++++++ src/pages/portfolio/ProjectTimeline.tsx | 13 +- 5 files changed, 600 insertions(+), 4 deletions(-) create mode 100644 src/pages/portfolio/ProjectModal.tsx diff --git a/src/pages/portfolio/Portfolio.tsx b/src/pages/portfolio/Portfolio.tsx index b431af3..94d630d 100644 --- a/src/pages/portfolio/Portfolio.tsx +++ b/src/pages/portfolio/Portfolio.tsx @@ -14,9 +14,12 @@ import { FILTERS, parseDate } from "./portfolioConstants"; import type { ProjectWithCategory } from "./portfolioConstants"; import ProjectTimeline from "./ProjectTimeline"; import OpenSourceBanner from "./OpenSourceBanner"; +import ProjectModal from "./ProjectModal"; const Portfolio = () => { const [activeFilter, setActiveFilter] = useState("Featured"); + const [selectedProject, setSelectedProject] = + useState(null); const handleFilterChange = useCallback((filter: string) => { if (document.startViewTransition) { @@ -94,6 +97,11 @@ const Portfolio = () => { otherProjects, ]); + const handleOpenProject = useCallback( + (project: ProjectWithCategory) => setSelectedProject(project), + [], + ); + return (
{ {/* Vertical timeline */} - + {/* Open Source Contributions Banner */}
+ + setSelectedProject(null)} + />
); }; diff --git a/src/pages/portfolio/ProjectCard.tsx b/src/pages/portfolio/ProjectCard.tsx index 508903e..d779345 100644 --- a/src/pages/portfolio/ProjectCard.tsx +++ b/src/pages/portfolio/ProjectCard.tsx @@ -12,15 +12,30 @@ import ProjectCardHeader from "./ProjectCardHeader"; interface ProjectCardProps { data: ProjectWithCategory; index?: number; + onOpen?: () => void; } -const ProjectCard = ({ data, index = 0 }: ProjectCardProps) => { +const ProjectCard = ({ data, index = 0, onOpen }: ProjectCardProps) => { const hasGithub = data.github && data.github !== "" && data.github !== "#"; const hasLive = data.live && data.live !== "" && data.live !== "#"; const colors = CATEGORY_COLORS[data.category] || CATEGORY_COLORS.Others; const isFeatured = data.category === "Featured"; const isCollab = data.category === "Collab"; + const hasDetail = + (data.features?.length ?? 0) > 0 || + (data.contributors?.length ?? 0) > 0 || + Boolean(data.description); + const clickable = hasDetail && Boolean(onOpen); + + const handleKey = (e: React.KeyboardEvent) => { + if (!clickable) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen?.(); + } + }; + return ( { overflow: "hidden", display: "flex", flexDirection: "column", + cursor: clickable ? "pointer" : "default", }} layout variants={scaleRotateIn} @@ -51,6 +67,13 @@ const ProjectCard = ({ data, index = 0 }: ProjectCardProps) => { y: -6, transition: { duration: 0.4, ease: [0.4, 0, 0.2, 1] }, }} + onClick={clickable ? onOpen : undefined} + onKeyDown={clickable ? handleKey : undefined} + role={clickable ? "button" : undefined} + tabIndex={clickable ? 0 : undefined} + aria-label={ + clickable ? `View details for ${data.title}` : undefined + } > {/* Accent top bar */}
{ ))}
+ {/* Click-for-details hint (subtle, shown only when there's real detail to see) */} + {clickable && ( + + )} + {/* Links */} {(hasGithub || hasLive) && (
e.stopPropagation()} style={{ display: "inline-flex", alignItems: "center", diff --git a/src/pages/portfolio/ProjectModal.tsx b/src/pages/portfolio/ProjectModal.tsx new file mode 100644 index 0000000..7331d75 --- /dev/null +++ b/src/pages/portfolio/ProjectModal.tsx @@ -0,0 +1,529 @@ +import { useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { motion, AnimatePresence } from "motion/react"; +import { + X, + ExternalLink, + Github, + Calendar, + Users, + Star, + FolderGit2, + Sparkles, +} from "lucide-react"; +import useBreakpoint from "@hooks/useBreakpoint"; +import useFocusTrap from "@hooks/useFocusTrap"; +import TechTag from "@components/ui/TechTag"; +import { + EASING, + TEXT_PRIMARY, + TEXT_SECONDARY, + TEXT_MUTED, + MONO_FONT, +} from "@/constants/theme"; +import { + CATEGORY_COLORS, + type ProjectWithCategory, +} from "./portfolioConstants"; + +interface ProjectModalProps { + project: ProjectWithCategory | null; + onClose: () => void; +} + +const EXPO_EASE = [0.16, 1, 0.3, 1] as const; + +const ProjectModal = ({ project, onClose }: ProjectModalProps) => { + const { isMobile } = useBreakpoint(); + const dialogRef = useFocusTrap(project !== null); + + const onEsc = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }, + [onClose], + ); + + useEffect(() => { + if (!project) return; + document.body.style.overflow = "hidden"; + document.addEventListener("keydown", onEsc); + return () => { + document.body.style.overflow = ""; + document.removeEventListener("keydown", onEsc); + }; + }, [project, onEsc]); + + const colors = project + ? CATEGORY_COLORS[project.category] || CATEGORY_COLORS.Others + : CATEGORY_COLORS.Others; + const isFeatured = project?.category === "Featured"; + const features = project?.features ?? []; + const contributors = project?.contributors ?? []; + const hasGithub = + project && + project.github && + project.github !== "" && + project.github !== "#"; + const hasLive = + project && project.live && project.live !== "" && project.live !== "#"; + + return createPortal( + + {project && ( + + e.stopPropagation()} + onWheel={(e) => e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="project-modal-title" + tabIndex={-1} + style={{ + position: "relative", + width: "100%", + maxWidth: isMobile ? "100%" : 720, + maxHeight: isMobile ? "88vh" : "85vh", + overflowY: "auto", + borderRadius: 20, + border: "1px solid rgba(255,255,255,0.1)", + background: + "linear-gradient(180deg, rgba(15,15,30,0.98) 0%, rgba(8,8,20,0.99) 100%)", + boxShadow: + "0 30px 80px rgba(0,0,0,0.6), 0 0 60px rgba(6,182,212,0.04)", + }} + > + {/* Category accent bar */} +
+ + {/* Sticky header */} + +
+ {isFeatured ? ( + + ) : ( + + )} +

+ {project.title} +

+ +
+ +
+ + + {project.date} + + {project.team && ( + + + {project.team} + + )} + + {project.category} + +
+
+ + {/* Body */} +
+ {/* Description */} + + {project.description} + + + {/* Tech stack */} + {project.tools_tech.length > 0 && ( + +

+ Tech Stack +

+
+ {project.tools_tech.map((t) => ( + + ))} +
+
+ )} + + {/* Features */} + {features.length > 0 && ( + +

+ + Key Features +

+
    + {features.map((f) => ( +
  • +
  • + ))} +
+
+ )} + + {/* Contributors (collab projects) */} + {contributors.length > 0 && ( + +

+ Contributors +

+
+ {contributors.map((c) => ( + + {c} + + ))} +
+
+ )} + + {/* Links */} + {(hasGithub || hasLive) && ( + + {hasGithub && ( + + + View Source + + )} + {hasLive && ( + + + Live Demo + + )} + + )} +
+ + + )} + , + document.body, + ); +}; + +export default ProjectModal; diff --git a/src/pages/portfolio/ProjectTimeline.tsx b/src/pages/portfolio/ProjectTimeline.tsx index 27a24d2..43527f4 100644 --- a/src/pages/portfolio/ProjectTimeline.tsx +++ b/src/pages/portfolio/ProjectTimeline.tsx @@ -6,9 +6,14 @@ import ProjectCard from "./ProjectCard"; interface ProjectTimelineProps { projects: ProjectWithCategory[]; isMobile: boolean; + onOpenProject: (project: ProjectWithCategory) => void; } -const ProjectTimeline = ({ projects, isMobile }: ProjectTimelineProps) => ( +const ProjectTimeline = ({ + projects, + isMobile, + onOpenProject, +}: ProjectTimelineProps) => (
( {/* Card */}
- + onOpenProject(project)} + />
); From 567438d663a895413265b18450f67ad8a3f88cfb Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Fri, 1 May 2026 22:01:15 +0530 Subject: [PATCH 06/12] Revert "perf: replace react-icons with lucide-react" This reverts commit d681e445b9682e0b0b0b70ac45f25e6bd58743cc. --- package.json | 1 + pnpm-lock.yaml | 12 ++++++++++++ src/pages/github/CodingProfiles.tsx | 5 +++-- src/pages/portfolio/ProjectCard.tsx | 5 +++-- src/utils/iconMap.ts | 13 ++++++------- vite.config.js | 2 +- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index c082139..1f4bf3e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-github-calendar": "^5.0.5", + "react-icons": "^5.5.0", "react-intersection-observer": "^10.0.2", "tailwindcss": "^4.1.18", "three": "^0.183.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cd68d2..a567551 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: react-github-calendar: specifier: ^5.0.5 version: 5.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.2.4) react-intersection-observer: specifier: ^10.0.2 version: 10.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2460,6 +2463,11 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + react-intersection-observer@10.0.2: resolution: {integrity: sha512-lAMzxVWrBko6SLd1jx6l84fVrzJu91hpxHlvD2as2Wec9mDCjdYXwc5xNOFBchpeBir0Y7AGBW+C/AYMa7CSFg==} peerDependencies: @@ -5464,6 +5472,10 @@ snapshots: transitivePeerDependencies: - react-dom + react-icons@5.5.0(react@19.2.4): + dependencies: + react: 19.2.4 + react-intersection-observer@10.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 diff --git a/src/pages/github/CodingProfiles.tsx b/src/pages/github/CodingProfiles.tsx index 82990a1..afad679 100644 --- a/src/pages/github/CodingProfiles.tsx +++ b/src/pages/github/CodingProfiles.tsx @@ -1,5 +1,6 @@ import { motion } from "motion/react"; -import { ArrowUpRight, Trophy, Code, Star, Github } from "lucide-react"; +import { ArrowUpRight, Trophy, Code, Star } from "lucide-react"; +import { FaGithub } from "react-icons/fa6"; import { getCodingPlatformStats } from "@data/dataLoader"; import type { CodingPlatformStat } from "@/types"; import { staggerContainer, fadeInUp } from "@utils/animations"; @@ -106,7 +107,7 @@ const CodingProfiles = ({ githubUsername }: CodingProfilesProps) => { whileHover={{ y: -4, borderColor: "rgba(165,165,192,0.3)" }} style={cardStyle} > - + { href={data.github} label="Source" ariaLabel={`View ${data.title} on GitHub`} - icon={Github} + icon={FaGithub} accentColor={colors.accent} /> )} diff --git a/src/utils/iconMap.ts b/src/utils/iconMap.ts index 19e9fd6..d9d2395 100644 --- a/src/utils/iconMap.ts +++ b/src/utils/iconMap.ts @@ -1,13 +1,12 @@ -import { Linkedin, Github, Instagram, Twitter } from "lucide-react"; +import { FaLinkedin, FaGithub, FaInstagram } from "react-icons/fa6"; +import { SiX } from "react-icons/si"; import type { IconMap } from "@/types"; -// Keys are the icon identifiers stored in personal.json/contact.json so that -// the JSON data doesn't need to change when we swap icon libraries. const ICON_MAP: IconMap = { - BsLinkedin: Linkedin, - FaGithub: Github, - FiInstagram: Instagram, - SiX: Twitter, + BsLinkedin: FaLinkedin, + FaGithub: FaGithub, + FiInstagram: FaInstagram, + SiX: SiX, }; export default ICON_MAP; diff --git a/vite.config.js b/vite.config.js index 7421d1a..6ca65e7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -33,7 +33,7 @@ export default defineConfig(() => ({ output: { manualChunks: { vendor: ["react", "react-dom"], - icons: ["lucide-react"], + icons: ["react-icons", "lucide-react"], animations: ["motion"], threejs: ["three", "@react-three/fiber", "@react-three/drei"], particles: ["@tsparticles/react", "@tsparticles/slim"], From 170e0f51e82ebbd6f13dace53071c84010a322d7 Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Fri, 1 May 2026 22:02:28 +0530 Subject: [PATCH 07/12] fix(portfolio): align ProjectModal with ExperienceModal + unify react-icons on fa6 ProjectModal parity with ExperienceModal's mobile UX: - Bottom-sheet alignment on mobile (flex-end, padding 0, top-rounded only) - Slide-up-from-bottom animation (y: 100) matching ExperienceModal - 92vh mobile max height (was 88vh) for parity Cleanups in the same pass: - Swap lucide Github (deprecated brand icon) for react-icons FaGithub to match the rest of the codebase post-revert - Unify ProjectCard on react-icons/fa6 (was /fa) for visual consistency with iconMap/CodingProfiles - Collapse identical ternary on accent-bar borderRadius - Use optional chaining on hasGithub/hasLive guards --- src/pages/portfolio/ProjectCard.tsx | 2 +- src/pages/portfolio/ProjectModal.tsx | 33 ++++++++++++++++------------ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/pages/portfolio/ProjectCard.tsx b/src/pages/portfolio/ProjectCard.tsx index e44d904..27948d0 100644 --- a/src/pages/portfolio/ProjectCard.tsx +++ b/src/pages/portfolio/ProjectCard.tsx @@ -1,6 +1,6 @@ import { motion } from "motion/react"; import { ExternalLink } from "lucide-react"; -import { FaGithub } from "react-icons/fa"; +import { FaGithub } from "react-icons/fa6"; import { scaleRotateIn } from "@utils/animations"; import { MONO_FONT } from "@/constants/theme"; import { diff --git a/src/pages/portfolio/ProjectModal.tsx b/src/pages/portfolio/ProjectModal.tsx index 7331d75..1155f14 100644 --- a/src/pages/portfolio/ProjectModal.tsx +++ b/src/pages/portfolio/ProjectModal.tsx @@ -4,13 +4,13 @@ import { motion, AnimatePresence } from "motion/react"; import { X, ExternalLink, - Github, Calendar, Users, Star, FolderGit2, Sparkles, } from "lucide-react"; +import { FaGithub } from "react-icons/fa6"; import useBreakpoint from "@hooks/useBreakpoint"; import useFocusTrap from "@hooks/useFocusTrap"; import TechTag from "@components/ui/TechTag"; @@ -61,12 +61,9 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => { const features = project?.features ?? []; const contributors = project?.contributors ?? []; const hasGithub = - project && - project.github && - project.github !== "" && - project.github !== "#"; + project?.github && project.github !== "" && project.github !== "#"; const hasLive = - project && project.live && project.live !== "" && project.live !== "#"; + project?.live && project.live !== "" && project.live !== "#"; return createPortal( @@ -82,9 +79,9 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => { inset: 0, zIndex: 1000, display: "flex", - alignItems: "center", + alignItems: isMobile ? "flex-end" : "center", justifyContent: "center", - padding: isMobile ? 16 : 20, + padding: isMobile ? 0 : 20, background: "rgba(0,0,0,0.3)", backdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)", @@ -93,9 +90,17 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => { > e.stopPropagation()} onWheel={(e) => e.stopPropagation()} @@ -107,9 +112,9 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => { position: "relative", width: "100%", maxWidth: isMobile ? "100%" : 720, - maxHeight: isMobile ? "88vh" : "85vh", + maxHeight: isMobile ? "92vh" : "85vh", overflowY: "auto", - borderRadius: 20, + borderRadius: isMobile ? "20px 20px 0 0" : 20, border: "1px solid rgba(255,255,255,0.1)", background: "linear-gradient(180deg, rgba(15,15,30,0.98) 0%, rgba(8,8,20,0.99) 100%)", @@ -122,7 +127,7 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => { style={{ height: isFeatured ? 4 : 3, background: colors.gradient, - borderRadius: isMobile ? "20px 20px 0 0" : "20px 20px 0 0", + borderRadius: "20px 20px 0 0", }} /> @@ -486,7 +491,7 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => { }} aria-label={`View ${project.title} on GitHub`} > - + View Source )} From 93ce4a695365832e8edf633d09948746c1860f0f Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Fri, 1 May 2026 22:23:21 +0530 Subject: [PATCH 08/12] refactor(portfolio): address SonarQube findings in ProjectModal + card Split ProjectModal into three files to match ExperienceModal's Modal/ModalHeader/ModalContent decomposition, which also drops the cognitive complexity from 23 below SonarQube's 15 threshold (S3776): - ProjectModal.tsx (129 lines, shell + portal only) - ProjectModalHeader.tsx (new, sticky header) - ProjectModalBody.tsx (new, description/stack/features/contributors/links) Extract two helpers in portfolioConstants.ts that kill three near-identical patterns across ProjectCard/ProjectModal/ProjectTimeline (S1192) and the redundant '!== ""' empty-string checks (S1764): - getCategoryColors(category) -- replaces three copies of CATEGORY_COLORS[x] || CATEGORY_COLORS.Others and switches || to ?? - isValidUrl(url) -- replaces hasGithub/hasLive guards --- src/pages/portfolio/ProjectCard.tsx | 9 +- src/pages/portfolio/ProjectModal.tsx | 438 +-------------------- src/pages/portfolio/ProjectModalBody.tsx | 234 +++++++++++ src/pages/portfolio/ProjectModalHeader.tsx | 148 +++++++ src/pages/portfolio/ProjectTimeline.tsx | 5 +- src/pages/portfolio/portfolioConstants.ts | 8 + 6 files changed, 414 insertions(+), 428 deletions(-) create mode 100644 src/pages/portfolio/ProjectModalBody.tsx create mode 100644 src/pages/portfolio/ProjectModalHeader.tsx diff --git a/src/pages/portfolio/ProjectCard.tsx b/src/pages/portfolio/ProjectCard.tsx index 27948d0..048d6e4 100644 --- a/src/pages/portfolio/ProjectCard.tsx +++ b/src/pages/portfolio/ProjectCard.tsx @@ -4,7 +4,8 @@ import { FaGithub } from "react-icons/fa6"; import { scaleRotateIn } from "@utils/animations"; import { MONO_FONT } from "@/constants/theme"; import { - CATEGORY_COLORS, + getCategoryColors, + isValidUrl, type ProjectWithCategory, } from "./portfolioConstants"; import ProjectLink from "./ProjectLink"; @@ -17,9 +18,9 @@ interface ProjectCardProps { } const ProjectCard = ({ data, index = 0, onOpen }: ProjectCardProps) => { - const hasGithub = data.github && data.github !== "" && data.github !== "#"; - const hasLive = data.live && data.live !== "" && data.live !== "#"; - const colors = CATEGORY_COLORS[data.category] || CATEGORY_COLORS.Others; + const hasGithub = isValidUrl(data.github); + const hasLive = isValidUrl(data.live); + const colors = getCategoryColors(data.category); const isFeatured = data.category === "Featured"; const isCollab = data.category === "Collab"; diff --git a/src/pages/portfolio/ProjectModal.tsx b/src/pages/portfolio/ProjectModal.tsx index 1155f14..59a002d 100644 --- a/src/pages/portfolio/ProjectModal.tsx +++ b/src/pages/portfolio/ProjectModal.tsx @@ -1,38 +1,21 @@ import { useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { motion, AnimatePresence } from "motion/react"; -import { - X, - ExternalLink, - Calendar, - Users, - Star, - FolderGit2, - Sparkles, -} from "lucide-react"; -import { FaGithub } from "react-icons/fa6"; import useBreakpoint from "@hooks/useBreakpoint"; import useFocusTrap from "@hooks/useFocusTrap"; -import TechTag from "@components/ui/TechTag"; -import { - EASING, - TEXT_PRIMARY, - TEXT_SECONDARY, - TEXT_MUTED, - MONO_FONT, -} from "@/constants/theme"; +import { EASING } from "@/constants/theme"; import { - CATEGORY_COLORS, + getCategoryColors, type ProjectWithCategory, } from "./portfolioConstants"; +import ProjectModalHeader from "./ProjectModalHeader"; +import ProjectModalBody from "./ProjectModalBody"; interface ProjectModalProps { project: ProjectWithCategory | null; onClose: () => void; } -const EXPO_EASE = [0.16, 1, 0.3, 1] as const; - const ProjectModal = ({ project, onClose }: ProjectModalProps) => { const { isMobile } = useBreakpoint(); const dialogRef = useFocusTrap(project !== null); @@ -55,15 +38,9 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => { }, [project, onEsc]); const colors = project - ? CATEGORY_COLORS[project.category] || CATEGORY_COLORS.Others - : CATEGORY_COLORS.Others; + ? getCategoryColors(project.category) + : getCategoryColors("Others"); const isFeatured = project?.category === "Featured"; - const features = project?.features ?? []; - const contributors = project?.contributors ?? []; - const hasGithub = - project?.github && project.github !== "" && project.github !== "#"; - const hasLive = - project?.live && project.live !== "" && project.live !== "#"; return createPortal( @@ -131,398 +108,17 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => { }} /> - {/* Sticky header */} - -
- {isFeatured ? ( - - ) : ( - - )} -

- {project.title} -

- -
- -
- - - {project.date} - - {project.team && ( - - - {project.team} - - )} - - {project.category} - -
-
- - {/* Body */} -
- {/* Description */} - - {project.description} - - - {/* Tech stack */} - {project.tools_tech.length > 0 && ( - -

- Tech Stack -

-
- {project.tools_tech.map((t) => ( - - ))} -
-
- )} - - {/* Features */} - {features.length > 0 && ( - -

- - Key Features -

-
    - {features.map((f) => ( -
  • -
  • - ))} -
-
- )} - - {/* Contributors (collab projects) */} - {contributors.length > 0 && ( - -

- Contributors -

-
- {contributors.map((c) => ( - - {c} - - ))} -
-
- )} - - {/* Links */} - {(hasGithub || hasLive) && ( - - {hasGithub && ( - - - View Source - - )} - {hasLive && ( - - - Live Demo - - )} - - )} -
+ +
)} diff --git a/src/pages/portfolio/ProjectModalBody.tsx b/src/pages/portfolio/ProjectModalBody.tsx new file mode 100644 index 0000000..627028f --- /dev/null +++ b/src/pages/portfolio/ProjectModalBody.tsx @@ -0,0 +1,234 @@ +import { motion } from "motion/react"; +import { ExternalLink, Sparkles } from "lucide-react"; +import { FaGithub } from "react-icons/fa6"; +import TechTag from "@components/ui/TechTag"; +import { + TEXT_PRIMARY, + TEXT_SECONDARY, + TEXT_MUTED, + MONO_FONT, +} from "@/constants/theme"; +import { + isValidUrl, + type CategoryColors, + type ProjectWithCategory, +} from "./portfolioConstants"; + +interface ProjectModalBodyProps { + project: ProjectWithCategory; + colors: CategoryColors; + isMobile: boolean; +} + +const EXPO_EASE = [0.16, 1, 0.3, 1] as const; + +const linkButtonStyle = (accent: string): React.CSSProperties => ({ + display: "inline-flex", + alignItems: "center", + gap: 6, + padding: "8px 14px", + marginTop: 12, + borderRadius: 10, + fontSize: 12, + fontWeight: 600, + color: TEXT_PRIMARY, + border: `1px solid ${accent}40`, + background: `${accent}10`, + textDecoration: "none", +}); + +const sectionLabelStyle: React.CSSProperties = { + fontSize: 11, + color: TEXT_MUTED, + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.06em", + marginBottom: 8, +}; + +const ProjectModalBody = ({ + project, + colors, + isMobile, +}: ProjectModalBodyProps) => { + const features = project.features ?? []; + const contributors = project.contributors ?? []; + const hasGithub = isValidUrl(project.github); + const hasLive = isValidUrl(project.live); + + return ( +
+ {/* Description */} + + {project.description} + + + {/* Tech stack */} + {project.tools_tech.length > 0 && ( + +

Tech Stack

+
+ {project.tools_tech.map((t) => ( + + ))} +
+
+ )} + + {/* Features */} + {features.length > 0 && ( + +

+ + Key Features +

+
    + {features.map((f) => ( +
  • +
  • + ))} +
+
+ )} + + {/* Contributors (collab projects) */} + {contributors.length > 0 && ( + +

Contributors

+
+ {contributors.map((c) => ( + + {c} + + ))} +
+
+ )} + + {/* Links */} + {(hasGithub || hasLive) && ( + + {hasGithub && ( + + + View Source + + )} + {hasLive && ( + + + Live Demo + + )} + + )} +
+ ); +}; + +export default ProjectModalBody; diff --git a/src/pages/portfolio/ProjectModalHeader.tsx b/src/pages/portfolio/ProjectModalHeader.tsx new file mode 100644 index 0000000..215b4a6 --- /dev/null +++ b/src/pages/portfolio/ProjectModalHeader.tsx @@ -0,0 +1,148 @@ +import { motion } from "motion/react"; +import { X, Calendar, Users, Star, FolderGit2 } from "lucide-react"; +import { + TEXT_PRIMARY, + TEXT_SECONDARY, + TEXT_MUTED, + MONO_FONT, +} from "@/constants/theme"; +import type { CategoryColors, ProjectWithCategory } from "./portfolioConstants"; + +interface ProjectModalHeaderProps { + project: ProjectWithCategory; + colors: CategoryColors; + isMobile: boolean; + onClose: () => void; +} + +const EXPO_EASE = [0.16, 1, 0.3, 1] as const; + +const ProjectModalHeader = ({ + project, + colors, + isMobile, + onClose, +}: ProjectModalHeaderProps) => { + const isFeatured = project.category === "Featured"; + const IconComponent = isFeatured ? Star : FolderGit2; + + return ( + +
+ +

+ {project.title} +

+ +
+ +
+ + + {project.date} + + {project.team && ( + + + {project.team} + + )} + + {project.category} + +
+
+ ); +}; + +export default ProjectModalHeader; diff --git a/src/pages/portfolio/ProjectTimeline.tsx b/src/pages/portfolio/ProjectTimeline.tsx index 43527f4..cf394b1 100644 --- a/src/pages/portfolio/ProjectTimeline.tsx +++ b/src/pages/portfolio/ProjectTimeline.tsx @@ -1,5 +1,5 @@ import { motion, AnimatePresence } from "motion/react"; -import { CATEGORY_COLORS } from "./portfolioConstants"; +import { getCategoryColors } from "./portfolioConstants"; import type { ProjectWithCategory } from "./portfolioConstants"; import ProjectCard from "./ProjectCard"; @@ -41,8 +41,7 @@ const ProjectTimeline = ({ {projects.map((project, idx) => { const isLeft = !isMobile && idx % 2 === 0; - const colors = - CATEGORY_COLORS[project.category] || CATEGORY_COLORS.Others; + const colors = getCategoryColors(project.category); let pl: number | string = 24; if (!isMobile) pl = isLeft ? 0 : "calc(50% + 20px)"; diff --git a/src/pages/portfolio/portfolioConstants.ts b/src/pages/portfolio/portfolioConstants.ts index baba093..f8526c7 100644 --- a/src/pages/portfolio/portfolioConstants.ts +++ b/src/pages/portfolio/portfolioConstants.ts @@ -63,6 +63,14 @@ export const CATEGORY_COLORS: Record = { }, }; +/** Resolve a category string to its palette, falling back to "Others". */ +export const getCategoryColors = (category: string): CategoryColors => + CATEGORY_COLORS[category] ?? CATEGORY_COLORS.Others; + +/** True for real project URLs -- excludes empty strings and the "#" placeholder used in JSON data. */ +export const isValidUrl = (url: string | undefined): url is string => + !!url && url !== "#"; + export interface ProjectWithCategory extends Project { category: string; achievement?: string; From f9f4dadc52c140391c849d89ed17276fed4f3df5 Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Fri, 1 May 2026 22:47:04 +0530 Subject: [PATCH 09/12] fix: address SonarCloud quality gate failures SonarCloud findings on PR #175: - Ambiguous JSX spacing after (S6853) in TimelineCardDesktop and TimelineCardMobile: wrap 'Present' text in an explicit so Sonar knows the layout gap is flex-gap, not whitespace. - Prefer globalThis over window (S7773) in index.html: swap the three window.* references in the gtag bootstrapper. - Missing SRI on external scripts (S5725) in index.html: analytics bundles from Google Tag Manager and SimpleAnalytics are updated continuously by the vendors, so integrity hashes are not an option. Add crossorigin=anonymous and a NOSONAR justification comment. - Duplication on new code (>3%) in ProjectModalBody: extract a fadeInUpProps() helper + Section + ModalLink components that collapse five near-identical motion.div blocks and two link blocks. --- index.html | 15 +- src/pages/experience/TimelineCardDesktop.tsx | 2 +- src/pages/experience/TimelineCardMobile.tsx | 2 +- src/pages/portfolio/ProjectModalBody.tsx | 152 +++++++++++-------- 4 files changed, 102 insertions(+), 69 deletions(-) diff --git a/index.html b/index.html index a0cc5be..d859cf9 100644 --- a/index.html +++ b/index.html @@ -147,21 +147,28 @@
- + - -