Add new documents and images to the public directory
All checks were successful
Build and Deploy Gabriel Kaszewski Portfolio / build-and-deploy-local (push) Successful in 1m32s

- Added essays: qui-sumus-existential-dread.pdf and licencjat.pdf
- Added scripts: loop.pdf
- Added images: DSC_1283.avif, GAK00015.avif, GAK00017.avif, GAK00041.avif, GAK00042.avif, painter.avif, parasitic-god-1.avif, parasitic-god-2.avif, parasitic-god-3.avif, parasitic-god-4.avif, parasitic-god-5.avif, spanish-inq-1.avif, spanish-inq-2.avif, spanish-inq-3.avif, spanish-inq-4.avif, typing-game-1.avif, typing-game-2.avif
This commit is contained in:
2026-08-20 01:39:14 +02:00
parent 97e8260def
commit 9e95d5c544
46 changed files with 1538 additions and 3108 deletions

View File

@@ -16,7 +16,7 @@ jobs:
- name: Rebuild and Deploy with Docker Compose
run: |
docker build -t local/gabriel-portfolio:latest .
docker build --build-arg NEXT_PUBLIC_SHOW_FUN_ZONE=false -t local/gabriel-portfolio:latest .
docker compose up -d
# Clean up any old, unused images to save disk space

View File

@@ -6,6 +6,8 @@ RUN bun install --frozen-lockfile
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

View File

@@ -1,5 +1,8 @@
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";
@@ -7,9 +10,24 @@ 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", "Gaming", "Playing guitar"];
const hobbies = ["Programming", "Filmmaking", "Photography", "Gaming", "Playing guitar"];
const interests = [
"Computer Science",
"Sci-Fi Books",
@@ -82,7 +100,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">
<h1 className="text-center">More info about me!</h1>
<h2 className="text-center">More info about me!</h2>
<p>
Hi! I am Gabriel and I am {age} years old. I graduated in
Bioinformatics from the University of Gdańsk. I&apos;m fluent in
@@ -105,7 +123,7 @@ const AboutPage = () => {
processes using cellular automata&quot; is available{" "}
<a
target="_blank"
href="/licencjat.pdf"
href="/documents/other/licencjat.pdf"
className="text-yellow-400 underline"
>
here
@@ -136,8 +154,18 @@ 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">
<h1>My Philosophy</h1>
<h2>My Philosophy</h2>
<p>
I believe much of today&apos;s software is bloated, inefficient, and
disrespectful of the user&apos;s resources. My passion, which
@@ -150,35 +178,8 @@ 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">
<h1>Future Goals</h1>
<h2>Future Goals</h2>
<p>
I&apos;m always eager to learn and grow. My goal is to continue
honing my skills in backend development and system architecture.
@@ -194,8 +195,16 @@ 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">
<h1>FAQ</h1>
<h2>FAQ</h2>
<div className="not-prose flex flex-col gap-4">
{faqItems.map((item) => (
<div key={item.q}>

46
app/fun-zone/page.tsx Normal file
View File

@@ -0,0 +1,46 @@
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;

View File

@@ -1,6 +1,28 @@
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;

View File

@@ -101,7 +101,7 @@ export default function RootLayout({
/>
<Navbar />
{children}
<main>{children}</main>
<Footer />
</body>
</html>

View File

@@ -1,9 +1,24 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Movie Corner",
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 =

View File

@@ -1,9 +1,33 @@
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 (
@@ -13,6 +37,7 @@ export default function Home() {
<AboutSummary />
<Skills skills={skills} />
<CommercialProjects />
{featureFlags.funZone && <FunZoneCta />}
<Experience jobs={jobs} />
</div>
</div>

39
app/photos/page.tsx Normal file
View File

@@ -0,0 +1,39 @@
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;

View File

@@ -23,7 +23,7 @@ export async function generateStaticParams() {
export async function generateMetadata({
params,
}: {
params: { projectName: string };
params: Promise<{ projectName: string }>;
}): Promise<Metadata> {
const { projectName } = await params;
const project = getProjectByName(projectName);
@@ -32,12 +32,18 @@ export async function generateMetadata({
return { title: "Project Not Found" };
}
const slug = encodeURIComponent(project.name.replace(/\s/g, "+"));
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
? [
@@ -50,13 +56,18 @@ export async function generateMetadata({
]
: undefined,
},
twitter: {
card: "summary",
title: project.name,
description: project.short_description,
},
};
}
export default async function ProjectDetailPage({
params,
}: {
params: { projectName: string };
params: Promise<{ projectName: string }>;
}) {
const { projectName } = await params;
const project = getProjectByName(projectName);

View File

@@ -6,6 +6,21 @@ 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 = () => {

View File

@@ -4,35 +4,34 @@ const AboutSummary = () => {
id="who-am-i"
className="flex flex-col items-center justify-center gap-4 p-4 rounded w-full"
>
<h3 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
<h2 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
Who am I?
</h3>
</h2>
<section className="prose text-white md:prose-lg lg:prose-xl max-w-3xl mx-auto">
<p>
Hi, I&apos;m Gabriel Kaszewski a Bioinformatics graduate and a
software engineer by trade. Ive been obsessed with programming since
I was 11, and I thrive on building software that solves complex,
real-world problems.
Hi, I&apos;m Gabriel Kaszewski. I studied Bioinformatics and work as
a software engineer. I&apos;ve been programming since I was 11 and
I still enjoy it just as much.
</p>
<p>
My most ambitious project to date is my first game. You can check out
the Steam preview below if it catches your eye, Id love for you to
add it to your wishlist!
I&apos;m currently working on my first game. If it looks interesting,
consider wishlisting it!
</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>
Currently, I am a Software Engineer at{" "}
<strong className="text-yellow-400">WPP Media</strong>. When Im not
at my day job, Im usually deep-diving into new technologies or
bringing my own side projects to life.
By day I work at{" "}
<strong className="text-yellow-400">WPP Media</strong>. Outside of
that I&apos;m usually tinkering with side projects or picking up
something new.
</p>
</section>
</div>

View File

@@ -10,9 +10,9 @@ const CommercialProjects = () => {
className="flex flex-col items-center justify-center gap-8 p-4 w-full"
>
<div className="text-center">
<h3 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
<h2 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
Commissioned Work
</h3>
</h2>
<p className="text-xl text-white/80 max-w-2xl mx-auto">
Selected commercial projects and freelance commissions.
</p>

View File

@@ -6,9 +6,9 @@ const Experience = ({ jobs }: { jobs: Job[] }) => (
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">
<h2 className="mt-4 text-5xl font-bold tracking-tight text-white">
Experience
</h3>
</h2>
<ExperienceTimeline jobs={jobs} />
</div>
);

View File

@@ -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">
<h1 className="text-xl">Gabriel Kaszewski</h1>
<span className="text-xl font-bold">Gabriel Kaszewski</span>
<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">
<a className="underline" href="https://duckwithsunglasses.com" target="_blank" rel="noopener noreferrer">
Liam
</a>
</p>
@@ -65,6 +65,12 @@ const Footer = () => {
>
Projects
</Link>
<Link
href="/photos"
className="hover:text-yellow-400 transition-colors"
>
Photos
</Link>
<a
href="https://blog.gabrielkaszewski.dev/"
target="_blank"

View File

@@ -0,0 +1,113 @@
"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;

View File

@@ -0,0 +1,24 @@
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;

View File

@@ -26,7 +26,8 @@ const Hero = () => {
<div className="relative w-full h-screen">
<Image
src="/images/hero.avif"
alt="Background"
alt=""
role="presentation"
fill
priority
className="hidden object-cover pointer-events-none md:block"

View File

@@ -3,9 +3,11 @@
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(() => {
@@ -20,6 +22,10 @@ const Navbar = () => {
};
}, []);
useEffect(() => {
setIsMenuOpen(false);
}, [pathname]);
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/k-suite", label: "K-Suite" },
@@ -33,22 +39,15 @@ 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";
return (
<nav
className={`${baseClasses} ${isScrolled ? scrolledClasses : transparentClasses
}`}
>
<div className="flex flex-1"></div>
<div className="flex gap-4">
{navLinks.map((link) => {
const renderLink = (link: (typeof navLinks)[number], className: string) => {
const isActive = pathname === link.href;
const linkClasses = `text-lg hover:text-yellow-400 transition-colors ${isActive ? "text-yellow-400 font-semibold" : "text-white"
const linkClasses = `${className} hover:text-yellow-400 transition-colors ${
isActive ? "text-yellow-400 font-semibold" : "text-white"
}`;
if (link.external) {
@@ -70,9 +69,41 @@ const Navbar = () => {
{link.label}
</Link>
);
})}
};
return (
<>
<nav
className={`${baseClasses} ${
isScrolled || isMenuOpen ? scrolledClasses : transparentClasses
}`}
>
<div className="flex flex-1"></div>
{/* Desktop links */}
<div className="hidden md:flex gap-4">
{navLinks.map((link) => renderLink(link, "text-lg"))}
</div>
{/* 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>
)}
</>
);
};

View File

@@ -0,0 +1,134 @@
"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;

View File

@@ -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"
>
<h3 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
<h2 className="mt-4 mb-2 text-5xl font-bold tracking-tight text-white">
Skills
</h3>
</h2>
<div className="flex flex-wrap justify-center w-full max-w-lg gap-4">
{skills.map((skill, index) => (
<div

View File

@@ -0,0 +1,35 @@
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;

View File

@@ -1,4 +1,4 @@
import { Skill, Job, Project } from "@/lib/types";
import { Skill, Job, Project, FunZoneItem, Writing, Photo } from "@/lib/types";
export const skills: Skill[] = [
{
@@ -192,7 +192,15 @@ export const projects: Project[] = [
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",
"Axum",
"Next.js",
"TailwindCSS",
"ActivityPub",
"PostgreSQL",
"NATS",
],
thumbnails: ["/images/thoughts.avif"],
},
{
@@ -232,7 +240,12 @@ export const projects: Project[] = [
visit_url: "https://gamejolt.com/games/spanish-inquisition/425125",
download_url: null,
technologies: ["C#", "Unity"],
thumbnails: [],
thumbnails: [
"/images/spanish-inq-1.avif",
"/images/spanish-inq-2.avif",
"/images/spanish-inq-3.avif",
"/images/spanish-inq-4.avif",
],
},
{
id: 3,
@@ -333,7 +346,13 @@ export const projects: Project[] = [
visit_url: "https://gabrielkaszewski.itch.io/parasitic-god",
download_url: null,
technologies: ["Godot", "C#"],
thumbnails: [],
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",
],
},
{
id: 11,
@@ -466,21 +485,13 @@ export const projects: Project[] = [
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",
],
technologies: ["Rust", "Axum", "React", "TailwindCSS", "SQLite", "Docker"],
thumbnails: [],
},
{
id: 24,
name: "Movies Diary",
short_description:
"Self-hosted movie logging with ActivityPub federation.",
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",
@@ -515,3 +526,94 @@ 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",
},
];
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,
},
];

3
lib/feature-flags.ts Normal file
View File

@@ -0,0 +1,3 @@
export const featureFlags = {
funZone: process.env.NEXT_PUBLIC_SHOW_FUN_ZONE === "true",
};

View File

@@ -28,9 +28,30 @@ 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;
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

BIN
public/images/painter.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB