Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b115c2284 | |||
| a3ccba0663 | |||
| cc284c27a9 | |||
| 7cec30170a | |||
| abe307a6ca | |||
| 7cc57e966b | |||
| 2ba0b90fce | |||
| 415dcb8459 | |||
| abde9ff0e9 | |||
| 571cf35151 | |||
| abb7651e41 |
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.git
|
||||
.superpowers
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
- name: Rebuild and Deploy with Docker Compose
|
||||
run: |
|
||||
docker build --build-arg NEXT_PUBLIC_SHOW_FUN_ZONE=false -t local/gabriel-portfolio:latest .
|
||||
docker build --target release -t local/gabriel-portfolio:latest .
|
||||
docker compose up -d
|
||||
|
||||
# Clean up any old, unused images to save disk space
|
||||
|
||||
1
.gitignore
vendored
@@ -39,4 +39,3 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.superpowers/
|
||||
|
||||
33
Dockerfile
@@ -1,16 +1,31 @@
|
||||
FROM oven/bun:1 AS build
|
||||
FROM oven/bun:1 AS base
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
FROM base AS install
|
||||
RUN mkdir -p /temp/dev
|
||||
COPY package.json bun.lock /temp/dev/
|
||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
||||
|
||||
RUN mkdir -p /temp/prod
|
||||
COPY package.json bun.lock /temp/prod/
|
||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||
|
||||
FROM base AS prerelease
|
||||
COPY --from=install /temp/dev/node_modules node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ARG NEXT_PUBLIC_SHOW_FUN_ZONE=false
|
||||
ENV NEXT_PUBLIC_SHOW_FUN_ZONE=$NEXT_PUBLIC_SHOW_FUN_ZONE
|
||||
RUN bun run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/out /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
FROM base AS release
|
||||
|
||||
COPY --from=prerelease /app/public ./public
|
||||
COPY --from=prerelease /app/.next ./.next
|
||||
COPY --from=prerelease /app/node_modules ./node_modules
|
||||
COPY --from=prerelease /app/package.json ./package.json
|
||||
|
||||
USER bun
|
||||
EXPOSE 3000/tcp
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import Chip from "@/components/chip";
|
||||
import WritingsList from "@/components/writings-list";
|
||||
import { writings } from "@/lib/data";
|
||||
import { Metadata } from "next";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
@@ -10,24 +7,9 @@ export const metadata: Metadata = {
|
||||
title: "About Me | Gabriel Kaszewski",
|
||||
description:
|
||||
"Learn more about Gabriel Kaszewski, his skills, and his journey as a developer.",
|
||||
alternates: {
|
||||
canonical: "https://gabrielkaszewski.dev/about",
|
||||
},
|
||||
openGraph: {
|
||||
title: "About Me | Gabriel Kaszewski",
|
||||
description:
|
||||
"Learn more about Gabriel Kaszewski, his skills, and his journey as a developer.",
|
||||
url: "https://gabrielkaszewski.dev/about",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: "About Me | Gabriel Kaszewski",
|
||||
description:
|
||||
"Learn more about Gabriel Kaszewski, his skills, and his journey as a developer.",
|
||||
},
|
||||
};
|
||||
|
||||
const hobbies = ["Programming", "Filmmaking", "Photography", "Gaming", "Playing guitar"];
|
||||
const hobbies = ["Programming", "Filmmaking", "Gaming", "Playing guitar"];
|
||||
const interests = [
|
||||
"Computer Science",
|
||||
"Sci-Fi Books",
|
||||
@@ -85,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"
|
||||
@@ -100,7 +82,7 @@ const AboutPage = () => {
|
||||
|
||||
<div className="prose prose-invert lg:prose-lg xl:prose-xl max-w-3xl mx-auto w-full">
|
||||
<section id="more-info">
|
||||
<h2 className="text-center">More info about me!</h2>
|
||||
<h1 className="text-center">More info about me!</h1>
|
||||
<p>
|
||||
Hi! I am Gabriel and I am {age} years old. I graduated in
|
||||
Bioinformatics from the University of Gdańsk. I'm fluent in
|
||||
@@ -123,7 +105,7 @@ const AboutPage = () => {
|
||||
processes using cellular automata" is available{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="/documents/other/licencjat.pdf"
|
||||
href="/licencjat.pdf"
|
||||
className="text-yellow-400 underline"
|
||||
>
|
||||
here
|
||||
@@ -154,18 +136,8 @@ const AboutPage = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="photography" className="mt-12">
|
||||
<p>
|
||||
I also enjoy photography. You can see some of my shots{" "}
|
||||
<Link href="/photos" className="text-yellow-400 underline">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="philosophy" className="mt-12">
|
||||
<h2>My Philosophy</h2>
|
||||
<h1>My Philosophy</h1>
|
||||
<p>
|
||||
I believe much of today's software is bloated, inefficient, and
|
||||
disrespectful of the user's resources. My passion, which
|
||||
@@ -178,8 +150,35 @@ const AboutPage = () => {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="toolkit">
|
||||
<h1>My Toolkit</h1>
|
||||
<div className="not-prose bg-black/20 backdrop-blur-sm glass-effect rounded-2xl p-4 text-white text-shadow-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">OS & Hardware</h3>
|
||||
<p>
|
||||
Arch Linux, Custom-built PC (Ryzen 7 5800X3D, RTX 4070 Ti,
|
||||
48GB RAM)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">Editor</h3>
|
||||
<p>Jetbrains IDEs (Pycharm, Rider) & VS Code</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">Primary Languages</h3>
|
||||
<p>Rust, Python, C#, TypeScript</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">Favorite Tech</h3>
|
||||
<p>Axum, Godot, React, TailwindCSS</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="future-goals" className="mt-12">
|
||||
<h2>Future Goals</h2>
|
||||
<h1>Future Goals</h1>
|
||||
<p>
|
||||
I'm always eager to learn and grow. My goal is to continue
|
||||
honing my skills in backend development and system architecture.
|
||||
@@ -195,16 +194,8 @@ const AboutPage = () => {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="writings" className="mt-12">
|
||||
<h2>Writings</h2>
|
||||
<p>Sometimes I dabble in writing, here are some of my works</p>
|
||||
<div className="not-prose flex justify-center mt-4">
|
||||
<WritingsList writings={writings} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="faq" className="mt-12">
|
||||
<h2>FAQ</h2>
|
||||
<h1>FAQ</h1>
|
||||
<div className="not-prose flex flex-col gap-4">
|
||||
{faqItems.map((item) => (
|
||||
<div key={item.q}>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import FunZoneCarousel from "@/components/fun-zone-carousel";
|
||||
import { funZoneItems } from "@/lib/data";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Fun Zone | Gabriel Kaszewski",
|
||||
description: "Games, tools, and toys I built for fun. Try them out!",
|
||||
keywords: [
|
||||
"games",
|
||||
"pixel canvas",
|
||||
"wordle",
|
||||
"game jam",
|
||||
"interactive",
|
||||
"playground",
|
||||
],
|
||||
alternates: {
|
||||
canonical: "https://gabrielkaszewski.dev/fun-zone",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Fun Zone | Gabriel Kaszewski",
|
||||
description: "Games, tools, and toys I built for fun. Try them out!",
|
||||
url: "https://gabrielkaszewski.dev/fun-zone",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: "Fun Zone | Gabriel Kaszewski",
|
||||
description: "Games, tools, and toys I built for fun. Try them out!",
|
||||
},
|
||||
};
|
||||
|
||||
const FunZonePage = () => {
|
||||
return (
|
||||
<div className="flex w-full min-h-screen flex-col items-center gap-8 pt-24 px-4 pb-16">
|
||||
<div className="text-center">
|
||||
<h1 className="text-5xl font-bold text-yellow-400 text-shadow-lg">
|
||||
Fun Zone
|
||||
</h1>
|
||||
<p className="text-white/50 mt-2">stuff I made for fun</p>
|
||||
</div>
|
||||
|
||||
<FunZoneCarousel items={funZoneItems} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FunZonePage;
|
||||
@@ -1,28 +1,6 @@
|
||||
import { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { ExternalLink, Github } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "K-Suite | Gabriel Kaszewski",
|
||||
description:
|
||||
"A cohesive ecosystem of open-source, self-hosted applications designed to restore digital sovereignty to the user.",
|
||||
alternates: {
|
||||
canonical: "https://gabrielkaszewski.dev/k-suite",
|
||||
},
|
||||
openGraph: {
|
||||
title: "K-Suite | Gabriel Kaszewski",
|
||||
description:
|
||||
"A cohesive ecosystem of open-source, self-hosted applications designed to restore digital sovereignty to the user.",
|
||||
url: "https://gabrielkaszewski.dev/k-suite",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: "K-Suite | Gabriel Kaszewski",
|
||||
description:
|
||||
"A cohesive ecosystem of open-source, self-hosted applications designed to restore digital sovereignty to the user.",
|
||||
},
|
||||
};
|
||||
|
||||
interface KSuiteApp {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -51,15 +29,39 @@ const kSuiteApps: KSuiteApp[] = [
|
||||
{
|
||||
id: 2,
|
||||
name: "Thoughts",
|
||||
shortDescription: "Federated microblogging platform",
|
||||
shortDescription: "Microblogging platform",
|
||||
description:
|
||||
"Nostalgic social platform with Frutiger Aero style. 128-char posts, custom CSS profiles, and full ActivityPub federation — interoperable with Mastodon, Misskey, and Movies Diary.",
|
||||
"Nostalgic social platform with Frutiger Aero style. 128-char posts, custom CSS profiles.",
|
||||
url: "https://thoughts.gabrielkaszewski.dev/",
|
||||
githubUrl: "https://git.gabrielkaszewski.dev/GKaszewski/thoughts",
|
||||
icon: "/images/thoughts.avif",
|
||||
technologies: ["Rust", "Next.js", "Axum", "ActivityPub", "PostgreSQL", "NATS"],
|
||||
technologies: ["Rust", "Next.js", "Axum"],
|
||||
color: "from-cyan-400 to-blue-500",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "K-Tuner",
|
||||
shortDescription: "Instrument tuner",
|
||||
description:
|
||||
"Tune guitar, ukulele, and piano with this Frutiger Aero styled PWA.",
|
||||
url: "https://tuner.gabrielkaszewski.dev/",
|
||||
githubUrl: "https://github.com/GKaszewski/k-tuner",
|
||||
icon: "/images/k-tuner.png",
|
||||
technologies: ["React", "PWA"],
|
||||
color: "from-emerald-400 to-teal-500",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "K-QR",
|
||||
shortDescription: "QR code generator",
|
||||
description:
|
||||
"High-performance QR generator. Single Rust executable serving clean HTML.",
|
||||
url: "https://qr.gabrielkaszewski.dev/",
|
||||
githubUrl: "https://github.com/GKaszewski/k-qr",
|
||||
icon: "/images/k-qr.png",
|
||||
technologies: ["Rust", "HTML"],
|
||||
color: "from-amber-400 to-orange-500",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "K-TV",
|
||||
@@ -72,29 +74,18 @@ const kSuiteApps: KSuiteApp[] = [
|
||||
technologies: ["Rust", "Next.js", "TailwindCSS"],
|
||||
color: "from-violet-400 to-purple-500",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Movies Diary",
|
||||
shortDescription: "Self-hosted movie logging with federation",
|
||||
description:
|
||||
"Log every film you watch, rate it, and share reviews across the Fediverse via ActivityPub. Federate with other instances, follow remote users, and browse a fully federated activity feed.",
|
||||
url: "https://movies.gabrielkaszewski.dev/",
|
||||
githubUrl: "https://github.com/GKaszewski/movies-diary",
|
||||
icon: "/images/movies-diary.webp",
|
||||
technologies: ["Rust", "Axum", "ActivityPub", "SQLite"],
|
||||
color: "from-rose-400 to-red-500",
|
||||
},
|
||||
];
|
||||
|
||||
// Connection definitions for the organism
|
||||
const connections = [
|
||||
{ from: 0, to: 1 }, // K-Notes -> Thoughts
|
||||
{ from: 1, to: 2 }, // Thoughts -> K-TV
|
||||
{ from: 2, to: 3 }, // K-TV -> Movies Diary
|
||||
{ from: 3, to: 0 }, // Movies Diary -> K-Notes
|
||||
{ from: 0, to: 2 }, // K-Notes -> K-TV (cross)
|
||||
{ from: 1, to: 3 }, // Thoughts -> Movies Diary (AP)
|
||||
{ from: 3, to: 1 }, // Movies Diary -> Thoughts (federation)
|
||||
{ from: 1, to: 2 }, // Thoughts -> K-Tuner
|
||||
{ from: 2, to: 3 }, // K-Tuner -> K-QR
|
||||
{ from: 3, to: 4 }, // K-QR -> K-TV
|
||||
{ from: 4, to: 0 }, // K-TV -> K-Notes
|
||||
{ from: 0, to: 2 }, // K-Notes -> K-Tuner (cross)
|
||||
{ from: 1, to: 3 }, // Thoughts -> K-QR (cross)
|
||||
{ from: 2, to: 4 }, // K-Tuner -> K-TV (cross)
|
||||
];
|
||||
|
||||
const KSuiteOrganism = () => {
|
||||
@@ -109,7 +100,7 @@ const KSuiteOrganism = () => {
|
||||
|
||||
const radius = 35;
|
||||
const positions = kSuiteApps.map((_, i) =>
|
||||
getPosition(i, kSuiteApps.length, radius)
|
||||
getPosition(i, kSuiteApps.length, radius),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -307,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">
|
||||
|
||||
@@ -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",
|
||||
@@ -15,7 +16,6 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://gabrielkaszewski.dev"),
|
||||
title: {
|
||||
default: "Gabriel Kaszewski | Software Engineer",
|
||||
template: "%s | Gabriel Kaszewski",
|
||||
@@ -30,9 +30,6 @@ export const metadata: Metadata = {
|
||||
"Next.js",
|
||||
"Portfolio",
|
||||
],
|
||||
alternates: {
|
||||
canonical: "https://gabrielkaszewski.dev",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Gabriel Kaszewski | Software Engineer",
|
||||
description:
|
||||
@@ -101,7 +98,8 @@ export default function RootLayout({
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
<main>{children}</main>
|
||||
{children}
|
||||
<GravityToggle />
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
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.",
|
||||
alternates: {
|
||||
canonical: "https://gabrielkaszewski.dev/movie-corner",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Movie Corner | Gabriel Kaszewski",
|
||||
description:
|
||||
"My personal cinema journal — what I watch and how I feel about it.",
|
||||
url: "https://gabrielkaszewski.dev/movie-corner",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
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 MOVIE_REVIEWS_EMBED_URL =
|
||||
"https://movies.gabrielkaszewski.dev/users/5d253151-0f6a-4246-9bc5-cb0b5869731b?embed=true";
|
||||
|
||||
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">
|
||||
“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.”
|
||||
</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-[700px] 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_EMBED_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>
|
||||
);
|
||||
}
|
||||
27
app/page.tsx
@@ -1,43 +1,18 @@
|
||||
import { Metadata } from "next";
|
||||
import AboutSummary from "@/components/about-summary";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Gabriel Kaszewski | Software Engineer",
|
||||
description:
|
||||
"Portfolio of Gabriel Kaszewski, a software engineer building fast, reliable software with Rust, Python, and modern web technologies.",
|
||||
alternates: {
|
||||
canonical: "https://gabrielkaszewski.dev",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Gabriel Kaszewski | Software Engineer",
|
||||
description:
|
||||
"Portfolio of Gabriel Kaszewski, a software engineer building fast, reliable software with Rust, Python, and modern web technologies.",
|
||||
url: "https://gabrielkaszewski.dev",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Gabriel Kaszewski | Software Engineer",
|
||||
description:
|
||||
"Portfolio of Gabriel Kaszewski, a software engineer building fast, reliable software with Rust, Python, and modern web technologies.",
|
||||
},
|
||||
};
|
||||
import CommercialProjects from "@/components/commercial-projects";
|
||||
import Experience from "@/components/experience";
|
||||
import FunZoneCta from "@/components/fun-zone-cta";
|
||||
import Hero from "@/components/hero";
|
||||
import Skills from "@/components/skills";
|
||||
import { skills, jobs } from "@/lib/data";
|
||||
import { featureFlags } from "@/lib/feature-flags";
|
||||
|
||||
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 />
|
||||
<Skills skills={skills} />
|
||||
<CommercialProjects />
|
||||
{featureFlags.funZone && <FunZoneCta />}
|
||||
<Experience jobs={jobs} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import PhotoGallery from "@/components/photo-gallery";
|
||||
import { photos } from "@/lib/data";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Photos | Gabriel Kaszewski",
|
||||
description: "A small collection of photos taken by Gabriel Kaszewski.",
|
||||
keywords: ["photography", "Sony a6400", "Nikon D5300", "photo gallery"],
|
||||
alternates: {
|
||||
canonical: "https://gabrielkaszewski.dev/photos",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Photos | Gabriel Kaszewski",
|
||||
description: "A small collection of photos taken by Gabriel Kaszewski.",
|
||||
url: "https://gabrielkaszewski.dev/photos",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: "Photos | Gabriel Kaszewski",
|
||||
description: "A small collection of photos taken by Gabriel Kaszewski.",
|
||||
},
|
||||
};
|
||||
|
||||
const PhotosPage = () => {
|
||||
return (
|
||||
<div className="flex w-full min-h-screen flex-col items-center gap-8 pt-24 px-4 pb-16">
|
||||
<div className="text-center">
|
||||
<h1 className="text-5xl font-bold text-yellow-400 text-shadow-lg">
|
||||
Photos
|
||||
</h1>
|
||||
<p className="text-white/50 mt-2">shot on Sony a6400 and Nikon D5300</p>
|
||||
</div>
|
||||
|
||||
<PhotoGallery photos={photos} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotosPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getProjectBySlug, projects, projectSlug } from "@/lib/data";
|
||||
import { projects } from "@/lib/data";
|
||||
import { Project } from "@/lib/types";
|
||||
import Chip from "@/components/chip";
|
||||
import MarkdownContent from "@/components/markdown-content";
|
||||
import { Metadata } from "next";
|
||||
@@ -6,36 +7,37 @@ import Image from "next/image";
|
||||
import { Github, Eye, CloudDownload } from "lucide-react";
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return projects.map((project) => ({
|
||||
projectName: projectSlug(project),
|
||||
projectName: encodeURIComponent(project.name.replace(/\s/g, "+")),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectName: string }>;
|
||||
params: { projectName: string };
|
||||
}): Promise<Metadata> {
|
||||
const { projectName } = await params;
|
||||
const project = getProjectBySlug(projectName);
|
||||
const project = getProjectByName(projectName);
|
||||
|
||||
if (!project) {
|
||||
return { title: "Project Not Found" };
|
||||
}
|
||||
|
||||
const slug = projectSlug(project);
|
||||
|
||||
return {
|
||||
title: `${project.name} | Gabriel Kaszewski`,
|
||||
description: project.short_description,
|
||||
alternates: {
|
||||
canonical: `https://gabrielkaszewski.dev/projects/${slug}`,
|
||||
},
|
||||
openGraph: {
|
||||
title: project.name,
|
||||
description: project.short_description,
|
||||
url: `https://gabrielkaszewski.dev/projects/${slug}`,
|
||||
images:
|
||||
project.thumbnails && project.thumbnails.length > 0
|
||||
? [
|
||||
@@ -48,21 +50,16 @@ export async function generateMetadata({
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: project.name,
|
||||
description: project.short_description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectName: string }>;
|
||||
params: { projectName: string };
|
||||
}) {
|
||||
const { projectName } = await params;
|
||||
const project = getProjectBySlug(projectName);
|
||||
const project = getProjectByName(projectName);
|
||||
|
||||
if (!project) {
|
||||
notFound();
|
||||
@@ -72,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}
|
||||
|
||||
@@ -6,26 +6,11 @@ export const metadata: Metadata = {
|
||||
title: "My Projects | Gabriel Kaszewski",
|
||||
description:
|
||||
"A showcase of my work, including web applications, games, and developer tools built with Rust, Next.js, and more.",
|
||||
alternates: {
|
||||
canonical: "https://gabrielkaszewski.dev/projects",
|
||||
},
|
||||
openGraph: {
|
||||
title: "My Projects | Gabriel Kaszewski",
|
||||
description:
|
||||
"A showcase of my work, including web applications, games, and developer tools built with Rust, Next.js, and more.",
|
||||
url: "https://gabrielkaszewski.dev/projects",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: "My Projects | Gabriel Kaszewski",
|
||||
description:
|
||||
"A showcase of my work, including web applications, games, and developer tools built with Rust, Next.js, and more.",
|
||||
},
|
||||
};
|
||||
|
||||
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">
|
||||
|
||||
@@ -4,43 +4,35 @@ const AboutSummary = () => {
|
||||
id="who-am-i"
|
||||
className="flex flex-col items-center justify-center gap-4 p-4 rounded w-full"
|
||||
>
|
||||
<h2 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
|
||||
<h3 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
|
||||
Who am I?
|
||||
</h2>
|
||||
</h3>
|
||||
<section className="prose text-white md:prose-lg lg:prose-xl max-w-3xl mx-auto">
|
||||
<p>
|
||||
Hi, I'm Gabriel Kaszewski. I studied Bioinformatics and work as
|
||||
a software engineer. I've been programming since I was 11 and
|
||||
I still enjoy it just as much.
|
||||
Hi, I'm Gabriel Kaszewski — a Bioinformatics graduate and a
|
||||
software engineer by trade. I’ve been obsessed with programming since
|
||||
I was 11, and I thrive on building software that solves complex,
|
||||
real-world problems.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I'm currently working on my first game. If it looks interesting,
|
||||
consider wishlisting it!
|
||||
My most ambitious project to date is my first game. You can check out
|
||||
the Steam preview below — if it catches your eye, I’d love for you to
|
||||
add it to your wishlist!
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-full aspect-[646/190] mx-auto not-prose">
|
||||
<iframe
|
||||
className="w-full h-full"
|
||||
src="https://store.steampowered.com/widget/3575090/"
|
||||
title="Steam store widget"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
By day I work at{" "}
|
||||
<strong className="text-yellow-400">WPP Media</strong>. I also
|
||||
co-founded{" "}
|
||||
<a
|
||||
href="https://jestzlecenie.pl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-yellow-400 underline"
|
||||
>
|
||||
jestzlecenie.pl
|
||||
</a>
|
||||
, a job marketplace. I self-host all my projects on my own
|
||||
Proxmox/Docker/Traefik infrastructure.
|
||||
Currently, I am a Software Engineer at{" "}
|
||||
<strong className="text-yellow-400">WPP Media</strong>. When I’m not
|
||||
at my day job, I’m usually deep-diving into new technologies or
|
||||
bringing my own side projects to life.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
||||
import { Project } from "@/lib/types";
|
||||
import Chip from "@/components/chip";
|
||||
import { Eye } from "lucide-react";
|
||||
import { projectSlug } from "@/lib/data";
|
||||
|
||||
const CommercialProjectCard = ({ project }: { project: Project }) => {
|
||||
return (
|
||||
@@ -35,7 +34,7 @@ const CommercialProjectCard = ({ project }: { project: Project }) => {
|
||||
|
||||
<div className="flex gap-3 mt-auto">
|
||||
<Link
|
||||
href={`/projects/${projectSlug(project)}`}
|
||||
href={`/projects/${project.name}`}
|
||||
className="flex-1 px-4 py-3 text-center font-bold text-black bg-yellow-400 rounded-xl hover:bg-yellow-500 transition-colors glass-effect glossy-effect bottom gloss-highlight"
|
||||
>
|
||||
Read more
|
||||
|
||||
@@ -10,9 +10,9 @@ const CommercialProjects = () => {
|
||||
className="flex flex-col items-center justify-center gap-8 p-4 w-full"
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
|
||||
<h3 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
|
||||
Commissioned Work
|
||||
</h2>
|
||||
</h3>
|
||||
<p className="text-xl text-white/80 max-w-2xl mx-auto">
|
||||
Selected commercial projects and freelance commissions.
|
||||
</p>
|
||||
|
||||
@@ -6,9 +6,9 @@ const Experience = ({ jobs }: { jobs: Job[] }) => (
|
||||
id="experience"
|
||||
className="flex flex-col items-center gap-8 p-4 w-full"
|
||||
>
|
||||
<h2 className="mt-4 text-5xl font-bold tracking-tight text-white">
|
||||
<h3 className="mt-4 text-5xl font-bold tracking-tight text-white">
|
||||
Experience
|
||||
</h2>
|
||||
</h3>
|
||||
<ExperienceTimeline jobs={jobs} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ const Footer = () => {
|
||||
return (
|
||||
<footer className="flex w-full flex-col gap-4 bg-gray-900 p-4 text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold">Gabriel Kaszewski</span>
|
||||
<h1 className="text-xl">Gabriel Kaszewski</h1>
|
||||
<span className="flex-1"></span>
|
||||
<div className="flex gap-2">
|
||||
{socialLinks.map((link) => (
|
||||
@@ -46,7 +46,7 @@ const Footer = () => {
|
||||
<div className="text-sm">
|
||||
<p>
|
||||
Background photo by{" "}
|
||||
<a className="underline" href="https://duckwithsunglasses.com" target="_blank" rel="noopener noreferrer">
|
||||
<a className="underline" href="https://duckwithsunglasses.com">
|
||||
Liam
|
||||
</a>
|
||||
</p>
|
||||
@@ -65,12 +65,6 @@ const Footer = () => {
|
||||
>
|
||||
Projects
|
||||
</Link>
|
||||
<Link
|
||||
href="/photos"
|
||||
className="hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
Photos
|
||||
</Link>
|
||||
<a
|
||||
href="https://blog.gabrielkaszewski.dev/"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { FunZoneItem } from "@/lib/types";
|
||||
import { Eye, Gamepad2 } from "lucide-react";
|
||||
|
||||
interface FunZoneCarouselProps {
|
||||
items: FunZoneItem[];
|
||||
}
|
||||
|
||||
const FunZoneCarousel = ({ items }: FunZoneCarouselProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const active = items[activeIndex];
|
||||
|
||||
const handleTabKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % items.length);
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i - 1 + items.length) % items.length);
|
||||
}
|
||||
},
|
||||
[items.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 w-full max-w-2xl mx-auto">
|
||||
{/* Spotlight card */}
|
||||
<div
|
||||
role="tabpanel"
|
||||
aria-label={active.name}
|
||||
className="w-full glass-effect glossy-effect bottom rounded-2xl overflow-hidden transition-all duration-300"
|
||||
>
|
||||
{/* Screenshot area */}
|
||||
<div className="relative w-full h-64 sm:h-80 bg-gradient-to-br from-violet-600 via-indigo-600 to-blue-600 flex items-center justify-center">
|
||||
{active.thumbnail ? (
|
||||
<Image
|
||||
src={active.thumbnail}
|
||||
alt={active.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Gamepad2 size={80} className="text-white/30" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center justify-between p-4 sm:p-5">
|
||||
<div>
|
||||
<h2 className="text-white text-xl font-bold">{active.name}</h2>
|
||||
<p className="text-white/50 text-sm">{active.tagline}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={active.play_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative overflow-hidden flex items-center gap-1.5 bg-yellow-400 hover:bg-yellow-500 px-5 py-2 rounded-full text-black text-sm font-bold transition-colors gloss-highlight"
|
||||
>
|
||||
<Eye size={16} />
|
||||
Play
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dots */}
|
||||
<div className="flex gap-2">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
aria-label={`Show ${item.name}`}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
index === activeIndex ? "bg-yellow-400" : "bg-white/25"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Fun Zone projects"
|
||||
className="flex gap-2 w-full"
|
||||
onKeyDown={handleTabKeyDown}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
role="tab"
|
||||
aria-selected={index === activeIndex}
|
||||
tabIndex={index === activeIndex ? 0 : -1}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
className={`flex-1 py-2.5 px-2 rounded-xl text-xs sm:text-sm font-medium transition-all duration-200 ${
|
||||
index === activeIndex
|
||||
? "glass-effect border-yellow-400 border-2 text-white"
|
||||
: "bg-white/5 border border-white/10 text-white/50 hover:text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FunZoneCarousel;
|
||||
@@ -1,24 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const FunZoneCta = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 w-full">
|
||||
<div className="glass-effect glossy-effect bottom rounded-2xl p-8 text-center max-w-lg w-full">
|
||||
<h3 className="text-white text-2xl font-bold mb-2">
|
||||
Wanna see some fun stuff?
|
||||
</h3>
|
||||
<p className="text-white/50 mb-6">
|
||||
Games, tools, and toys I built for fun
|
||||
</p>
|
||||
<Link
|
||||
href="/fun-zone"
|
||||
className="relative overflow-hidden inline-flex items-center gap-2 bg-yellow-400 hover:bg-yellow-500 text-black font-bold px-7 py-3 rounded-full transition-colors gloss-highlight"
|
||||
>
|
||||
Enter the Fun Zone
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FunZoneCta;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,7 @@ const Hero = () => {
|
||||
<div className="relative w-full h-screen">
|
||||
<Image
|
||||
src="/images/hero.avif"
|
||||
alt=""
|
||||
role="presentation"
|
||||
alt="Background"
|
||||
fill
|
||||
priority
|
||||
className="hidden object-cover pointer-events-none md:block"
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu, X } from "lucide-react";
|
||||
|
||||
const Navbar = () => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -22,15 +20,10 @@ const Navbar = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
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",
|
||||
@@ -39,71 +32,46 @@ const Navbar = () => {
|
||||
{ href: "/about", label: "About" },
|
||||
];
|
||||
|
||||
|
||||
const baseClasses =
|
||||
"fixed z-20 flex w-full items-center justify-center p-4 transition-all duration-300";
|
||||
const scrolledClasses = "bg-gray-900/80 backdrop-blur-md";
|
||||
const transparentClasses = "bg-transparent";
|
||||
|
||||
const renderLink = (link: (typeof navLinks)[number], className: string) => {
|
||||
const isActive = pathname === link.href;
|
||||
const linkClasses = `${className} hover:text-yellow-400 transition-colors ${
|
||||
isActive ? "text-yellow-400 font-semibold" : "text-white"
|
||||
}`;
|
||||
|
||||
if (link.external) {
|
||||
return (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={linkClasses}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link key={link.href} href={link.href} className={linkClasses}>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={`${baseClasses} ${
|
||||
isScrolled || isMenuOpen ? scrolledClasses : transparentClasses
|
||||
<nav
|
||||
className={`${baseClasses} ${isScrolled ? scrolledClasses : transparentClasses
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-1"></div>
|
||||
>
|
||||
<div className="flex flex-1"></div>
|
||||
<div className="flex gap-4">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = pathname === link.href;
|
||||
const linkClasses = `text-lg hover:text-yellow-400 transition-colors ${isActive ? "text-yellow-400 font-semibold" : "text-white"
|
||||
}`;
|
||||
|
||||
{/* Desktop links */}
|
||||
<div className="hidden md:flex gap-4">
|
||||
{navLinks.map((link) => renderLink(link, "text-lg"))}
|
||||
</div>
|
||||
if (link.external) {
|
||||
return (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={linkClasses}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
|
||||
className="md:hidden text-white hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{isMenuOpen && (
|
||||
<div role="dialog" aria-label="Navigation menu" className="fixed inset-0 z-10 pt-16 bg-gray-900/95 backdrop-blur-md md:hidden">
|
||||
<div className="flex flex-col items-center gap-6 pt-8">
|
||||
{navLinks.map((link) => renderLink(link, "text-2xl"))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
return (
|
||||
<Link key={link.href} href={link.href} className={linkClasses}>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Photo } from "@/lib/types";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
const PhotoGallery = ({ photos }: { photos: Photo[] }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const touchStart = useRef<number | null>(null);
|
||||
|
||||
const selected = selectedIndex !== null ? photos[selectedIndex] : null;
|
||||
|
||||
const close = useCallback(() => setSelectedIndex(null), []);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setSelectedIndex((i) => (i !== null && i > 0 ? i - 1 : i));
|
||||
}, []);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setSelectedIndex((i) =>
|
||||
i !== null && i < photos.length - 1 ? i + 1 : i
|
||||
);
|
||||
}, [photos.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex === null) return;
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
if (e.key === "ArrowLeft") prev();
|
||||
if (e.key === "ArrowRight") next();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
window.removeEventListener("keydown", handleKey);
|
||||
};
|
||||
}, [selectedIndex, close, prev, next]);
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStart.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (touchStart.current === null) return;
|
||||
const diff = e.changedTouches[0].clientX - touchStart.current;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0) prev();
|
||||
else next();
|
||||
}
|
||||
touchStart.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="columns-2 md:columns-3 gap-4 w-full max-w-4xl">
|
||||
{photos.map((photo, i) => (
|
||||
<button
|
||||
key={photo.src}
|
||||
onClick={() => setSelectedIndex(i)}
|
||||
className="mb-4 w-full block cursor-zoom-in"
|
||||
>
|
||||
<Image
|
||||
src={photo.src}
|
||||
alt={photo.alt}
|
||||
width={photo.width}
|
||||
height={photo.height}
|
||||
className="w-full h-auto rounded-lg hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Photo lightbox"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<button
|
||||
onClick={close}
|
||||
aria-label="Close lightbox"
|
||||
className="absolute top-4 right-4 z-10 text-white/70 hover:text-white p-2"
|
||||
>
|
||||
<X size={28} />
|
||||
</button>
|
||||
|
||||
{selectedIndex! > 0 && (
|
||||
<button
|
||||
onClick={prev}
|
||||
aria-label="Previous photo"
|
||||
className="absolute left-2 md:left-4 z-10 text-white/50 hover:text-white p-2"
|
||||
>
|
||||
<ChevronLeft size={32} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedIndex! < photos.length - 1 && (
|
||||
<button
|
||||
onClick={next}
|
||||
aria-label="Next photo"
|
||||
className="absolute right-2 md:right-4 z-10 text-white/50 hover:text-white p-2"
|
||||
>
|
||||
<ChevronRight size={32} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button onClick={close} aria-label="Close lightbox" className="contents">
|
||||
<Image
|
||||
src={selected.src}
|
||||
alt={selected.alt}
|
||||
width={selected.width}
|
||||
height={selected.height}
|
||||
className="max-w-[92vw] max-h-[85vh] w-auto h-auto object-contain cursor-zoom-out"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-4 text-white/40 text-sm">
|
||||
{selectedIndex! + 1} / {photos.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoGallery;
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { projectSlug } from "@/lib/data";
|
||||
import { Project } from "@/lib/types";
|
||||
import Chip from "@/components/chip";
|
||||
import ImageCarousel from "@/components/image-carousel";
|
||||
@@ -65,7 +64,7 @@ const ProjectItem = ({ project }: ProjectItemProps) => {
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/projects/${projectSlug(project)}`}
|
||||
href={`/projects/${project.name}`}
|
||||
className="text-center glass-effect glossy-effect bottom gloss-highlight p-2 rounded-2xl bg-yellow-400 hover:bg-yellow-500 text-black font-bold"
|
||||
>
|
||||
Read more
|
||||
|
||||
@@ -11,9 +11,9 @@ const Skills = ({ skills }: SkillsProps) => {
|
||||
id="skills"
|
||||
className="flex flex-col items-center justify-center gap-4 p-4 rounded w-full"
|
||||
>
|
||||
<h2 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
|
||||
<h3 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
|
||||
Skills
|
||||
</h2>
|
||||
</h3>
|
||||
<div className="flex flex-wrap justify-center w-full max-w-lg gap-4">
|
||||
{skills.map((skill, index) => (
|
||||
<div
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Writing } from "@/lib/types";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
const kindLabel: Record<Writing["kind"], string> = {
|
||||
essay: "Essay",
|
||||
script: "Script",
|
||||
other: "Other",
|
||||
novel: "Novel",
|
||||
};
|
||||
|
||||
const WritingsList = ({ writings }: { writings: Writing[] }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 w-full max-w-lg">
|
||||
{writings.map((w) => (
|
||||
<a
|
||||
key={w.url}
|
||||
href={w.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg text-white hover:bg-white/10 transition-colors group"
|
||||
>
|
||||
<FileText className="w-5 h-5 text-yellow-400 shrink-0" />
|
||||
<span className="group-hover:text-yellow-400 transition-colors">
|
||||
{w.title}
|
||||
</span>
|
||||
<span className="text-white/40 text-sm ml-auto">
|
||||
{kindLabel[w.kind]}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WritingsList;
|
||||
@@ -5,7 +5,7 @@ services:
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.gabrielkaszewski.rule=Host(`gabrielkaszewski.dev`) || Host(`gabrielkaszewski.pl`)"
|
||||
- "traefik.http.routers.gabrielkaszewski.rule=Host(`gabrielkaszewski.dev`)"
|
||||
- "traefik.http.routers.gabrielkaszewski.entrypoints=websecure"
|
||||
- "traefik.http.routers.gabrielkaszewski.tls.certresolver=letsencrypt"
|
||||
networks:
|
||||
|
||||
@@ -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,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">
|
||||
“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.”
|
||||
</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"
|
||||
```
|
||||
@@ -1,113 +0,0 @@
|
||||
# Dual-Domain Support 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:** Serve the portfolio on both `gabrielkaszewski.dev` and `gabrielkaszewski.pl` with identical content, with `.dev` as the canonical domain for SEO.
|
||||
|
||||
**Architecture:** Two independent changes — Traefik routing config to accept both hostnames, and Next.js metadata to inject a canonical `<link>` tag. No app logic changes.
|
||||
|
||||
**Tech Stack:** Next.js 15 (App Router metadata API), Traefik (Docker labels), Let's Encrypt TLS.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `.pl` domain to Traefik router
|
||||
|
||||
**Files:**
|
||||
- Modify: `compose.yml`
|
||||
|
||||
- [ ] **Step 1: Update the Traefik router rule**
|
||||
|
||||
In `compose.yml`, change the router rule label from:
|
||||
|
||||
```yaml
|
||||
- "traefik.http.routers.gabrielkaszewski.rule=Host(`gabrielkaszewski.dev`)"
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```yaml
|
||||
- "traefik.http.routers.gabrielkaszewski.rule=Host(`gabrielkaszewski.dev`) || Host(`gabrielkaszewski.pl`)"
|
||||
```
|
||||
|
||||
The full `labels` block should look like:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.gabrielkaszewski.rule=Host(`gabrielkaszewski.dev`) || Host(`gabrielkaszewski.pl`)"
|
||||
- "traefik.http.routers.gabrielkaszewski.entrypoints=websecure"
|
||||
- "traefik.http.routers.gabrielkaszewski.tls.certresolver=letsencrypt"
|
||||
```
|
||||
|
||||
Note: Traefik's Let's Encrypt cert resolver automatically provisions a cert for every hostname matched by the router rule, so no additional TLS labels are needed.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add compose.yml
|
||||
git commit -m "feat: add gabrielkaszewski.pl to traefik router"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add canonical URL to Next.js metadata
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/layout.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `alternates.canonical` to the metadata export**
|
||||
|
||||
In `app/layout.tsx`, add `alternates` to the existing `metadata` object:
|
||||
|
||||
```ts
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Gabriel Kaszewski | Software Engineer",
|
||||
template: "%s | Gabriel Kaszewski",
|
||||
},
|
||||
description:
|
||||
"The portfolio of Gabriel Kaszewski, a software engineer specializing in Rust, Python, and modern web technologies.",
|
||||
keywords: [
|
||||
"Gabriel Kaszewski",
|
||||
"Software Engineer",
|
||||
"Rust Developer",
|
||||
"Python Developer",
|
||||
"Next.js",
|
||||
"Portfolio",
|
||||
],
|
||||
alternates: {
|
||||
canonical: "https://gabrielkaszewski.dev",
|
||||
},
|
||||
openGraph: {
|
||||
// ... rest unchanged
|
||||
},
|
||||
// ... rest unchanged
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the canonical tag is present in the built HTML**
|
||||
|
||||
Run the dev server:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Then in another terminal, check the rendered HTML for the canonical tag:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3000 | grep -i canonical
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
<link rel="canonical" href="https://gabrielkaszewski.dev"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/layout.tsx
|
||||
git commit -m "feat: add canonical URL to metadata"
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,38 +0,0 @@
|
||||
# Dual-Domain Support
|
||||
|
||||
**Date:** 2026-05-16
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Serve the portfolio under both `gabrielkaszewski.dev` and `gabrielkaszewski.pl` with identical content. The `.dev` domain is canonical for SEO.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. `compose.yml` — Traefik router rule
|
||||
|
||||
Extend the `Host()` rule to accept both domains and cover both with TLS:
|
||||
|
||||
```yaml
|
||||
- "traefik.http.routers.gabrielkaszewski.rule=Host(`gabrielkaszewski.dev`) || Host(`gabrielkaszewski.pl`)"
|
||||
- "traefik.http.routers.gabrielkaszewski.entrypoints=websecure"
|
||||
- "traefik.http.routers.gabrielkaszewski.tls.certresolver=letsencrypt"
|
||||
```
|
||||
|
||||
### 2. `app/layout.tsx` — canonical metadata
|
||||
|
||||
Add `alternates.canonical` to the exported `metadata` object:
|
||||
|
||||
```ts
|
||||
alternates: {
|
||||
canonical: "https://gabrielkaszewski.dev",
|
||||
},
|
||||
```
|
||||
|
||||
This injects `<link rel="canonical" href="https://gabrielkaszewski.dev">` into every page's `<head>`, telling search engines the `.dev` domain is authoritative.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No redirects — `.pl` serves content directly.
|
||||
- No per-domain content differences.
|
||||
- No changes to OpenGraph or JSON-LD (already hardcoded to `.dev`).
|
||||
291
lib/data.ts
@@ -1,49 +1,66 @@
|
||||
import { Skill, Job, Project, FunZoneItem, Writing, Photo } from "@/lib/types";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { Skill, Job, Project } from "@/lib/types";
|
||||
|
||||
export const skills: Skill[] = [
|
||||
{ name: "Angular" },
|
||||
{ name: "Ansible" },
|
||||
{ name: "Axum" },
|
||||
{ name: "Azure" },
|
||||
{ name: "C#" },
|
||||
{ name: "C++" },
|
||||
{ name: "Celery" },
|
||||
{ name: "Django" },
|
||||
{ name: "Docker" },
|
||||
{ name: "FastAPI" },
|
||||
{ name: "GCP" },
|
||||
{ name: "Git" },
|
||||
{ name: "GitLab CI" },
|
||||
{ name: "Kubernetes" },
|
||||
{ name: "Linux" },
|
||||
{ name: "NATS JetStream" },
|
||||
{ name: "Next.js" },
|
||||
{ name: "PostGIS" },
|
||||
{ name: "PostgreSQL" },
|
||||
{ name: "Proxmox" },
|
||||
{ name: "Python" },
|
||||
{ name: "React" },
|
||||
{ name: "Rust" },
|
||||
{ name: "S3" },
|
||||
{ name: "SQLite" },
|
||||
{ name: "TailwindCSS" },
|
||||
{ name: "Traefik" },
|
||||
{ name: "TypeScript" },
|
||||
{ name: "Unity" },
|
||||
{
|
||||
name: "Azure Pipelines",
|
||||
},
|
||||
{
|
||||
name: "C#",
|
||||
},
|
||||
{
|
||||
name: "C++",
|
||||
},
|
||||
{
|
||||
name: "Django",
|
||||
},
|
||||
{
|
||||
name: "Docker",
|
||||
},
|
||||
{
|
||||
name: "FastAPI",
|
||||
},
|
||||
{
|
||||
name: "Git",
|
||||
},
|
||||
{
|
||||
name: "Java",
|
||||
},
|
||||
{
|
||||
name: "Javascript",
|
||||
},
|
||||
{
|
||||
name: "Linux",
|
||||
},
|
||||
{
|
||||
name: "PostGIS",
|
||||
},
|
||||
{
|
||||
name: "PostgreSQL",
|
||||
},
|
||||
{
|
||||
name: "Python",
|
||||
},
|
||||
{
|
||||
name: "Qt",
|
||||
},
|
||||
{
|
||||
name: "React",
|
||||
},
|
||||
{
|
||||
name: "Rust",
|
||||
},
|
||||
{
|
||||
name: "TailwindCSS",
|
||||
},
|
||||
{
|
||||
name: "Typescript",
|
||||
},
|
||||
{
|
||||
name: "Unity",
|
||||
},
|
||||
];
|
||||
|
||||
export const jobs: Job[] = [
|
||||
{
|
||||
id: 9,
|
||||
position: "Co-founder & Backend Engineer",
|
||||
company: "jestzlecenie.pl",
|
||||
still_working: true,
|
||||
start_date: "2024-09-01",
|
||||
end_date: null,
|
||||
summary: "Co-founded a job marketplace. Built the backend from scratch.",
|
||||
technologies: ["Python", "Django", "PostgreSQL", "Celery", "Docker"],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
position: "Full Stack Developer",
|
||||
@@ -168,22 +185,14 @@ export const projects: Project[] = [
|
||||
id: 6,
|
||||
name: "Thoughts",
|
||||
short_description:
|
||||
"Federated microblogging platform with a Frutiger Aero aesthetic.",
|
||||
"Nostalgic microblogging platform with a Frutiger Aero aesthetic.",
|
||||
description:
|
||||
"Thoughts is a fully federated microblogging platform built on ActivityPub, compatible with Mastodon, Misskey, Pleroma, and Movies Diary. It features a distinctive Frutiger Aero style straight out of the 00s.\n\nUsers can post short thoughts (up to 128 characters), reply, boost, and like. Profiles are fully customizable with user-supplied CSS. The feed is purely chronological — no algorithms.\n\n**Federation:**\n- WebFinger, NodeInfo, shared inbox, paginated outbox, and actor profile sync\n- Remote actor discovery by `@user@instance` handle\n- Follow/unfollow remote actors with proper `Accept`/`Follow` delivery\n- Per-domain and per-actor moderation with `Block` activity delivery\n\n**Technical details:**\n- **Architecture:** Hexagonal (Ports & Adapters)\n- **Backend:** Rust, Axum, SQLx\n- **Frontend:** Next.js, TailwindCSS\n- **Database:** PostgreSQL (full-text search via trigram indexes)\n- **Event bus:** NATS JetStream (async AP delivery and notifications)\n- **Auth:** JWT + API key\n- **Docs:** OpenAPI (Swagger UI + Scalar)",
|
||||
"Thoughts is a microblogging social website straight out of the 00s, featuring a distinctive Frutiger Aero style. Users can post short text-based thoughts (up to 128 characters), customize their entire profile page with their own CSS, and follow others in a purely chronological, algorithm-free environment. It's a return to the 'old times' of the web, focusing on genuine interaction and user expression.\n\n**Technical details:**\n- **Backend:** Rust\n- **Frontend:** Next.js",
|
||||
category: "Web",
|
||||
github_url: "https://git.gabrielkaszewski.dev/GKaszewski/thoughts",
|
||||
visit_url: "https://thoughts.gabrielkaszewski.dev/",
|
||||
download_url: null,
|
||||
technologies: [
|
||||
"Rust",
|
||||
"Axum",
|
||||
"Next.js",
|
||||
"TailwindCSS",
|
||||
"ActivityPub",
|
||||
"PostgreSQL",
|
||||
"NATS",
|
||||
],
|
||||
technologies: ["Rust", "Next.js", "TailwindCSS", "Axum"],
|
||||
thumbnails: ["/images/thoughts.avif"],
|
||||
},
|
||||
{
|
||||
@@ -223,12 +232,7 @@ export const projects: Project[] = [
|
||||
visit_url: "https://gamejolt.com/games/spanish-inquisition/425125",
|
||||
download_url: null,
|
||||
technologies: ["C#", "Unity"],
|
||||
thumbnails: [
|
||||
"/images/spanish-inq-1.avif",
|
||||
"/images/spanish-inq-2.avif",
|
||||
"/images/spanish-inq-3.avif",
|
||||
"/images/spanish-inq-4.avif",
|
||||
],
|
||||
thumbnails: [],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
@@ -305,30 +309,6 @@ export const projects: Project[] = [
|
||||
thumbnails: [],
|
||||
commercial: true,
|
||||
},
|
||||
{
|
||||
id: 26,
|
||||
name: "jestzlecenie.pl",
|
||||
short_description: "Job marketplace connecting clients with contractors.",
|
||||
description:
|
||||
"A job marketplace platform I co-founded (registered LLC). I built the entire backend: Django with PostgreSQL trigram search, Celery for async tasks, real-time messaging, KNN-based job recommendations, moderation tools, and notifications.",
|
||||
category: "Web",
|
||||
github_url: null,
|
||||
visit_url: "https://jestzlecenie.pl",
|
||||
download_url: null,
|
||||
technologies: [
|
||||
"Python",
|
||||
"Django",
|
||||
"PostgreSQL",
|
||||
"Celery",
|
||||
"Docker",
|
||||
"Angular",
|
||||
],
|
||||
thumbnails: [
|
||||
"/images/jestzlecenie-1.avif",
|
||||
"/images/jestzlecenie-2.avif",
|
||||
],
|
||||
commercial: true,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: "Codebase to Prompt",
|
||||
@@ -353,13 +333,7 @@ export const projects: Project[] = [
|
||||
visit_url: "https://gabrielkaszewski.itch.io/parasitic-god",
|
||||
download_url: null,
|
||||
technologies: ["Godot", "C#"],
|
||||
thumbnails: [
|
||||
"/images/parasitic-god-1.avif",
|
||||
"/images/parasitic-god-2.avif",
|
||||
"/images/parasitic-god-3.avif",
|
||||
"/images/parasitic-god-4.avif",
|
||||
"/images/parasitic-god-5.avif",
|
||||
],
|
||||
thumbnails: [],
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
@@ -422,7 +396,7 @@ export const projects: Project[] = [
|
||||
category: "Desktop",
|
||||
github_url: "https://git.gabrielkaszewski.dev/GKaszewski/k-launcher",
|
||||
visit_url: null,
|
||||
download_url: "https://aur.archlinux.org/packages/k-launcher",
|
||||
download_url: null,
|
||||
technologies: ["Rust", "iced"],
|
||||
thumbnails: ["/images/k-launcher.avif"],
|
||||
},
|
||||
@@ -481,42 +455,6 @@ export const projects: Project[] = [
|
||||
thumbnails: ["/images/galeria_rumia.avif", "/images/galeria_rumia2.avif"],
|
||||
commercial: true,
|
||||
},
|
||||
{
|
||||
id: 25,
|
||||
name: "PocketChords",
|
||||
short_description:
|
||||
"Self-hosted chord chart library for playing music on the go.",
|
||||
description:
|
||||
"A rip-off of Ultimate Guitar without ads or subscriptions. Import chord sheets from UG HTML, transpose chords (including slash chords like G/B), view piano and guitar chord diagrams — all optimized for phone use while playing.\n\n**Highlights:**\n- Import from Ultimate Guitar URLs or HTML files (bulk import)\n- Server-side transposition with slash chord support\n- Piano keyboard and guitar fretboard chord diagrams\n- Auto-scroll, section navigation, wake lock for hands-free playing\n- Favorites, adjustable font size, capo support\n- JWT auth (register/login/refresh/logout)\n- OpenAPI docs at /docs\n- Single binary serves both API and SPA\n\n**Technical details:**\n- **Architecture:** Hexagonal (Ports & Adapters) with DDD/CQRS\n- **Backend:** Rust, Axum, SQLx\n- **Frontend:** React Router 7 (SPA), TailwindCSS, shadcn/ui\n- **Database:** SQLite\n- **Auth:** JWT + Argon2",
|
||||
category: "Web",
|
||||
github_url: "https://git.gabrielkaszewski.dev/GKaszewski/pocket-chords",
|
||||
visit_url: "https://chords.gabrielkaszewski.dev/",
|
||||
download_url: null,
|
||||
technologies: ["Rust", "Axum", "React", "TailwindCSS", "SQLite", "Docker"],
|
||||
thumbnails: [],
|
||||
},
|
||||
{
|
||||
id: 24,
|
||||
name: "Movies Diary",
|
||||
short_description: "Self-hosted movie logging with ActivityPub federation.",
|
||||
description:
|
||||
"A self-hosted, server-side rendered movie logging system with a full REST API. Built entirely in Rust with no JavaScript in the HTML interface — just HTML forms and an RSS feed.\n\n**Highlights:**\n- Immutable, append-only viewing ledger (tracks re-watches)\n- ActivityPub federation — follow remote users, broadcast reviews as federated Notes, shared inbox, paginated outbox\n- Full-text search via SQLite FTS5 or PostgreSQL tsvector + GIN\n- Background poster fetching, TMDB enrichment (cast, crew, genres, box office), image conversion to AVIF/WebP\n- File importer: CSV, TSV, JSON, XLSX from Letterboxd, IMDb, etc.\n- Dual database support: SQLite and PostgreSQL\n- Terminal UI client\n- OpenAPI documentation (Swagger UI + Scalar)\n\n**Technical details:**\n- **Architecture:** Hexagonal (Ports & Adapters) with DDD\n- **Backend:** Rust, Axum, SQLx\n- **Templates:** Askama (SSR)\n- **Event bus:** SQLite/PostgreSQL queue or NATS JetStream\n- **Federation:** ActivityPub (AP)",
|
||||
category: "Web",
|
||||
github_url: "https://github.com/GKaszewski/movies-diary",
|
||||
visit_url: "https://movies.gabrielkaszewski.dev/",
|
||||
download_url: null,
|
||||
technologies: [
|
||||
"Rust",
|
||||
"Axum",
|
||||
"SQLite",
|
||||
"PostgreSQL",
|
||||
"ActivityPub",
|
||||
"Askama",
|
||||
"NATS",
|
||||
"Docker",
|
||||
],
|
||||
thumbnails: ["/images/movies-diary.webp"],
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
name: "Pixel palette colorizer",
|
||||
@@ -533,104 +471,3 @@ export const projects: Project[] = [
|
||||
thumbnails: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const funZoneItems: FunZoneItem[] = [
|
||||
{
|
||||
id: "painter",
|
||||
name: "Painter",
|
||||
tagline: "collaborative pixel canvas",
|
||||
thumbnail: "/images/painter.avif",
|
||||
play_url: "https://painter.gabrielkaszewski.dev/",
|
||||
},
|
||||
{
|
||||
id: "wordle",
|
||||
name: "Wordle",
|
||||
tagline: "guess the word",
|
||||
thumbnail: null,
|
||||
play_url: "https://wordle.gabrielkaszewski.dev/",
|
||||
},
|
||||
{
|
||||
id: "parasitic-god",
|
||||
name: "Parasitic God",
|
||||
tagline: "cosmic horror game jam entry",
|
||||
thumbnail: "/images/parasitic-god-1.avif",
|
||||
play_url: "https://gabrielkaszewski.itch.io/parasitic-god",
|
||||
},
|
||||
{
|
||||
id: "broberry",
|
||||
name: "Broberry",
|
||||
tagline: "action-packed game jam entry",
|
||||
thumbnail: null,
|
||||
play_url: "https://gabrielkaszewski.itch.io/broberry",
|
||||
},
|
||||
{
|
||||
id: "spanish-inquisition",
|
||||
name: "Spanish Inquisition",
|
||||
tagline: "spanish vocabulary game with a twist",
|
||||
thumbnail: "/images/spanish-inq-1.avif",
|
||||
play_url: "https://gabrielkaszewski.itch.io/spanish-inquisition",
|
||||
},
|
||||
{
|
||||
id: "typing-game",
|
||||
name: "Typing game",
|
||||
tagline: "typing game with a twist",
|
||||
thumbnail: "/images/typing-game-1.avif",
|
||||
play_url: "https://gabrielkaszewski.itch.io/typing-game",
|
||||
},
|
||||
];
|
||||
|
||||
export const writings: Writing[] = [
|
||||
{
|
||||
title: "Qui Sumus — Existential Dread",
|
||||
kind: "essay",
|
||||
url: "/documents/essays/qui-sumus-existential-dread.pdf",
|
||||
},
|
||||
{
|
||||
title: "Loop",
|
||||
kind: "script",
|
||||
url: "/documents/scripts/loop.pdf",
|
||||
},
|
||||
{
|
||||
title: "Hole",
|
||||
kind: "script",
|
||||
url: "/documents/scripts/hole.pdf",
|
||||
}
|
||||
];
|
||||
|
||||
export const photos: Photo[] = [
|
||||
{
|
||||
src: "/images/camera/DSC_1283.avif",
|
||||
alt: "Sunlight filtering through a pine forest onto a grassy path",
|
||||
width: 2000,
|
||||
height: 1335,
|
||||
},
|
||||
{
|
||||
src: "/images/camera/GAK00015.avif",
|
||||
alt: "Glass dome greenhouse glowing at dusk against a blue sky",
|
||||
width: 1333,
|
||||
height: 2000,
|
||||
},
|
||||
{
|
||||
src: "/images/camera/GAK00017.avif",
|
||||
alt: "Small waterfall cascading over rocks beneath a wooden bridge in a park",
|
||||
width: 2000,
|
||||
height: 1333,
|
||||
},
|
||||
{
|
||||
src: "/images/camera/GAK00041.avif",
|
||||
alt: "Neptune Fountain on Długi Targ with tourists and Gdańsk townhouses",
|
||||
width: 1333,
|
||||
height: 2000,
|
||||
},
|
||||
{
|
||||
src: "/images/camera/GAK00042.avif",
|
||||
alt: "Neptune Fountain statue against the Artus Court and colorful facades in Gdańsk",
|
||||
width: 1333,
|
||||
height: 2000,
|
||||
},
|
||||
];
|
||||
|
||||
export const projectSlug = (project: Project): string => slugify(project.name);
|
||||
|
||||
export const getProjectBySlug = (slug: string): Project | undefined =>
|
||||
projects.find((project) => projectSlug(project) === slug);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const featureFlags = {
|
||||
funZone: process.env.NEXT_PUBLIC_SHOW_FUN_ZONE === "true",
|
||||
};
|
||||
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);
|
||||
};
|
||||
}
|
||||
10
lib/slug.ts
@@ -1,10 +0,0 @@
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.replace(/ł/g, "l")
|
||||
.replace(/Ł/g, "L")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
23
lib/types.ts
@@ -28,30 +28,9 @@ export interface Project {
|
||||
description: string;
|
||||
technologies: string[];
|
||||
thumbnails: string[];
|
||||
category: "Web" | "Mobile" | "Desktop" | "Api" | "Game";
|
||||
category: 'Web' | 'Mobile' | 'Desktop' | 'Api' | 'Game';
|
||||
github_url?: string | null;
|
||||
visit_url?: string | null;
|
||||
download_url?: string | null;
|
||||
commercial?: boolean;
|
||||
}
|
||||
|
||||
export interface FunZoneItem {
|
||||
id: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
thumbnail: string | null;
|
||||
play_url: string;
|
||||
}
|
||||
|
||||
export interface Writing {
|
||||
title: string;
|
||||
kind: "essay" | "script" | "other" | "novel";
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Photo {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
21
nginx.conf
@@ -1,21 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri.html $uri/ =404;
|
||||
}
|
||||
|
||||
location /_next/static/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /images/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
}
|
||||
3658
public/cv.pdf
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 21 KiB |