From 7e19cf642e7768ae9b399c6e4ff368b634440dbb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 21:33:35 +0000 Subject: [PATCH 01/27] Redesign portfolio into a unified single-page experience --- .github/workflows/deploy-dev.yml | 36 ++++ Makefile | 7 +- firebase.json | 142 +++++++++++----- src/app/page.tsx | 115 ++++--------- src/app/positions/page.tsx | 53 ------ src/app/projects/page.tsx | 63 ------- src/components/about-section.tsx | 26 +++ src/components/certifications-section.tsx | 66 ++++++++ src/components/education-section.tsx | 56 ++++++ src/components/experience-section.tsx | 34 ++++ src/components/filter-base.tsx | 61 ------- src/components/header.tsx | 198 ++++++++-------------- src/components/projects-section.tsx | 41 +++++ src/components/section.tsx | 14 +- src/components/skills-section.tsx | 64 +++++++ src/components/unified-filter-bar.tsx | 69 ++++++++ src/contexts/filter.tsx | 56 +++--- src/data/certifications.tsx | 10 ++ src/data/skillAreas.ts | 22 +++ src/filter.tsx | 72 ++++++-- src/types.ts | 2 + test-results/.last-run.json | 4 + 22 files changed, 725 insertions(+), 486 deletions(-) create mode 100644 .github/workflows/deploy-dev.yml delete mode 100644 src/app/positions/page.tsx delete mode 100644 src/app/projects/page.tsx create mode 100644 src/components/about-section.tsx create mode 100644 src/components/certifications-section.tsx create mode 100644 src/components/education-section.tsx create mode 100644 src/components/experience-section.tsx delete mode 100644 src/components/filter-base.tsx create mode 100644 src/components/projects-section.tsx create mode 100644 src/components/skills-section.tsx create mode 100644 src/components/unified-filter-bar.tsx create mode 100644 src/data/skillAreas.ts create mode 100644 test-results/.last-run.json diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..24bc306 --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,36 @@ +name: Deploy to Firebase (Dev) + +on: + workflow_dispatch: + inputs: + ref: + description: 'Branch or commit SHA to deploy' + required: false + default: 'main' + +jobs: + deploy: + runs-on: ubuntu-latest + environment: dev + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref }} + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + - name: Install dependencies + run: make install + - name: Install Firebase CLI + run: make firebase + - name: Deploy to Firebase Dev + run: make deploy-dev + env: + FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} + +# NOTE: Before this workflow runs successfully, the Firebase dev hosting site must be created +# and the .firebaserc must map the dev target to its Firebase site name: +# firebase target:apply hosting dev +# This updates .firebaserc which should be committed to the repo. diff --git a/Makefile b/Makefile index 23c7b68..72727c9 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,10 @@ firebase: npm install -g firebase-tools deploy: build - firebase deploy --only hosting + firebase deploy --only hosting:prod + +deploy-dev: build + firebase deploy --only hosting:dev clean: - rm -rf node_modules .next out \ No newline at end of file + rm -rf node_modules .next out diff --git a/firebase.json b/firebase.json index 4715c0a..f5b878b 100644 --- a/firebase.json +++ b/firebase.json @@ -1,48 +1,98 @@ { - "hosting": { - "public": "out", - "ignore": [ - "firebase.json", - "**/.*", - "**/node_modules/**", - "**/Makefile" - ], - "rewrites": [ - { - "source": "**", - "destination": "/index.html" - } - ], - "headers": [ - { - "source": "**", - "headers": [ - { - "key": "X-Content-Type-Options", - "value": "nosniff" - }, - { - "key": "X-Frame-Options", - "value": "DENY" - }, - { - "key": "Referrer-Policy", - "value": "strict-origin-when-cross-origin" - }, - { - "key": "Permissions-Policy", - "value": "camera=(), microphone=(), geolocation=()" - }, - { - "key": "Strict-Transport-Security", - "value": "max-age=63072000; includeSubDomains; preload" - }, - { - "key": "Content-Security-Policy", - "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://va.vercel-scripts.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://www.google-analytics.com https://*.google-analytics.com https://*.analytics.google.com; upgrade-insecure-requests;" - } - ] - } - ] - } + "hosting": [ + { + "target": "prod", + "public": "out", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**", + "**/Makefile" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ], + "headers": [ + { + "source": "**", + "headers": [ + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=()" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=63072000; includeSubDomains; preload" + }, + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://va.vercel-scripts.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://www.google-analytics.com https://*.google-analytics.com https://*.analytics.google.com; upgrade-insecure-requests;" + } + ] + } + ] + }, + { + "target": "dev", + "public": "out", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**", + "**/Makefile" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ], + "headers": [ + { + "source": "**", + "headers": [ + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=()" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=63072000; includeSubDomains; preload" + }, + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://va.vercel-scripts.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://www.google-analytics.com https://*.google-analytics.com https://*.analytics.google.com; upgrade-insecure-requests;" + } + ] + } + ] + } + ] } diff --git a/src/app/page.tsx b/src/app/page.tsx index c422cdf..98be346 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,92 +1,37 @@ -import dynamic from "next/dynamic"; -import Image from "next/image"; +"use client"; +import dynamic from "next/dynamic"; import { Banner } from "@/components/banner"; import { MainHeader } from "@/components/header"; -import { Section } from "@/components/section"; -import certificates from "@/data/certifications"; -import degrees from "@/data/degrees"; -import skills from "@/data/skills"; -import type { Certification, Degree, Skill } from "@/types"; -import "@/types"; - -const Intro = dynamic(() => import("@/components/intro")); - -const Skills = () => ( -
- {Object.values(skills).map((skill: Skill) => ( -
-

{skill.icon}

-

{skill.name}

-
- ))} -
-); - -const Certifications = () => ( -
- {certificates.map((certificate: Certification) => ( -
- -
- {`Badge -

{certificate.title}

-

{certificate.organization.name}

-

{certificate.date}

-
-
-
- ))} -
-); +import { UnifiedFilterBar } from "@/components/unified-filter-bar"; +import { SkillsSection } from "@/components/skills-section"; +import { CertificationsSection } from "@/components/certifications-section"; +import { EducationSection } from "@/components/education-section"; +import { ProjectsSection } from "@/components/projects-section"; +import { ExperienceSection } from "@/components/experience-section"; +import { AboutSection } from "@/components/about-section"; -const Degrees = () => ( -
- {degrees.map((degree: Degree) => ( -
- -
- {`${degree.university.name} -

- {degree.title} -

-

- {degree.university.name} -

-

{degree.duration}

-
-
-
- ))} -
-); +const Intro = dynamic(() => import("@/components/intro"), { ssr: false }); -const Home = () => ( - <> - - - - - - - -); +const Home = () => { + return ( +
+ + + + +
+
+ + + + + + +
+
+
+ ); +}; export default Home; diff --git a/src/app/positions/page.tsx b/src/app/positions/page.tsx deleted file mode 100644 index 4bb5abf..0000000 --- a/src/app/positions/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { Section } from "@/components/section"; -import React, { useMemo } from "react"; - -import { useFilter } from "@/contexts/filter"; -import { useSearch } from "@/contexts/search"; -import positions from "@/data/positions"; -import { filterByQuery, filterBySelection } from "@/filter"; - -import { EmptyState } from "@/components/empty-state"; - -import { FilterBase } from "../../components/filter-base"; -import Timeline from "./timeline"; - -const Page = () => { - const { debouncedQuery } = useSearch(); - const { selected } = useFilter(); - - const filteredPositions = useMemo(() => { - const lowercaseQuery = debouncedQuery.toLowerCase(); - const roleSet = new Set((selected["roles"] || []).map((s) => s.toLowerCase())); - const toolSet = new Set((selected["tools"] || []).map((s) => s.toLowerCase())); - const skillSet = new Set((selected["skills"] || []).map((s) => s.toLowerCase())); - - return positions.filter( - (position) => - filterByQuery(position, lowercaseQuery) && - filterBySelection(position.tags, skillSet) && - filterBySelection(position.roles, roleSet) && - filterBySelection(position.skills, toolSet), - ); - }, [debouncedQuery, selected]); - - return ( - -
- {filteredPositions.length > 0 ? ( -
- -
- ) : ( - - )} -
-
- ); -}; - -export default Page; diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx deleted file mode 100644 index 136aa65..0000000 --- a/src/app/projects/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { Section } from "@/components/section"; -import React, { useMemo } from "react"; - -import { useFilter } from "@/contexts/filter"; -import { useSearch } from "@/contexts/search"; -import projects from "@/data/projects"; -import { filterByQuery, filterBySelection } from "@/filter"; -import type { Project } from "@/types"; - -import { EmptyState } from "@/components/empty-state"; - -import { FilterBase } from "../../components/filter-base"; -import ProjectView from "./project"; - -const Page = () => { - const { debouncedQuery } = useSearch(); - const { selected } = useFilter(); - - const filteredProjects = useMemo(() => { - const lowercaseQuery = debouncedQuery.toLowerCase(); - const roleSet = new Set((selected["roles"] || []).map((s) => s.toLowerCase())); - const toolSet = new Set((selected["tools"] || []).map((s) => s.toLowerCase())); - const skillSet = new Set((selected["skills"] || []).map((s) => s.toLowerCase())); - - return projects - .filter( - (project) => - filterByQuery(project, lowercaseQuery) && - filterBySelection(project.roles, roleSet) && - filterBySelection(project.tags, skillSet) && - filterBySelection(project.tools, toolSet), - ) - .sort( - (project1, project2) => - project1.group - project2.group || - new Date(project2.date).getFullYear() - - new Date(project1.date).getFullYear(), - ); - }, [debouncedQuery, selected]); - - return ( - -
- {filteredProjects.length > 0 ? ( -
- {filteredProjects.map((project: Project) => ( - - ))} -
- ) : ( - - )} -
-
- ); -}; - -export default Page; diff --git a/src/components/about-section.tsx b/src/components/about-section.tsx new file mode 100644 index 0000000..933ab1c --- /dev/null +++ b/src/components/about-section.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Section } from "./section"; + +export const AboutSection = () => { + return ( +
+
+

+ I am a passionate software engineer, researcher, and educator with a focus on cloud computing, machine learning, and mobile development. + With a background in Computer Engineering and years of experience in both academia and industry, I strive to build impactful solutions + that bridge the gap between complex research and practical application. +

+

+ My work spans across various domains, including developing scalable cloud architectures, implementing advanced machine learning models, + and creating intuitive mobile experiences. I am a strong advocate for agile methodologies and continuous learning, always seeking to + stay at the forefront of technological advancements. +

+

+ When I'm not coding or researching, I enjoy sharing my knowledge through teaching and mentoring, helping the next generation of + engineers grow and succeed in the ever-evolving tech landscape. +

+
+
+ ); +}; diff --git a/src/components/certifications-section.tsx b/src/components/certifications-section.tsx new file mode 100644 index 0000000..4c81772 --- /dev/null +++ b/src/components/certifications-section.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useMemo } from "react"; +import Image from "next/image"; +import { Card, Chip } from "@heroui/react"; +import { useFilter } from "@/contexts/filter"; +import { useSearch } from "@/contexts/search"; +import certificationsData from "@/data/certifications"; +import { filterByQuery, filterByArea } from "@/filter"; +import { Section } from "./section"; + +export const CertificationsSection = () => { + const { debouncedQuery } = useSearch(); + const { selectedAreas } = useFilter(); + + const filteredCerts = useMemo(() => { + const lowercaseQuery = debouncedQuery.toLowerCase(); + return certificationsData.filter((cert) => { + const matchesQuery = filterByQuery(cert, lowercaseQuery); + const matchesArea = filterByArea(cert.areas || [], selectedAreas); + return matchesQuery && matchesArea; + }); + }, [debouncedQuery, selectedAreas]); + + return ( +
+ + {filteredCerts.length === 0 && ( +

No certifications match your filters.

+ )} +
+ ); +}; diff --git a/src/components/education-section.tsx b/src/components/education-section.tsx new file mode 100644 index 0000000..5d68403 --- /dev/null +++ b/src/components/education-section.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useMemo } from "react"; +import Image from "next/image"; +import { Card } from "@heroui/react"; +import { useSearch } from "@/contexts/search"; +import degreesData from "@/data/degrees"; +import { filterByQuery } from "@/filter"; +import { Section } from "./section"; + +export const EducationSection = () => { + const { debouncedQuery } = useSearch(); + + const filteredDegrees = useMemo(() => { + const lowercaseQuery = debouncedQuery.toLowerCase(); + return degreesData.filter((degree) => filterByQuery(degree, lowercaseQuery)); + }, [debouncedQuery]); + + return ( +
+ + {filteredDegrees.length === 0 && ( +

No education entries match your search.

+ )} +
+ ); +}; diff --git a/src/components/experience-section.tsx b/src/components/experience-section.tsx new file mode 100644 index 0000000..d5c19d7 --- /dev/null +++ b/src/components/experience-section.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useMemo } from "react"; +import { useFilter } from "@/contexts/filter"; +import { useSearch } from "@/contexts/search"; +import positionsData from "@/data/positions"; +import { filterByQuery, filterByArea } from "@/filter"; +import Timeline from "@/app/positions/timeline"; +import { Section } from "./section"; + +export const ExperienceSection = () => { + const { debouncedQuery } = useSearch(); + const { selectedAreas } = useFilter(); + + const filteredPositions = useMemo(() => { + const lowercaseQuery = debouncedQuery.toLowerCase(); + return positionsData.filter((position) => { + const matchesQuery = filterByQuery(position, lowercaseQuery); + const matchesArea = filterByArea(position.tags, selectedAreas); + return matchesQuery && matchesArea; + }); + }, [debouncedQuery, selectedAreas]); + + return ( +
+
+ +
+ {filteredPositions.length === 0 && ( +

No positions match your filters.

+ )} +
+ ); +}; diff --git a/src/components/filter-base.tsx b/src/components/filter-base.tsx deleted file mode 100644 index 60ce3cf..0000000 --- a/src/components/filter-base.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { ReactNode, Fragment } from "react"; - -import { Selections, Filter } from "@/components/filter"; -import { PageHeader } from "@/components/header"; -import { useFilter } from "@/contexts/filter"; -import { useSearch } from "@/contexts/search"; -import areas from "@/data/areas"; -import roles from "@/data/roles"; -import skills from "@/data/skills"; - -interface FilterBaseProps { - title: string; - placeholder: string; - children: ReactNode; -} - -export const FilterBase = ({ - title, - placeholder, - children, -}: FilterBaseProps) => { - const { query, setQuery } = useSearch(); - const { selected, setSelected } = useFilter(); - - const selectedRoles = selected["roles"] || []; - const selectedTools = selected["tools"] || []; - const selectedSkills = selected["skills"] || []; - - return ( - - - - role.name)} - selected={selectedRoles} - setSelected={(values) => setSelected("roles", values as string[])} - /> - skill.name)} - selected={selectedTools} - setSelected={(values) => setSelected("tools", values as string[])} - /> - area.name)} - selected={selectedSkills} - setSelected={(values) => setSelected("skills", values as string[])} - /> - - - {children} - - ); -}; diff --git a/src/components/header.tsx b/src/components/header.tsx index 0587c42..44ea517 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,38 +1,15 @@ "use client"; import Link from "next/link"; - -import { ReactNode, useState } from "react"; - -import { ChevronLeftIcon } from "@heroicons/react/24/outline"; -import { Separator } from "@heroui/react"; - -import { Searchbar, SearchIcon } from "./search"; +import { useEffect, useState } from "react"; export const sections = [ - { - name: "skills", - link: "#skills", - }, - { - name: "certifications", - link: "#certifications", - }, - { - name: "degrees", - link: "#degrees", - }, -]; - -export const pages = [ - { - name: "projects", - link: "./projects", - }, - { - name: "positions", - link: "./positions", - }, + { name: "Skills", link: "#skills" }, + { name: "Certifications", link: "#certifications" }, + { name: "Education", link: "#education" }, + { name: "Projects", link: "#projects" }, + { name: "Experience", link: "#experience" }, + { name: "About", link: "#about" }, ]; const Title = () => ( @@ -43,9 +20,52 @@ const Title = () => ( export const MainHeader = () => { const [isMenuOpen, setIsMenuOpen] = useState(false); + const [activeSection, setActiveSection] = useState(""); + + useEffect(() => { + const observerOptions = { + root: null, + rootMargin: "-20% 0px -70% 0px", + threshold: 0, + }; + + const observerCallback = (entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveSection(entry.target.id); + } + }); + }; + + const observer = new IntersectionObserver(observerCallback, observerOptions); + + sections.forEach((section) => { + const element = document.getElementById(section.link.substring(1)); + if (element) observer.observe(element); + }); + + // Also observe hero/intro if needed to clear active section + const home = document.getElementById("home"); + if (home) observer.observe(home); + + return () => observer.disconnect(); + }, []); + + const handleScroll = (e: React.MouseEvent, link: string) => { + e.preventDefault(); + const targetId = link.substring(1); + const element = document.getElementById(targetId); + if (element) { + window.scrollTo({ + top: element.offsetTop - 180, // Adjust for sticky header + filter bar + behavior: "smooth", + }); + setIsMenuOpen(false); + } + }; return ( -