Compare commits

..

11 Commits

15 changed files with 278 additions and 849 deletions

1
.gitignore vendored
View File

@@ -39,4 +39,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.superpowers/

View File

@@ -67,7 +67,7 @@ const AboutPage = () => {
const age = calculateAge("2002-02-27");
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">
<Image
src="/images/ja.avif"

View File

@@ -100,7 +100,7 @@ const KSuiteOrganism = () => {
const radius = 35;
const positions = kSuiteApps.map((_, i) =>
getPosition(i, kSuiteApps.length, radius)
getPosition(i, kSuiteApps.length, radius),
);
return (
@@ -298,7 +298,7 @@ const KSuiteAppCard = ({ app }: { app: KSuiteApp }) => {
export default function KSuitePage() {
return (
<div className="min-h-screen pt-20">
<div className="min-h-screen pt-20 gravity-body">
{/* Hero Section */}
<section className="relative py-16 md:py-24 overflow-hidden">
<div className="container mx-auto px-4 text-center">

View File

@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Navbar from "@/components/navbar";
import Footer from "@/components/footer";
import GravityToggle from "@/components/gravity-toggle";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -98,6 +99,7 @@ export default function RootLayout({
<Navbar />
{children}
<GravityToggle />
<Footer />
</body>
</html>

View File

@@ -1,96 +0,0 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Movie Corner",
description:
"My personal cinema journal — what I watch and how I feel about it.",
};
const MOVIE_REVIEWS_URL =
"https://movies.gabrielkaszewski.dev/users/5d253151-0f6a-4246-9bc5-cb0b5869731b";
const genres = ["Sci-Fi", "Drama", "Family"];
export default function MovieCornerPage() {
return (
<div className="min-h-screen flex flex-col pt-20 gravity-body">
<section className="py-12 px-4 text-center relative">
<div className="absolute inset-0 flex justify-center pointer-events-none">
<div className="w-64 h-32 bg-yellow-400/10 blur-3xl rounded-full mt-4" />
</div>
<div className="relative">
<div className="text-5xl mb-4">🎬</div>
<h1 className="text-4xl md:text-6xl font-bold tracking-widest text-yellow-400 uppercase mb-3">
Movie Corner
</h1>
<p className="text-white/60 text-base md:text-lg mb-8">
What I watch, what I think. A personal cinema journal.
</p>
<blockquote className="border-l-2 border-yellow-400/50 bg-white/5 backdrop-blur-sm rounded-r-lg px-6 py-4 max-w-xl mx-auto text-left mb-8">
<p className="text-white/80 text-sm italic leading-relaxed mb-2">
&ldquo;I&apos;d only give one piece of advice to anyone marrying.
We&apos;re all quite similar in the end. We all get old and tell
the same tales too many times. But try and marry someone
kind.&rdquo;
</p>
<cite className="text-yellow-400/70 text-xs not-italic">
About Time, 2013
</cite>
</blockquote>
<div className="flex justify-center gap-3 flex-wrap">
{genres.map((genre, i) => (
<span
key={genre}
className={`px-4 py-1.5 rounded-full text-sm border ${
i === 0
? "bg-yellow-400/10 border-yellow-400/40 text-yellow-400"
: "bg-white/5 border-white/15 text-white/70"
}`}
>
{genre}
</span>
))}
</div>
</div>
</section>
<section className="flex-1 flex flex-col border-t border-white/10 min-h-0 items-center">
<div className="flex-1 flex flex-col w-full max-w-[1200px] min-h-0 px-4 py-4">
<div
className="flex-1 flex flex-col min-h-0 bg-gradient-to-br from-yellow-400/60 via-amber-400/30 to-yellow-400/50 p-[2px] rounded-lg"
style={{
boxShadow:
"0 0 40px rgba(250,204,21,0.2), 0 0 80px rgba(250,204,21,0.08)",
}}
>
<div className="flex-1 overflow-hidden rounded-md flex flex-col">
<iframe
src={MOVIE_REVIEWS_URL}
title="Gabriel's Movie Reviews"
allow=""
loading="lazy"
className="flex-1 min-h-0"
style={{ width: "calc(100% + 20px)" }}
/>
</div>
</div>
</div>
<div className="py-3 text-center">
<a
href={MOVIE_REVIEWS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-yellow-400/70 text-xs hover:text-yellow-400 transition-colors"
>
Open reviews directly
</a>
</div>
</section>
</div>
);
}

View File

@@ -7,7 +7,7 @@ import { skills, jobs } from "@/lib/data";
export default function Home() {
return (
<div className="flex flex-col items-center w-full">
<div className="flex flex-col items-center w-full gravity-body">
<Hero />
<div className="container mx-auto px-4">
<AboutSummary />

View File

@@ -10,7 +10,7 @@ import { notFound } from "next/navigation";
function getProjectByName(name: string): Project | undefined {
const decodedName = decodeURIComponent(name.replace(/\+/g, " "));
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;
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">
<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}

View File

@@ -10,7 +10,7 @@ export const metadata: Metadata = {
const ProjectsPage = () => {
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>
<div className="w-full flex flex-col items-center gap-16 mt-8">

View 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>
);
}

View File

@@ -24,7 +24,6 @@ const Navbar = () => {
{ href: "/", label: "Home" },
{ href: "/k-suite", label: "K-Suite" },
{ href: "/projects", label: "Projects" },
{ href: "/movie-corner", label: "Movie Corner" },
{
href: "https://blog.gabrielkaszewski.dev/",
label: "Blog",

View File

@@ -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"
```

View File

@@ -1,199 +0,0 @@
# Movie Corner 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:** Add a `/movie-corner` page with a cinematic hero section and a full-height iframe embedding the external movie reviews site.
**Architecture:** Two-task implementation — navbar link first, then the page itself. The page is a single static component: a fixed-height hero section followed by an iframe that grows to fill all remaining viewport height via flexbox.
**Tech Stack:** Next.js 15 (App Router), Tailwind CSS v4, TypeScript
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `components/navbar.tsx` | Modify | Add "Movie Corner" nav link |
| `app/movie-corner/page.tsx` | Create | Full page: metadata + hero + iframe |
---
### Task 1: Add "Movie Corner" to the navbar
**Files:**
- Modify: `components/navbar.tsx`
The `navLinks` array currently has: Home, K-Suite, Projects, Blog, About. Insert Movie Corner between Projects and Blog.
- [ ] **Step 1: Edit `components/navbar.tsx`**
Find this block:
```ts
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/k-suite", label: "K-Suite" },
{ href: "/projects", label: "Projects" },
{
href: "https://blog.gabrielkaszewski.dev/",
label: "Blog",
external: true,
},
{ href: "/about", label: "About" },
];
```
Replace with:
```ts
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/k-suite", label: "K-Suite" },
{ href: "/projects", label: "Projects" },
{ href: "/movie-corner", label: "Movie Corner" },
{
href: "https://blog.gabrielkaszewski.dev/",
label: "Blog",
external: true,
},
{ href: "/about", label: "About" },
];
```
- [ ] **Step 2: Type-check**
```bash
npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add components/navbar.tsx
git commit -m "feat: add Movie Corner to navbar"
```
---
### Task 2: Create the Movie Corner page
**Files:**
- Create: `app/movie-corner/page.tsx`
The page is a `min-h-screen flex flex-col` container with `pt-20 gravity-body` (matching all other pages). The hero takes its natural height; the iframe section has `flex-1` so it fills the rest.
- [ ] **Step 1: Create `app/movie-corner/page.tsx`**
```tsx
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Movie Corner | Gabriel Kaszewski",
description:
"My personal cinema journal — what I watch and how I feel about it.",
};
const MOVIE_REVIEWS_URL =
"https://movies.gabrielkaszewski.dev/users/5d253151-0f6a-4246-9bc5-cb0b5869731b";
const genres = ["Sci-Fi", "Drama", "Family"];
export default function MovieCornerPage() {
return (
<div className="min-h-screen flex flex-col pt-20 gravity-body">
<section className="py-12 px-4 text-center relative">
<div className="absolute inset-0 flex justify-center pointer-events-none">
<div className="w-64 h-32 bg-yellow-400/10 blur-3xl rounded-full mt-4" />
</div>
<div className="relative">
<div className="text-5xl mb-4">🎬</div>
<h1 className="text-4xl md:text-6xl font-bold tracking-widest text-yellow-400 uppercase mb-3">
Movie Corner
</h1>
<p className="text-white/60 text-base md:text-lg mb-8">
What I watch, what I think. A personal cinema journal.
</p>
<blockquote className="border-l-2 border-yellow-400/50 bg-white/5 backdrop-blur-sm rounded-r-lg px-6 py-4 max-w-xl mx-auto text-left mb-8">
<p className="text-white/80 text-sm italic leading-relaxed mb-2">
&ldquo;I&apos;d only give one piece of advice to anyone marrying.
We&apos;re all quite similar in the end. We all get old and tell
the same tales too many times. But try and marry someone
kind.&rdquo;
</p>
<cite className="text-yellow-400/70 text-xs not-italic">
About Time, 2013
</cite>
</blockquote>
<div className="flex justify-center gap-3 flex-wrap">
{genres.map((genre, i) => (
<span
key={genre}
className={`px-4 py-1.5 rounded-full text-sm border ${
i === 0
? "bg-yellow-400/10 border-yellow-400/40 text-yellow-400"
: "bg-white/5 border-white/15 text-white/70"
}`}
>
{genre}
</span>
))}
</div>
</div>
</section>
<section className="flex-1 flex flex-col border-t border-white/10 min-h-0">
<iframe
src={MOVIE_REVIEWS_URL}
title="Gabriel's Movie Reviews"
className="flex-1 w-full min-h-0"
/>
<div className="py-3 text-center">
<a
href={MOVIE_REVIEWS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-yellow-400/70 text-xs hover:text-yellow-400 transition-colors"
>
Open reviews directly
</a>
</div>
</section>
</div>
);
}
```
- [ ] **Step 2: Type-check**
```bash
npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 3: Verify dev server renders the page**
```bash
bun run dev
```
Open `http://localhost:3000/movie-corner`. Verify:
- Navbar shows "Movie Corner" highlighted in yellow
- Hero: 🎬 icon, "MOVIE CORNER" title in yellow, tagline, quote card with left yellow border, three genre chips (Sci-Fi yellow, Drama + Family glass)
- Iframe fills remaining screen height with the movie reviews site
- "Open reviews directly →" link visible at the bottom
- [ ] **Step 4: Commit**
```bash
git add app/movie-corner/page.tsx
git commit -m "feat: add movie-corner page with cinematic hero and embedded reviews"
```

View File

@@ -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 2023Present) | Backend & Infra, Frontend Arch | Yes |
| digimonkeys.com (May 2021Present) | None | No |
| GIAP (May 2021Feb 2023) | Desktop/Backend, Frontend | Yes |
Freelance period (Feb 2023Sep 2023) is intentionally excluded.

View File

@@ -1,80 +0,0 @@
# Movie Corner Page — Design Spec
## Overview
A new `/movie-corner` page on gabrielkaszewski.dev that embeds the user's external movie review site and introduces it with a cinematic hero section.
## Goals
- Give the movie review site a home on the personal portfolio
- Welcome visitors with personality before showing the embed
- Keep the design consistent with the rest of the site (dark glassmorphism, yellow accents, `gravity-body`)
## Page Structure
Two vertically stacked sections filling the full viewport height (`min-h-screen flex flex-col`):
1. **Hero section** — fixed height, centered content
2. **Iframe section**`flex-1`, fills all remaining height
No page-level scroll after the hero. The iframe scrolls internally.
## Hero Section
Centered, padded, with a subtle radial yellow glow behind the icon.
| Element | Detail |
|---|---|
| Icon | 🎬 emoji, large |
| Title | "MOVIE CORNER", uppercase, yellow (`text-yellow-400`), large tracking |
| Tagline | "What I watch, what I think. A personal cinema journal." — muted white |
| Quote | Left-bordered card (yellow left border, glass background): *"I'd only give one piece of advice to anyone marrying. We're all quite similar in the end. We all get old and tell the same tales too many times. But try and marry someone kind."* — About Time, 2013 |
| Genre chips | Three pills: **Sci-Fi** (yellow tint), **Drama** (white/glass), **Family** (white/glass) |
The quote card uses `border-l-2 border-yellow-400/50 bg-white/5` consistent with the site's glass style.
## Iframe Section
- `flex-1 w-full` — grows to fill remaining page height
- `src`: `https://movies.gabrielkaszewski.dev/users/5d253151-0f6a-4246-9bc5-cb0b5869731b`
- `title`: "Gabriel's Movie Reviews"
- No JS error handling — a permanent **"Open reviews directly →"** text link (yellow, small) sits below the iframe as an always-visible fallback
- Thin top border separating hero from iframe (`border-t border-white/10`)
## Navbar
Add "Movie Corner" to the `navLinks` array in `components/navbar.tsx`:
```ts
{ href: "/movie-corner", label: "Movie Corner" }
```
Inserted after "Projects" and before "Blog".
## Metadata
```ts
export const metadata: Metadata = {
title: "Movie Corner | Gabriel Kaszewski",
description: "My personal cinema journal — what I watch and how I feel about it.",
}
```
## Styling Constraints
- Use `gravity-body` class on the page root (matches all other pages)
- `pt-20` on the page root to clear the fixed navbar
- No new CSS — use only existing Tailwind utilities and glass classes already in the project (`backdrop-blur-sm`, `bg-white/5`, `border-white/10`, etc.)
## Files to Touch
| File | Change |
|---|---|
| `app/movie-corner/page.tsx` | Create — full page implementation |
| `components/navbar.tsx` | Add "Movie Corner" nav link |
## Out of Scope
- Fetching live stats (films watched, avg rating) — static content only
- JS error boundary on the iframe
- Any backend or API work

233
lib/gravity-engine.ts Normal file
View 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);
};
}