diff --git a/.github/workflows/portfolio-v2-ci.yml b/.github/workflows/portfolio-v2-ci.yml index 37b13cb..17ca08b 100644 --- a/.github/workflows/portfolio-v2-ci.yml +++ b/.github/workflows/portfolio-v2-ci.yml @@ -3,15 +3,12 @@ name: Portfolio V2 CI on: pull_request: branches: ["main"] - paths: - - "portfolio-v2/**" - - ".github/workflows/portfolio-v2-ci.yml" - - ".github/workflows/portfolio-project-sync.yml" - - ".github/prompts/**" workflow_dispatch: permissions: - contents: read + contents: write + issues: write + pull-requests: write jobs: build: @@ -20,6 +17,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }} - name: Setup Node uses: actions/setup-node@v4 @@ -28,8 +28,77 @@ jobs: cache: npm cache-dependency-path: portfolio-v2/package-lock.json - - name: Build Portfolio V2 + - name: Install Dependencies + working-directory: portfolio-v2 + run: npm ci + + - name: Format Code + working-directory: portfolio-v2 + run: npm run format + + - name: Project Health and Clean up + id: project_health + working-directory: portfolio-v2 + run: | + npm run project:health:fix + cat project-health-summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Commit Automated Cleanups + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + run: | + set -euo pipefail + + if git diff --quiet; then + echo "No automated formatting or project cleanup changes were needed." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Apply automated portfolio cleanup" + git push + echo "Committed automated formatting or project cleanup changes to this PR branch." >> "$GITHUB_STEP_SUMMARY" + + - name: Check Code Formatting working-directory: portfolio-v2 + run: npm run format:check + + - name: Comment Project Health Review + if: always() && github.event_name == 'pull_request' && steps.project_health.outputs.status == 'needs_review' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | - npm ci - npm run build + set -euo pipefail + + MARKER="" + COMMENT_ID="$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" | tail -n 1)" + + COMMENT_FILE="$RUNNER_TEMP/project-health-comment.md" + { + echo "$MARKER" + if [ -f portfolio-v2/project-health-summary.md ]; then + cat portfolio-v2/project-health-summary.md + else + echo "Project Health and Clean up did not produce a summary. Please inspect the workflow logs." + fi + } > "$COMMENT_FILE" + + if [ -n "$COMMENT_ID" ]; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" \ + --field "body=$(cat "$COMMENT_FILE")" + else + gh pr comment "$PR_NUMBER" --body-file "$COMMENT_FILE" + fi + + - name: Fail on Project Health Issues + if: steps.project_health.outputs.status == 'needs_review' + run: | + echo "Project Health and Clean up found issues. See the workflow summary or PR comment." + exit 1 + + - name: Build Portfolio V2 + working-directory: portfolio-v2 + run: npm run build diff --git a/.gitignore b/.gitignore index 74baf38..16f38ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ linkedin-proof-assets/ +portfolio-v2/node_modules/ +portfolio-v2/dist/ +portfolio-v2/project-health-summary.md diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..24dc651 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +portfolio-v2/dist/ +portfolio-v2/node_modules/ +portfolio-v2/project-health-summary.md +linkedin-proof-assets/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..c2ba36e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 120, + "semi": true, + "singleQuote": false, + "trailingComma": "es5" +} diff --git a/portfolio-v2/index.html b/portfolio-v2/index.html index c83eba6..f34022b 100644 --- a/portfolio-v2/index.html +++ b/portfolio-v2/index.html @@ -16,7 +16,7 @@ - + Vikram Sharma | Python Backend Engineer diff --git a/portfolio-v2/package-lock.json b/portfolio-v2/package-lock.json index f70658e..f5c0b43 100644 --- a/portfolio-v2/package-lock.json +++ b/portfolio-v2/package-lock.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@vitejs/plugin-react": "^4.3.4", + "prettier": "^3.8.3", "vite": "^5.4.11" } }, @@ -1478,6 +1479,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", diff --git a/portfolio-v2/package.json b/portfolio-v2/package.json index 85082ae..97c5c5f 100644 --- a/portfolio-v2/package.json +++ b/portfolio-v2/package.json @@ -6,7 +6,11 @@ "scripts": { "dev": "vite --host 127.0.0.1", "build": "vite build", - "preview": "vite preview --host 127.0.0.1" + "preview": "vite preview --host 127.0.0.1", + "format": "prettier --write --config ../.prettierrc.json --ignore-path ../.prettierignore \"src/**/*.{js,jsx,json,css}\" \"scripts/**/*.mjs\" \"index.html\" \"package.json\" \"docs/**/*.md\" \"../README.md\" \"../.github/**/*.{yml,yaml}\"", + "format:check": "prettier --check --config ../.prettierrc.json --ignore-path ../.prettierignore \"src/**/*.{js,jsx,json,css}\" \"scripts/**/*.mjs\" \"index.html\" \"package.json\" \"docs/**/*.md\" \"../README.md\" \"../.github/**/*.{yml,yaml}\"", + "project:health": "node scripts/checkProjectHealth.mjs", + "project:health:fix": "node scripts/checkProjectHealth.mjs --fix" }, "dependencies": { "lucide-react": "^0.468.0", @@ -15,6 +19,7 @@ }, "devDependencies": { "@vitejs/plugin-react": "^4.3.4", + "prettier": "^3.8.3", "vite": "^5.4.11" } } diff --git a/portfolio-v2/scripts/applyPortfolioProjectUpdate.mjs b/portfolio-v2/scripts/applyPortfolioProjectUpdate.mjs index 1e0c174..613f7bc 100644 --- a/portfolio-v2/scripts/applyPortfolioProjectUpdate.mjs +++ b/portfolio-v2/scripts/applyPortfolioProjectUpdate.mjs @@ -200,7 +200,9 @@ function labelForLink(href) { } function clean(value) { - return String(value || "").replace(/\s+/g, " ").trim(); + return String(value || "") + .replace(/\s+/g, " ") + .trim(); } function findExistingProject(projects, matchTitle, project) { diff --git a/portfolio-v2/scripts/checkProjectHealth.mjs b/portfolio-v2/scripts/checkProjectHealth.mjs new file mode 100644 index 0000000..dda1837 --- /dev/null +++ b/portfolio-v2/scripts/checkProjectHealth.mjs @@ -0,0 +1,369 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const publicDir = resolve(__dirname, "../public"); +const projectsDataPath = resolve(__dirname, "../src/projects.json"); +const summaryPath = resolve(__dirname, "../project-health-summary.md"); +const shouldFix = process.argv.includes("--fix"); +const skipNetwork = process.argv.includes("--skip-network"); +const timeoutMs = Number(process.env.PROJECT_HEALTH_TIMEOUT_MS || 12000); +const userAgent = "Mozilla/5.0 (compatible; PortfolioProjectHealth/1.0; +https://github.com/vikramsh2002/My-Portfolio)"; + +const projects = JSON.parse(readFileSync(projectsDataPath, "utf8")); +const fixes = []; +const reviewNotes = []; +const warnings = []; +const checkedLinks = []; +let changed = false; + +if (!Array.isArray(projects)) { + throw new Error("projects.json must contain a project array."); +} + +const updatedProjects = []; + +for (const [projectIndex, rawProject] of projects.entries()) { + const project = normalizeProject(rawProject); + const projectName = project.title || `Project ${projectIndex + 1}`; + + if (JSON.stringify(project) !== JSON.stringify(rawProject)) { + changed = true; + fixes.push(`Normalized spacing, tags, or link metadata for "${projectName}".`); + } + + validateRequiredText(project, projectName); + validateImage(project, projectName); + + const linkResult = await cleanAndCheckLinks(project, projectName); + project.links = linkResult.links; + + if (!project.links.length) { + reviewNotes.push(`"${projectName}" has no usable project links after cleanup.`); + } + + updatedProjects.push(project); +} + +if (!shouldFix && changed) { + reviewNotes.push( + "Project data has automatic cleanup suggestions. Run `npm run project:health:fix` and commit the result." + ); +} + +if (shouldFix && changed) { + writeFileSync(projectsDataPath, `${JSON.stringify(updatedProjects, null, 2)}\n`, "utf8"); +} + +const status = reviewNotes.length ? "needs_review" : changed ? "fixed" : "clean"; +writeSummary({ status }); +writeOutput("status", status); +writeOutput("changed", String(Boolean(shouldFix && changed))); +writeOutput("fix_count", String(fixes.length)); +writeOutput("review_count", String(reviewNotes.length)); +writeOutput("warning_count", String(warnings.length)); +writeOutput("summary_path", summaryPath); + +function normalizeProject(project) { + const normalized = { + ...project, + title: clean(project.title), + subtitle: clean(project.subtitle), + image: clean(project.image), + description: clean(project.description), + impact: clean(project.impact), + tags: normalizeTags(project.tags), + links: normalizeLinks(project.links), + }; + + if (Object.hasOwn(project, "featured")) { + normalized.featured = Boolean(project.featured); + } + + return normalized; +} + +function validateRequiredText(project, projectName) { + for (const field of ["title", "subtitle", "description", "impact"]) { + if (!project[field]) { + reviewNotes.push(`"${projectName}" is missing required field: ${field}.`); + } + } + + if (!project.tags.length) { + reviewNotes.push(`"${projectName}" is missing tags.`); + } +} + +function validateImage(project, projectName) { + if (!project.image) { + reviewNotes.push(`"${projectName}" is missing a project image.`); + return; + } + + if (/^https?:\/\//i.test(project.image)) { + reviewNotes.push(`"${projectName}" uses a remote image. Move it into public/images/Projects for stable builds.`); + return; + } + + const imagePath = resolve(publicDir, project.image); + if (!existsSync(imagePath)) { + reviewNotes.push(`"${projectName}" references a missing image: ${project.image}.`); + } +} + +async function cleanAndCheckLinks(project, projectName) { + const seenLinks = new Set(); + const keptLinks = []; + + for (const link of project.links) { + const normalizedHref = normalizeHref(link.href); + + if (!normalizedHref) { + changed = true; + fixes.push(`Removed an empty link from "${projectName}".`); + continue; + } + + if (seenLinks.has(normalizedHref)) { + changed = true; + fixes.push(`Removed duplicate "${link.label}" link from "${projectName}".`); + continue; + } + + seenLinks.add(normalizedHref); + + if (!/^https?:\/\//i.test(link.href)) { + reviewNotes.push(`"${projectName}" has a non-web project link that needs review: ${link.href}.`); + keptLinks.push(link); + continue; + } + + if (!skipNetwork) { + const check = await checkUrl(link.href); + checkedLinks.push({ projectName, label: link.label, href: link.href, ...check }); + + if (check.status === "dead") { + const remainingLinkCount = project.links.filter((candidate) => normalizeHref(candidate.href)).length - 1; + const importantSourceLink = isSourceLink(link); + + if (shouldFix && !importantSourceLink && remainingLinkCount > 0) { + changed = true; + fixes.push(`Removed dead "${link.label}" link from "${projectName}": ${link.href}`); + continue; + } + + reviewNotes.push( + `"${projectName}" has a dead ${importantSourceLink ? "source" : "project"} link that needs review: ${link.href} (${check.reason}).` + ); + } else if (check.status === "blocked") { + warnings.push( + `"${projectName}" ${link.label} link could not be fully checked in this environment: ${link.href} (${check.reason}).` + ); + } else if (check.status === "transient") { + warnings.push( + `"${projectName}" ${link.label} link had a transient check issue: ${link.href} (${check.reason}).` + ); + } + } + + keptLinks.push(link); + } + + return { links: keptLinks }; +} + +async function checkUrl(url) { + const head = await requestUrl(url, "HEAD"); + + if (head.kind === "response" && isHealthyStatus(head.statusCode)) { + return { status: "ok", reason: `HTTP ${head.statusCode}` }; + } + + if (head.kind === "response" && shouldRetryWithGet(head.statusCode)) { + const get = await requestUrl(url, "GET"); + return classifyProbe(get); + } + + return classifyProbe(head); +} + +async function requestUrl(url, method) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + method, + redirect: "follow", + signal: controller.signal, + headers: { + "user-agent": userAgent, + accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + }); + + return { kind: "response", statusCode: response.status }; + } catch (error) { + return { + kind: "error", + code: error?.cause?.code || error?.code || error?.name || "ERROR", + message: error?.cause?.message || error?.message || String(error), + }; + } finally { + clearTimeout(timeout); + } +} + +function classifyProbe(probe) { + if (probe.kind === "response") { + if (isHealthyStatus(probe.statusCode)) { + return { status: "ok", reason: `HTTP ${probe.statusCode}` }; + } + + if ([401, 403, 418, 429, 999].includes(probe.statusCode)) { + return { status: "blocked", reason: `HTTP ${probe.statusCode}` }; + } + + if ([404, 410].includes(probe.statusCode)) { + return { status: "dead", reason: `HTTP ${probe.statusCode}` }; + } + + if (probe.statusCode >= 500) { + return { status: "transient", reason: `HTTP ${probe.statusCode}` }; + } + + return { status: "blocked", reason: `HTTP ${probe.statusCode}` }; + } + + if (["ENOTFOUND", "ENODATA", "ECONNREFUSED"].includes(probe.code)) { + return { status: "dead", reason: probe.code }; + } + + if (["AbortError", "ETIMEDOUT", "ECONNRESET", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"].includes(probe.code)) { + return { status: "transient", reason: probe.code }; + } + + return { status: "blocked", reason: probe.code }; +} + +function isHealthyStatus(statusCode) { + return statusCode >= 200 && statusCode < 400; +} + +function shouldRetryWithGet(statusCode) { + return [401, 403, 405, 418, 429, 999].includes(statusCode) || statusCode >= 500; +} + +function normalizeTags(tags) { + if (!Array.isArray(tags)) { + return []; + } + + const seen = new Set(); + const normalized = []; + + for (const tag of tags.map(clean).filter(Boolean)) { + const key = tag.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + normalized.push(tag); + } + } + + return normalized.slice(0, 8); +} + +function normalizeLinks(links) { + if (!Array.isArray(links)) { + return []; + } + + return links.map((link) => ({ + label: clean(link?.label) || labelForLink(clean(link?.href)), + href: clean(link?.href), + })); +} + +function labelForLink(href) { + if (!href) { + return "Link"; + } + + if (href.includes("github.com")) { + return "Source"; + } + + if (href.includes("linkedin.com")) { + return "Demo"; + } + + return "Live app"; +} + +function isSourceLink(link) { + return link.label.toLowerCase() === "source" || link.href.includes("github.com"); +} + +function normalizeHref(href) { + const cleaned = clean(href); + if (!cleaned) { + return ""; + } + + try { + const url = new URL(cleaned); + url.hash = ""; + url.searchParams.sort(); + return url.toString().replace(/\/$/, "").toLowerCase(); + } catch { + return cleaned.replace(/\/$/, "").toLowerCase(); + } +} + +function clean(value) { + return String(value || "") + .replace(/\s+/g, " ") + .trim(); +} + +function writeSummary({ status }) { + const lines = [ + "# Project Health and Clean up", + "", + `- Status: ${status}`, + `- Projects checked: ${projects.length}`, + `- Links checked: ${checkedLinks.length}`, + `- Auto-fixes: ${fixes.length}`, + `- Needs review: ${reviewNotes.length}`, + `- Warnings: ${warnings.length}`, + "", + ]; + + if (fixes.length) { + lines.push("## Auto-fixes", "", ...fixes.map((fix) => `- ${fix}`), ""); + } + + if (reviewNotes.length) { + lines.push("## Needs Review", "", ...reviewNotes.map((note) => `- ${note}`), ""); + } + + if (warnings.length) { + lines.push("## Warnings", "", ...warnings.map((warning) => `- ${warning}`), ""); + } + + if (!fixes.length && !reviewNotes.length && !warnings.length) { + lines.push("No project data, asset, or link health issues were found.", ""); + } + + writeFileSync(summaryPath, `${lines.join("\n")}\n`, "utf8"); +} + +function writeOutput(name, value) { + if (!process.env.GITHUB_OUTPUT) { + return; + } + + writeFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`, { flag: "a" }); +} diff --git a/portfolio-v2/scripts/prepareProjectThumbnail.mjs b/portfolio-v2/scripts/prepareProjectThumbnail.mjs index b90b46c..08ab501 100644 --- a/portfolio-v2/scripts/prepareProjectThumbnail.mjs +++ b/portfolio-v2/scripts/prepareProjectThumbnail.mjs @@ -233,7 +233,9 @@ function extensionFromUrl(url) { } function clean(value) { - return String(value || "").replace(/\s+/g, " ").trim(); + return String(value || "") + .replace(/\s+/g, " ") + .trim(); } function slug(value) { diff --git a/portfolio-v2/src/App.jsx b/portfolio-v2/src/App.jsx index 2bdc824..03664a7 100644 --- a/portfolio-v2/src/App.jsx +++ b/portfolio-v2/src/App.jsx @@ -15,15 +15,7 @@ import { X, } from "lucide-react"; import { useMemo, useState } from "react"; -import { - certifications, - education, - experience, - profile, - projects, - publications, - skillGroups, -} from "./portfolioData"; +import { certifications, education, experience, profile, projects, publications, skillGroups } from "./portfolioData"; const asset = (path) => `${import.meta.env.BASE_URL}${path}`; const resumeHref = asset("resume/Vikram_Sharma_Python_Backend_Engineer.pdf"); @@ -32,17 +24,27 @@ const defaultProjectImage = "images/Projects/headlines.png"; function App() { const [menuOpen, setMenuOpen] = useState(false); const [activeFilter, setActiveFilter] = useState("All"); - const featuredProjects = projects.filter((project) => project.featured); - const libraryProjects = projects.filter((project) => !project.featured); const filters = useMemo(() => { - const tags = projects.flatMap((project) => project.tags); - return ["All", ...Array.from(new Set(tags)).slice(0, 9)]; + return [ + { label: "All" }, + { label: "Python", tags: ["Python"] }, + { label: "APIs", tags: ["APIs", "API"] }, + { label: "ML / AI", tags: ["ML", "TensorFlow", "Deep Learning", "Computer Vision", "NLP", "Regression"] }, + { label: "React", tags: ["React", "Frontend"] }, + { label: "Java", tags: ["Java", "Spring Boot", "Microservices"] }, + { label: "Data", tags: ["Data", "MongoDB", "Recommender"] }, + ]; }, []); - const shownProjects = libraryProjects.filter( - (project) => activeFilter === "All" || project.tags.includes(activeFilter) - ); + const activeFilterConfig = filters.find((filter) => filter.label === activeFilter) || filters[0]; + const shownProjects = projects.filter((project) => { + if (!activeFilterConfig.tags) { + return true; + } + + return project.tags.some((tag) => activeFilterConfig.tags.includes(tag)); + }); return (
@@ -53,9 +55,7 @@ function App() {

Python Backend Engineer

-

- Building reliable APIs, AWS serverless systems, and production-ready backend services. -

+

Building reliable APIs, AWS serverless systems, and production-ready backend services.

{profile.summary}

@@ -104,7 +104,7 @@ function App() { icon={} kicker="Experience" title="Backend work in production environments" - text="Resume-led experience focused on service reliability, API delivery, cloud systems, and maintainable engineering." + text="Experience across service reliability, API delivery, cloud systems, and maintainable engineering practices." />
{experience.map((item) => ( @@ -138,7 +138,7 @@ function App() { icon={} kicker="Technical Stack" title="Backend-first skill set with cloud and testing depth" - text="Grouped around the way engineering leads review practical production readiness." + text="Core tools and technologies used across backend services, cloud workflows, testing, data, and frontend delivery." />
{skillGroups.map((group) => ( @@ -158,28 +158,25 @@ function App() { } kicker="Projects" - title="Selected engineering work, with the full project history kept intact" - text="The first row is intentionally stronger for backend and engineering review; the rest stays available for context." + title="Selected software engineering projects" + text="Backend, API, cloud, and machine learning projects showing implementation depth, reliability thinking, and end-to-end delivery." /> -
- {featuredProjects - .map((project) => ( - - ))} -
-
{filters.map((filter) => ( ))} + + {shownProjects.length} {shownProjects.length === 1 ? "project" : "projects"} +
@@ -248,10 +245,10 @@ function App() {

Contact

-

Backend engineering, API reliability, and cloud systems are the lane.

+

Open to backend engineering roles focused on APIs, cloud systems, and reliable services.

- Reach out for Python backend roles, AWS serverless work, or engineering conversations around - production systems. + Reach out for Python backend roles, AWS serverless work, or engineering conversations around production + systems.

@@ -320,11 +317,11 @@ function SectionHeading({ icon, kicker, title, text }) { ); } -function ProjectCard({ project, featured = false }) { +function ProjectCard({ project }) { const projectImage = project.image || defaultProjectImage; return ( -
+
{`${project.title}
diff --git a/portfolio-v2/src/projects.json b/portfolio-v2/src/projects.json index 8706403..b147906 100644 --- a/portfolio-v2/src/projects.json +++ b/portfolio-v2/src/projects.json @@ -5,22 +5,12 @@ "image": "images/Projects/DrCnn.jpg", "description": "Python and TensorFlow web application with a data-processing, model-training, and real-time inference pipeline for classifying lung X-ray conditions.", "impact": "Built scalable prediction-serving APIs with preprocessing, model integration, and inference reliability in mind.", - "tags": [ - "Python", - "TensorFlow", - "APIs", - "ML", - "Healthcare" - ], + "tags": ["Python", "TensorFlow", "APIs", "ML", "Healthcare"], "featured": true, "links": [ { "label": "Source", "href": "https://github.com/vikramsh2002/DrCNN" - }, - { - "label": "Live app", - "href": "https://cvbn.azurewebsites.net/" } ] }, @@ -30,12 +20,7 @@ "image": "images/Projects/shopping.png", "description": "Online shopping system designed with Java Spring Boot services, MVC patterns, REST APIs, and MongoDB persistence.", "impact": "Useful proof of backend service boundaries, API flow, and distributed application thinking.", - "tags": [ - "Java", - "Spring Boot", - "Microservices", - "MongoDB" - ], + "tags": ["Java", "Spring Boot", "Microservices", "MongoDB"], "featured": true, "links": [ { @@ -50,11 +35,7 @@ "image": "images/Projects/headlines.png", "description": "React application that consumes a News API and renders current top headlines through a responsive interface.", "impact": "Demonstrates frontend API consumption, state-driven UI, and responsive application delivery.", - "tags": [ - "React", - "API", - "Frontend" - ], + "tags": ["React", "API", "Frontend"], "featured": true, "links": [ { @@ -69,12 +50,7 @@ "image": "images/Projects/Novel.png", "description": "NLP-assisted recommendation application that combines trending books with collaborative rating analysis.", "impact": "Shows applied recommendation concepts and data-driven product thinking.", - "tags": [ - "Python", - "NLP", - "Recommender", - "Streamlit" - ], + "tags": ["Python", "NLP", "Recommender", "Streamlit"], "featured": true, "links": [ { @@ -93,11 +69,7 @@ "image": "images/Projects/Human.png", "description": "Computer vision application for classifying popular yoga poses from images, webcam streams, and videos.", "impact": "Explores real-time inputs, pose estimation, and applied computer vision workflows.", - "tags": [ - "Python", - "Computer Vision", - "ML" - ], + "tags": ["Python", "Computer Vision", "ML"], "links": [ { "label": "Demo", @@ -115,12 +87,7 @@ "image": "images/Projects/Course.png", "description": "NLP-based recommender built on Coursera course data to help learners filter and discover courses.", "impact": "Combines data preparation, text matching, and recommendation UX.", - "tags": [ - "Python", - "NLP", - "Recommender", - "Streamlit" - ], + "tags": ["Python", "NLP", "Recommender", "Streamlit"], "links": [ { "label": "Live app", @@ -138,12 +105,7 @@ "image": "images/Projects/Diamond.png", "description": "Machine learning regression project that predicts diamond prices from quality and dimension features.", "impact": "Covers preprocessing, model evaluation, and deployment-facing prediction flow.", - "tags": [ - "Python", - "Regression", - "ML", - "Streamlit" - ], + "tags": ["Python", "Regression", "ML", "Streamlit"], "links": [ { "label": "Live app", @@ -161,11 +123,7 @@ "image": "images/Projects/Crop.png", "description": "Deep learning project using soil and climate features to classify the best crop from 22 crop categories.", "impact": "Highlights applied agricultural ML, EDA, and model-building workflow.", - "tags": [ - "Python", - "Deep Learning", - "Data" - ], + "tags": ["Python", "Deep Learning", "Data"], "links": [ { "label": "Source", @@ -179,11 +137,7 @@ "image": "images/Projects/Movie.png", "description": "NLP-based movie recommendation app that suggests similar movies from previously watched content.", "impact": "Shows text-processing, similarity modeling, and recommendation interface design.", - "tags": [ - "Python", - "NLP", - "Recommender" - ], + "tags": ["Python", "NLP", "Recommender"], "links": [ { "label": "Live app", @@ -205,11 +159,7 @@ "image": "images/Projects/IVA.png", "description": "Python GUI application for hiding and encrypting data in images and files using layered custom encryption.", "impact": "Demonstrates security-oriented thinking, file handling, and desktop application flow.", - "tags": [ - "Python", - "Security", - "GUI" - ], + "tags": ["Python", "Security", "GUI"], "links": [ { "label": "Source", @@ -223,12 +173,7 @@ "image": "images/Projects/AMS.png", "description": "Static website for alumni interaction, student information, profile pages, and an LPU virtual gallery.", "impact": "Early project showing multi-page static site structure and GitHub Pages deployment.", - "tags": [ - "HTML", - "CSS", - "JavaScript", - "GitHub Pages" - ], + "tags": ["HTML", "CSS", "JavaScript", "GitHub Pages"], "links": [ { "label": "Live site", diff --git a/portfolio-v2/src/styles.css b/portfolio-v2/src/styles.css index 71389a3..b630807 100644 --- a/portfolio-v2/src/styles.css +++ b/portfolio-v2/src/styles.css @@ -1,6 +1,13 @@ :root { color-scheme: light; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; --ink: #101114; --ink-soft: #41454f; --muted: #6d7280; @@ -242,9 +249,7 @@ a { .portrait-frame { aspect-ratio: 1; - background: - linear-gradient(145deg, rgba(54, 200, 210, 0.12), rgba(229, 107, 93, 0.12)), - #20262b; + background: linear-gradient(145deg, rgba(54, 200, 210, 0.12), rgba(229, 107, 93, 0.12)), #20262b; border-radius: 8px; overflow: hidden; } @@ -432,7 +437,6 @@ a { padding: 7px 10px; } -.featured-grid, .project-grid { display: grid; gap: 18px; @@ -440,7 +444,7 @@ a { } .project-grid { - margin-top: 18px; + margin-top: 22px; } .project-card { @@ -451,15 +455,9 @@ a { overflow: hidden; } -.project-card.featured { - border-color: rgba(54, 200, 210, 0.55); -} - .project-image { align-items: center; - background: - linear-gradient(135deg, rgba(54, 200, 210, 0.12), rgba(117, 200, 118, 0.08)), - #f4f2ec; + background: linear-gradient(135deg, rgba(54, 200, 210, 0.12), rgba(117, 200, 118, 0.08)), #f4f2ec; border-right: 1px solid var(--line); display: flex; justify-content: center; @@ -528,10 +526,11 @@ a { } .project-tools { + align-items: center; display: flex; flex-wrap: wrap; gap: 8px; - margin: 34px 0 0; + margin: -10px 0 0; } .filter { @@ -540,9 +539,10 @@ a { border-radius: 999px; color: var(--ink-soft); cursor: pointer; + font-size: 0.92rem; font-weight: 800; - min-height: 38px; - padding: 0 13px; + min-height: 36px; + padding: 0 12px; } .filter.active { @@ -551,6 +551,13 @@ a { color: white; } +.project-count { + color: var(--muted); + font-size: 0.9rem; + font-weight: 800; + margin-left: 6px; +} + .section-split { align-items: start; display: grid; @@ -604,9 +611,7 @@ a { } .proof-panel { - background: - linear-gradient(135deg, rgba(54, 200, 210, 0.14), rgba(117, 200, 118, 0.11)), - var(--panel); + background: linear-gradient(135deg, rgba(54, 200, 210, 0.14), rgba(117, 200, 118, 0.11)), var(--panel); } .proof-panel p { @@ -662,7 +667,6 @@ a { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .featured-grid, .project-grid { grid-template-columns: 1fr; } @@ -737,6 +741,11 @@ a { min-height: 0; } + .project-count { + flex-basis: 100%; + margin-left: 0; + } + .publication-item { align-items: start; }