Compare commits
11 Commits
main
...
7b115c2284
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b115c2284 | |||
| a3ccba0663 | |||
| cc284c27a9 | |||
| 7cec30170a | |||
| abe307a6ca | |||
| 7cc57e966b | |||
| 2ba0b90fce | |||
| 415dcb8459 | |||
| abde9ff0e9 | |||
| 571cf35151 | |||
| abb7651e41 |
@@ -67,7 +67,7 @@ const AboutPage = () => {
|
|||||||
const age = calculateAge("2002-02-27");
|
const age = calculateAge("2002-02-27");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-center gap-8 p-4 pt-24 text-white">
|
<div className="flex w-full flex-col items-center gap-8 p-4 pt-24 text-white gravity-body">
|
||||||
<div className="flex flex-col items-center justify-center gap-2 p-4 backdrop-blur-sm glass-effect glossy-effect bottom gloss-highlight rounded-lg shadow-lg">
|
<div className="flex flex-col items-center justify-center gap-2 p-4 backdrop-blur-sm glass-effect glossy-effect bottom gloss-highlight rounded-lg shadow-lg">
|
||||||
<Image
|
<Image
|
||||||
src="/images/ja.avif"
|
src="/images/ja.avif"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const KSuiteOrganism = () => {
|
|||||||
|
|
||||||
const radius = 35;
|
const radius = 35;
|
||||||
const positions = kSuiteApps.map((_, i) =>
|
const positions = kSuiteApps.map((_, i) =>
|
||||||
getPosition(i, kSuiteApps.length, radius)
|
getPosition(i, kSuiteApps.length, radius),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -298,7 +298,7 @@ const KSuiteAppCard = ({ app }: { app: KSuiteApp }) => {
|
|||||||
|
|
||||||
export default function KSuitePage() {
|
export default function KSuitePage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pt-20">
|
<div className="min-h-screen pt-20 gravity-body">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative py-16 md:py-24 overflow-hidden">
|
<section className="relative py-16 md:py-24 overflow-hidden">
|
||||||
<div className="container mx-auto px-4 text-center">
|
<div className="container mx-auto px-4 text-center">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Navbar from "@/components/navbar";
|
import Navbar from "@/components/navbar";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
|
import GravityToggle from "@/components/gravity-toggle";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -98,6 +99,7 @@ export default function RootLayout({
|
|||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
{children}
|
{children}
|
||||||
|
<GravityToggle />
|
||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { skills, jobs } from "@/lib/data";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center w-full">
|
<div className="flex flex-col items-center w-full gravity-body">
|
||||||
<Hero />
|
<Hero />
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<AboutSummary />
|
<AboutSummary />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { notFound } from "next/navigation";
|
|||||||
function getProjectByName(name: string): Project | undefined {
|
function getProjectByName(name: string): Project | undefined {
|
||||||
const decodedName = decodeURIComponent(name.replace(/\+/g, " "));
|
const decodedName = decodeURIComponent(name.replace(/\+/g, " "));
|
||||||
return projects.find(
|
return projects.find(
|
||||||
(p) => p.name.toLowerCase() === decodedName.toLowerCase()
|
(p) => p.name.toLowerCase() === decodedName.toLowerCase(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export default async function ProjectDetailPage({
|
|||||||
project.github_url || project.visit_url || project.download_url;
|
project.github_url || project.visit_url || project.download_url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full h-full min-h-screen gap-4 p-4 pt-24">
|
<div className="flex flex-col w-full h-full min-h-screen gap-4 p-4 pt-24 gravity-body">
|
||||||
<div className="max-w-4xl mx-auto w-full">
|
<div className="max-w-4xl mx-auto w-full">
|
||||||
<h1 className="text-4xl font-extrabold mb-6 bg-gradient-to-r from-yellow-400 to-blue-400 bg-clip-text text-transparent tracking-tight">
|
<h1 className="text-4xl font-extrabold mb-6 bg-gradient-to-r from-yellow-400 to-blue-400 bg-clip-text text-transparent tracking-tight">
|
||||||
{project.name}
|
{project.name}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
const ProjectsPage = () => {
|
const ProjectsPage = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full h-full min-h-screen flex-col items-center gap-4 pt-24">
|
<div className="flex w-full h-full min-h-screen flex-col items-center gap-4 pt-24 gravity-body">
|
||||||
<h1 className="text-5xl font-bold text-center text-white">My Projects</h1>
|
<h1 className="text-5xl font-bold text-center text-white">My Projects</h1>
|
||||||
|
|
||||||
<div className="w-full flex flex-col items-center gap-16 mt-8">
|
<div className="w-full flex flex-col items-center gap-16 mt-8">
|
||||||
|
|||||||
36
components/gravity-toggle.tsx
Normal file
36
components/gravity-toggle.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { GravityEngine } from "@/lib/gravity-engine";
|
||||||
|
import { Magnet } from "lucide-react";
|
||||||
|
|
||||||
|
export default function GravityToggle() {
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const engineRef = useRef<GravityEngine | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
engineRef.current = new GravityEngine();
|
||||||
|
return () => engineRef.current?.stop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleGravity = () => {
|
||||||
|
if (!engineRef.current) return;
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
engineRef.current.stop();
|
||||||
|
} else {
|
||||||
|
engineRef.current.start();
|
||||||
|
}
|
||||||
|
setIsActive(!isActive);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggleGravity}
|
||||||
|
className="fixed bottom-4 right-4 p-3 bg-yellow-400 text-black rounded-full shadow-lg z-50 hover:bg-yellow-500 transition-colors"
|
||||||
|
title="Toggle Gravity"
|
||||||
|
>
|
||||||
|
<Magnet size={24} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
# Experience Timeline Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Replace the horizontal experience card carousel with a full-width vertical timeline that surfaces job descriptions and sub-phase breakdowns from static data.
|
|
||||||
|
|
||||||
**Architecture:** Extend the `Job` type with optional `summary` and `sub_phases` fields, update `data.ts` with CV content, then replace the card carousel with a new `ExperienceTimeline` server component. No client-side JS — all content is statically rendered.
|
|
||||||
|
|
||||||
**Tech Stack:** Next.js (App Router), TypeScript, TailwindCSS
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | File | Responsibility |
|
|
||||||
|--------|------|---------------|
|
|
||||||
| Modify | `lib/types.ts` | Add `JobSubPhase`, extend `Job` |
|
|
||||||
| Modify | `lib/data.ts` | Add `summary` + `sub_phases` to WPP and GIAP entries |
|
|
||||||
| Create | `components/experience-timeline.tsx` | Renders the full vertical timeline (server component) |
|
|
||||||
| Modify | `components/experience.tsx` | Swap card loop for `<ExperienceTimeline>` |
|
|
||||||
| Delete | `components/experience-card.tsx` | Replaced by timeline component |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Extend types
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `lib/types.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `JobSubPhase` and extend `Job`**
|
|
||||||
|
|
||||||
Replace the entire contents of `lib/types.ts` with:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface Skill {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobSubPhase {
|
|
||||||
label: string;
|
|
||||||
start_date: string;
|
|
||||||
end_date: string | null;
|
|
||||||
bullets: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Job {
|
|
||||||
id: number;
|
|
||||||
position: string;
|
|
||||||
company: string;
|
|
||||||
still_working: boolean;
|
|
||||||
start_date: string;
|
|
||||||
end_date: string | null;
|
|
||||||
technologies: string[];
|
|
||||||
summary?: string;
|
|
||||||
sub_phases?: JobSubPhase[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Project {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
short_description: string;
|
|
||||||
description: string;
|
|
||||||
technologies: string[];
|
|
||||||
thumbnails: string[];
|
|
||||||
category: 'Web' | 'Mobile' | 'Desktop' | 'Api' | 'Game';
|
|
||||||
github_url?: string | null;
|
|
||||||
visit_url?: string | null;
|
|
||||||
download_url?: string | null;
|
|
||||||
commercial?: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify TypeScript is happy**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors (existing job entries have no `sub_phases` yet, so optional fields are fine).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add lib/types.ts
|
|
||||||
git commit -m "feat: add JobSubPhase type and extend Job with summary and sub_phases"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Update job data
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `lib/data.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update the WPP entry (id: 8)**
|
|
||||||
|
|
||||||
Replace the WPP job object in the `jobs` array:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
position: "Software Engineer",
|
|
||||||
company: "WPP Media | Choreograph | Wavemaker",
|
|
||||||
still_working: true,
|
|
||||||
start_date: "2023-09-13",
|
|
||||||
end_date: null,
|
|
||||||
summary:
|
|
||||||
"Advanced from frontend UI development to backend systems engineering, leading infrastructure-agnostic design initiatives.",
|
|
||||||
sub_phases: [
|
|
||||||
{
|
|
||||||
label: "Backend & Infrastructure",
|
|
||||||
start_date: "2025-03-01",
|
|
||||||
end_date: null,
|
|
||||||
bullets: [
|
|
||||||
"Engineered and optimized backend applications and internal tools using Python, FastAPI, and Django.",
|
|
||||||
"Streamlined CI/CD workflows and containerized applications with GitLab Pipelines, Docker, and Kubernetes across GCP and Azure environments.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Frontend Architecture",
|
|
||||||
start_date: "2023-09-13",
|
|
||||||
end_date: "2025-03-01",
|
|
||||||
bullets: [
|
|
||||||
"Architected scalable microfrontends utilizing Angular and Module Federation, seamlessly integrating standalone internal tools into a unified enterprise shell application.",
|
|
||||||
"Ensured seamless integration of UI components with Kubernetes-based deployments and Azure Pipelines.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
technologies: [
|
|
||||||
"Angular",
|
|
||||||
"Azure",
|
|
||||||
"Azure Pipelines",
|
|
||||||
"Django",
|
|
||||||
"Docker",
|
|
||||||
"FastAPI",
|
|
||||||
"GCP",
|
|
||||||
"Gitlab CI",
|
|
||||||
"Gitlab Pipelines",
|
|
||||||
"Kubernetes",
|
|
||||||
"PostgreSQL",
|
|
||||||
"Python",
|
|
||||||
"React",
|
|
||||||
"SCSS",
|
|
||||||
"TailwindCSS",
|
|
||||||
"Typescript",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update the GIAP entry (id: 2)**
|
|
||||||
|
|
||||||
Replace the GIAP job object:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
position: "Full Stack Developer",
|
|
||||||
company: "GIAP",
|
|
||||||
still_working: false,
|
|
||||||
start_date: "2021-05-19",
|
|
||||||
end_date: "2023-02-03",
|
|
||||||
sub_phases: [
|
|
||||||
{
|
|
||||||
label: "Desktop / Backend",
|
|
||||||
start_date: "2021-05-19",
|
|
||||||
end_date: "2022-02-01",
|
|
||||||
bullets: [
|
|
||||||
"Architected and optimized complex PostGIS/PostgreSQL cross-database comparison queries utilizing Common Table Expressions (CTEs), drastically reducing execution time from over 5 minutes to under 15 seconds.",
|
|
||||||
"Developed a robust GIS data assertion module using Python and Qt to automatically validate spatial data against strict compliance standards.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Frontend",
|
|
||||||
start_date: "2022-02-01",
|
|
||||||
end_date: "2023-02-03",
|
|
||||||
bullets: [
|
|
||||||
"Engineered a comprehensive, public-facing web application for the City of Gdańsk (geogdansk.pl) leveraging React, TypeScript, Redux, and the ArcGIS JS API.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
technologies: [
|
|
||||||
"Python",
|
|
||||||
"React",
|
|
||||||
"Typescript",
|
|
||||||
"PostgreSQL",
|
|
||||||
"PostGIS",
|
|
||||||
"ArcGIS JS API",
|
|
||||||
"Redux",
|
|
||||||
"Qt",
|
|
||||||
"QGIS",
|
|
||||||
"Git",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify TypeScript is happy**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add lib/data.ts
|
|
||||||
git commit -m "feat: add sub_phases and summary to WPP and GIAP job entries"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Create ExperienceTimeline component
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `components/experience-timeline.tsx`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the component**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Job } from "@/lib/types";
|
|
||||||
import Chip from "./chip";
|
|
||||||
import formatDate from "@/utils/format-date";
|
|
||||||
|
|
||||||
const ExperienceTimeline = ({ jobs }: { jobs: Job[] }) => (
|
|
||||||
<div className="relative w-full max-w-3xl mx-auto flex flex-col gap-0">
|
|
||||||
<div className="absolute left-[9px] top-3 bottom-3 w-px bg-gradient-to-b from-white/30 to-white/0" />
|
|
||||||
{jobs.map((job) => (
|
|
||||||
<div key={job.id} className="flex gap-6 pb-10 last:pb-0">
|
|
||||||
<div className="flex flex-col items-center shrink-0 w-5 pt-1.5 z-10">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-white/40 border border-white/20 shadow-[0_0_8px_rgba(255,255,255,0.2)]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-white/5 border border-white/10 rounded-2xl p-5 hover:border-white/20 transition-colors">
|
|
||||||
<div className="flex justify-between items-start flex-wrap gap-1 mb-1">
|
|
||||||
<h4 className="text-lg font-semibold text-white">{job.position}</h4>
|
|
||||||
<span className="text-xs text-white/40">
|
|
||||||
{formatDate(job.start_date)} –{" "}
|
|
||||||
{job.still_working ? "Present" : formatDate(job.end_date!)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-white/60 mb-4">{job.company}</p>
|
|
||||||
|
|
||||||
{job.summary && (
|
|
||||||
<p className="text-sm text-white/50 italic mb-4">{job.summary}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{job.sub_phases && job.sub_phases.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-3 mb-4">
|
|
||||||
{job.sub_phases.map((phase) => (
|
|
||||||
<div
|
|
||||||
key={phase.label}
|
|
||||||
className="bg-white/[0.03] border border-white/[0.07] rounded-xl px-4 py-3"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center flex-wrap gap-1 mb-2">
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-white/50">
|
|
||||||
{phase.label}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-white/30">
|
|
||||||
{formatDate(phase.start_date)} –{" "}
|
|
||||||
{phase.end_date ? formatDate(phase.end_date) : "Present"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ul className="list-disc list-inside space-y-1">
|
|
||||||
{phase.bullets.map((b) => (
|
|
||||||
<li key={b} className="text-xs text-white/50 leading-relaxed">
|
|
||||||
{b}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="border-t border-white/10 pt-4 mt-2">
|
|
||||||
<p className="text-[10px] uppercase tracking-widest text-white/30 mb-2">
|
|
||||||
Technologies
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{job.technologies.map((tech) => (
|
|
||||||
<Chip key={tech} text={tech} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ExperienceTimeline;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify TypeScript is happy**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add components/experience-timeline.tsx
|
|
||||||
git commit -m "feat: add ExperienceTimeline server component"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Wire up timeline in Experience section and remove old card
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `components/experience.tsx`
|
|
||||||
- Delete: `components/experience-card.tsx`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace `experience.tsx`**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Job } from "@/lib/types";
|
|
||||||
import ExperienceTimeline from "@/components/experience-timeline";
|
|
||||||
|
|
||||||
const Experience = ({ jobs }: { jobs: Job[] }) => (
|
|
||||||
<div
|
|
||||||
id="experience"
|
|
||||||
className="flex flex-col items-center gap-8 p-4 w-full"
|
|
||||||
>
|
|
||||||
<h3 className="mt-4 text-5xl font-bold tracking-tight text-white">
|
|
||||||
Experience
|
|
||||||
</h3>
|
|
||||||
<ExperienceTimeline jobs={jobs} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Experience;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Delete the old card component**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm components/experience-card.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify no remaining imports of ExperienceCard**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -r "experience-card\|ExperienceCard" /mnt/drive/dev/gabrielkaszewski-next --include="*.tsx" --include="*.ts" | grep -v node_modules
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify TypeScript is happy**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Start dev server and visually verify the timeline renders correctly**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://localhost:3000` and scroll to the Experience section. Verify:
|
|
||||||
- Vertical line and dots are visible
|
|
||||||
- WPP shows summary + two sub-phase blocks with bullets
|
|
||||||
- digimonkeys.com shows only dates + chips (no sub-phases)
|
|
||||||
- GIAP shows two sub-phase blocks with bullets
|
|
||||||
- Tech chips render under each entry
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add components/experience.tsx
|
|
||||||
git commit -m "feat: replace experience card carousel with vertical timeline"
|
|
||||||
```
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Experience Section — Vertical Timeline Redesign
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Replace the horizontal card carousel in the Experience section with a full-width vertical timeline. The new design surfaces job descriptions and sub-phase breakdowns directly from static data — no JavaScript required.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- No client-side JavaScript. All content is statically rendered.
|
|
||||||
- Data lives in `lib/data.ts`; the `Job` type in `lib/types.ts` is extended to support the new fields.
|
|
||||||
|
|
||||||
## Data Model Changes
|
|
||||||
|
|
||||||
### `lib/types.ts`
|
|
||||||
|
|
||||||
Add `JobSubPhase` and extend `Job`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface JobSubPhase {
|
|
||||||
label: string;
|
|
||||||
start_date: string;
|
|
||||||
end_date: string | null; // null = present
|
|
||||||
bullets: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Job {
|
|
||||||
id: number;
|
|
||||||
position: string;
|
|
||||||
company: string;
|
|
||||||
still_working: boolean;
|
|
||||||
start_date: string;
|
|
||||||
end_date: string | null;
|
|
||||||
technologies: string[];
|
|
||||||
summary?: string; // optional one-liner shown below company name
|
|
||||||
sub_phases?: JobSubPhase[]; // optional; if absent, no bullets shown
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `lib/data.ts`
|
|
||||||
|
|
||||||
Update all three job entries:
|
|
||||||
|
|
||||||
**WPP** — add `summary` + two sub-phases:
|
|
||||||
- Backend & Infrastructure (Mar 2025 – Present)
|
|
||||||
- Frontend Architecture (Sep 2023 – Mar 2025)
|
|
||||||
|
|
||||||
**digimonkeys.com** — no changes to content (no summary, no sub_phases).
|
|
||||||
|
|
||||||
**GIAP** — add two sub-phases:
|
|
||||||
- Desktop / Backend (May 2021 – Feb 2022)
|
|
||||||
- Frontend (Feb 2022 – Feb 2023)
|
|
||||||
|
|
||||||
Bullet text sourced from CV verbatim (slightly trimmed for display).
|
|
||||||
|
|
||||||
## Component Changes
|
|
||||||
|
|
||||||
### Remove
|
|
||||||
|
|
||||||
- `components/experience-card.tsx` — replaced entirely.
|
|
||||||
|
|
||||||
### Add
|
|
||||||
|
|
||||||
- `components/experience-timeline.tsx` — renders the full timeline list as a server component. Accepts `jobs: Job[]`. Renders each entry as a timeline card with dot, vertical line, header, optional sub-phases, and tech chips. Pure JSX, no `useState`/`useEffect`.
|
|
||||||
|
|
||||||
### Update
|
|
||||||
|
|
||||||
- Wherever the Experience section renders `ExperienceCard` in a scroll container — replace with `<ExperienceTimeline jobs={jobs} />`. Remove the horizontal scroll wrapper.
|
|
||||||
|
|
||||||
## Visual Design
|
|
||||||
|
|
||||||
- Vertical connecting line: left-aligned, gradient from accent color to transparent.
|
|
||||||
- Each entry: glassmorphism card (`bg-white/5`, `border-white/10`, `rounded-2xl`), matching the existing site style.
|
|
||||||
- Sub-phases rendered as nested cards inside the entry card (`bg-white/[0.03]`, `border-white/[0.07]`, `rounded-xl`).
|
|
||||||
- Sub-phase header: label in small uppercase accent color + date right-aligned.
|
|
||||||
- Bullets: `<ul>` with `text-sm text-white/60`.
|
|
||||||
- Tech chips: existing `<Chip>` component reused.
|
|
||||||
- Divider line between summary/phases and chips: `border-t border-white/10`.
|
|
||||||
|
|
||||||
## Entries (final)
|
|
||||||
|
|
||||||
| Job | Sub-phases | Bullets |
|
|
||||||
|-----|-----------|---------|
|
|
||||||
| WPP (Sep 2023–Present) | Backend & Infra, Frontend Arch | Yes |
|
|
||||||
| digimonkeys.com (May 2021–Present) | None | No |
|
|
||||||
| GIAP (May 2021–Feb 2023) | Desktop/Backend, Frontend | Yes |
|
|
||||||
|
|
||||||
Freelance period (Feb 2023–Sep 2023) is intentionally excluded.
|
|
||||||
233
lib/gravity-engine.ts
Normal file
233
lib/gravity-engine.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
interface PhysicsBody {
|
||||||
|
el: HTMLElement;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
originX: number;
|
||||||
|
originY: number;
|
||||||
|
isDragging: boolean;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GravityEngine {
|
||||||
|
private bodies: PhysicsBody[] = [];
|
||||||
|
private dirtyElements: Array<{ el: HTMLElement; originalCssText: string }> = [];
|
||||||
|
private animationFrameId: number | null = null;
|
||||||
|
private isRunning = false;
|
||||||
|
|
||||||
|
INITIAL_FORCE = 10;
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.isRunning) return;
|
||||||
|
this.isRunning = true;
|
||||||
|
this.dirtyElements = [];
|
||||||
|
|
||||||
|
const MAX_DEPTH = 3;
|
||||||
|
const containers = document.querySelectorAll(".gravity-body");
|
||||||
|
const leafElements: HTMLElement[] = [];
|
||||||
|
const intermediates: { el: HTMLElement; w: number; h: number }[] = [];
|
||||||
|
|
||||||
|
// READ-ONLY traversal: classify all elements and snapshot sizes.
|
||||||
|
// No DOM writes here — one layout flush for the entire traversal.
|
||||||
|
const collectElements = (el: HTMLElement, depth: number) => {
|
||||||
|
// Skip purely decorative elements (e.g. background images).
|
||||||
|
if (el.classList.contains("pointer-events-none")) return;
|
||||||
|
|
||||||
|
const isSolid = ["SVG", "IMG", "BUTTON", "IFRAME", "A"].includes(
|
||||||
|
el.tagName.toUpperCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Treat as a leaf if solid, childless, or at the depth cap.
|
||||||
|
if (isSolid || el.children.length === 0 || depth >= MAX_DEPTH) {
|
||||||
|
if (el.offsetWidth > 0 && el.offsetHeight > 0) {
|
||||||
|
leafElements.push(el);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
intermediates.push({ el, w: el.offsetWidth, h: el.offsetHeight });
|
||||||
|
Array.from(el.children).forEach((child) =>
|
||||||
|
collectElements(child as HTMLElement, depth + 1),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootSizes: { el: HTMLElement; w: number; h: number }[] = [];
|
||||||
|
containers.forEach((container) => {
|
||||||
|
const htmlContainer = container as HTMLElement;
|
||||||
|
rootSizes.push({
|
||||||
|
el: htmlContainer,
|
||||||
|
w: htmlContainer.offsetWidth,
|
||||||
|
h: htmlContainer.offsetHeight,
|
||||||
|
});
|
||||||
|
Array.from(htmlContainer.children).forEach((child) =>
|
||||||
|
collectElements(child as HTMLElement, 0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WRITE PASS 1: size-lock roots and intermediates so they don't collapse
|
||||||
|
// when their children become position:fixed.
|
||||||
|
[...rootSizes, ...intermediates].forEach(({ el, w, h }) => {
|
||||||
|
this.dirtyElements.push({ el, originalCssText: el.style.cssText });
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// READ PASS 2: batch all getBoundingClientRect calls before any leaf writes.
|
||||||
|
const snapshots = leafElements.map((el) => ({
|
||||||
|
el,
|
||||||
|
rect: el.getBoundingClientRect(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// WRITE PASS 2: apply fixed positioning to leaves using snapshotted rects.
|
||||||
|
this.bodies = snapshots.map(({ el, rect }) => {
|
||||||
|
this.dirtyElements.push({ el, originalCssText: el.style.cssText });
|
||||||
|
el.style.width = `${rect.width}px`;
|
||||||
|
el.style.height = `${rect.height}px`;
|
||||||
|
el.style.margin = "0px";
|
||||||
|
el.style.position = "fixed";
|
||||||
|
el.style.left = "0px";
|
||||||
|
el.style.top = "0px";
|
||||||
|
el.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
|
||||||
|
|
||||||
|
const body: PhysicsBody = {
|
||||||
|
el,
|
||||||
|
x: rect.left,
|
||||||
|
y: rect.top,
|
||||||
|
vx: (Math.random() - 0.5) * 8,
|
||||||
|
vy: (Math.random() - 0.5) * 5,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
originX: rect.left,
|
||||||
|
originY: rect.top,
|
||||||
|
isDragging: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.attachMouseEvents(body);
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.isRunning = false;
|
||||||
|
if (this.animationFrameId) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
this.animationFrameId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DURATION = 600;
|
||||||
|
|
||||||
|
// Glide each body back to its origin position.
|
||||||
|
this.bodies.forEach((body) => {
|
||||||
|
body.el.style.transition = `transform ${DURATION}ms cubic-bezier(0.22, 1, 0.36, 1)`;
|
||||||
|
body.el.style.transform = `translate(${body.originX}px, ${body.originY}px)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// After the transition completes, restore every element's original inline
|
||||||
|
// styles so document flow is fully recovered — including elements that had
|
||||||
|
// pre-existing inline styles we must not erase (e.g. Next.js fill images).
|
||||||
|
const elementsToClear = [...this.dirtyElements];
|
||||||
|
this.bodies = [];
|
||||||
|
this.dirtyElements = [];
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
elementsToClear.forEach(({ el, originalCssText }) => {
|
||||||
|
el.style.cssText = originalCssText;
|
||||||
|
});
|
||||||
|
}, DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachMouseEvents(body: PhysicsBody) {
|
||||||
|
// Prevent default drag behaviors that interfere with physics
|
||||||
|
body.el.ondragstart = () => false;
|
||||||
|
|
||||||
|
body.el.addEventListener("pointerdown", (e) => {
|
||||||
|
body.isDragging = true;
|
||||||
|
body.startX = e.clientX;
|
||||||
|
body.startY = e.clientY;
|
||||||
|
body.vx = 0;
|
||||||
|
body.vy = 0;
|
||||||
|
body.el.setPointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
body.el.addEventListener("pointermove", (e) => {
|
||||||
|
if (!body.isDragging) return;
|
||||||
|
|
||||||
|
// Calculate velocity based on mouse movement for the "throw"
|
||||||
|
body.vx = e.movementX * 0.5;
|
||||||
|
body.vy = e.movementY * 0.5;
|
||||||
|
|
||||||
|
body.x += e.movementX;
|
||||||
|
body.y += e.movementY;
|
||||||
|
});
|
||||||
|
|
||||||
|
body.el.addEventListener("pointerup", (e) => {
|
||||||
|
body.isDragging = false;
|
||||||
|
body.el.releasePointerCapture(e.pointerId);
|
||||||
|
|
||||||
|
// The Drag vs. Click Resolver
|
||||||
|
const deltaX = Math.abs(e.clientX - body.startX);
|
||||||
|
const deltaY = Math.abs(e.clientY - body.startY);
|
||||||
|
|
||||||
|
// If the user moved the mouse less than 5px, treat it as a click
|
||||||
|
if (deltaX > 5 || deltaY > 5) {
|
||||||
|
// It was a drag. Prevent links from firing.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch clicks at the capture phase to stop them if we were dragging
|
||||||
|
body.el.addEventListener(
|
||||||
|
"click",
|
||||||
|
(e) => {
|
||||||
|
const deltaX = Math.abs(e.clientX - body.startX);
|
||||||
|
const deltaY = Math.abs(e.clientY - body.startY);
|
||||||
|
if (deltaX > 5 || deltaY > 5) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick = () => {
|
||||||
|
if (!this.isRunning) return;
|
||||||
|
|
||||||
|
const gravity = 0.5;
|
||||||
|
const bounce = -0.7;
|
||||||
|
const floorY = window.innerHeight;
|
||||||
|
|
||||||
|
this.bodies.forEach((body) => {
|
||||||
|
if (body.isDragging) {
|
||||||
|
body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.vy += gravity;
|
||||||
|
body.x += body.vx;
|
||||||
|
body.y += body.vy;
|
||||||
|
|
||||||
|
body.vx *= 0.99;
|
||||||
|
body.vy *= 0.99;
|
||||||
|
|
||||||
|
if (body.y + body.height > floorY) {
|
||||||
|
body.y = floorY - body.height;
|
||||||
|
body.vy *= bounce;
|
||||||
|
body.vx *= 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.tick);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user