Compare commits
1 Commits
main
...
abb7651e41
| Author | SHA1 | Date | |
|---|---|---|---|
| abb7651e41 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,4 +39,3 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.superpowers/
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const AboutPage = () => {
|
|||||||
const age = calculateAge("2002-02-27");
|
const age = calculateAge("2002-02-27");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-center gap-8 p-4 pt-24 text-white">
|
<div className="flex w-full flex-col items-center gap-8 p-4 pt-24 text-white gravity-body">
|
||||||
<div className="flex flex-col items-center justify-center gap-2 p-4 backdrop-blur-sm glass-effect glossy-effect bottom gloss-highlight rounded-lg shadow-lg">
|
<div className="flex flex-col items-center justify-center gap-2 p-4 backdrop-blur-sm glass-effect glossy-effect bottom gloss-highlight rounded-lg shadow-lg">
|
||||||
<Image
|
<Image
|
||||||
src="/images/ja.avif"
|
src="/images/ja.avif"
|
||||||
|
|||||||
@@ -29,15 +29,39 @@ const kSuiteApps: KSuiteApp[] = [
|
|||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "Thoughts",
|
name: "Thoughts",
|
||||||
shortDescription: "Federated microblogging platform",
|
shortDescription: "Microblogging platform",
|
||||||
description:
|
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/",
|
url: "https://thoughts.gabrielkaszewski.dev/",
|
||||||
githubUrl: "https://git.gabrielkaszewski.dev/GKaszewski/thoughts",
|
githubUrl: "https://git.gabrielkaszewski.dev/GKaszewski/thoughts",
|
||||||
icon: "/images/thoughts.avif",
|
icon: "/images/thoughts.avif",
|
||||||
technologies: ["Rust", "Next.js", "Axum", "ActivityPub", "PostgreSQL", "NATS"],
|
technologies: ["Rust", "Next.js", "Axum"],
|
||||||
color: "from-cyan-400 to-blue-500",
|
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,
|
id: 5,
|
||||||
name: "K-TV",
|
name: "K-TV",
|
||||||
@@ -50,29 +74,18 @@ const kSuiteApps: KSuiteApp[] = [
|
|||||||
technologies: ["Rust", "Next.js", "TailwindCSS"],
|
technologies: ["Rust", "Next.js", "TailwindCSS"],
|
||||||
color: "from-violet-400 to-purple-500",
|
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
|
// Connection definitions for the organism
|
||||||
const connections = [
|
const connections = [
|
||||||
{ from: 0, to: 1 }, // K-Notes -> Thoughts
|
{ from: 0, to: 1 }, // K-Notes -> Thoughts
|
||||||
{ from: 1, to: 2 }, // Thoughts -> K-TV
|
{ from: 1, to: 2 }, // Thoughts -> K-Tuner
|
||||||
{ from: 2, to: 3 }, // K-TV -> Movies Diary
|
{ from: 2, to: 3 }, // K-Tuner -> K-QR
|
||||||
{ from: 3, to: 0 }, // Movies Diary -> K-Notes
|
{ from: 3, to: 4 }, // K-QR -> K-TV
|
||||||
{ from: 0, to: 2 }, // K-Notes -> K-TV (cross)
|
{ from: 4, to: 0 }, // K-TV -> K-Notes
|
||||||
{ from: 1, to: 3 }, // Thoughts -> Movies Diary (AP)
|
{ from: 0, to: 2 }, // K-Notes -> K-Tuner (cross)
|
||||||
{ from: 3, to: 1 }, // Movies Diary -> Thoughts (federation)
|
{ from: 1, to: 3 }, // Thoughts -> K-QR (cross)
|
||||||
|
{ from: 2, to: 4 }, // K-Tuner -> K-TV (cross)
|
||||||
];
|
];
|
||||||
|
|
||||||
const KSuiteOrganism = () => {
|
const KSuiteOrganism = () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Navbar from "@/components/navbar";
|
import Navbar from "@/components/navbar";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
|
import GravityToggle from "@/components/gravity-toggle";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -29,9 +30,6 @@ export const metadata: Metadata = {
|
|||||||
"Next.js",
|
"Next.js",
|
||||||
"Portfolio",
|
"Portfolio",
|
||||||
],
|
],
|
||||||
alternates: {
|
|
||||||
canonical: "https://gabrielkaszewski.dev",
|
|
||||||
},
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Gabriel Kaszewski | Software Engineer",
|
title: "Gabriel Kaszewski | Software Engineer",
|
||||||
description:
|
description:
|
||||||
@@ -101,6 +99,7 @@ export default function RootLayout({
|
|||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
{children}
|
{children}
|
||||||
|
<GravityToggle />
|
||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { Metadata } from "next";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Movie Corner",
|
|
||||||
description:
|
|
||||||
"My personal cinema journal — what I watch and how I feel about it.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOVIE_REVIEWS_URL =
|
|
||||||
"https://movies.gabrielkaszewski.dev/users/5d253151-0f6a-4246-9bc5-cb0b5869731b";
|
|
||||||
const 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import { skills, jobs } from "@/lib/data";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center w-full">
|
<div className="flex flex-col items-center w-full gravity-body">
|
||||||
<Hero />
|
<Hero />
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<AboutSummary />
|
<AboutSummary />
|
||||||
|
|||||||
36
components/gravity-toggle.tsx
Normal file
36
components/gravity-toggle.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { GravityEngine } from "@/lib/gravity-engine";
|
||||||
|
import { Magnet } from "lucide-react";
|
||||||
|
|
||||||
|
export default function GravityToggle() {
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const engineRef = useRef<GravityEngine | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
engineRef.current = new GravityEngine();
|
||||||
|
return () => engineRef.current?.stop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleGravity = () => {
|
||||||
|
if (!engineRef.current) return;
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
engineRef.current.stop();
|
||||||
|
} else {
|
||||||
|
engineRef.current.start();
|
||||||
|
}
|
||||||
|
setIsActive(!isActive);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggleGravity}
|
||||||
|
className="fixed bottom-4 right-4 p-3 bg-yellow-400 text-black rounded-full shadow-lg z-50 hover:bg-yellow-500 transition-colors"
|
||||||
|
title="Toggle Gravity"
|
||||||
|
>
|
||||||
|
<Magnet size={24} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,6 @@ const Navbar = () => {
|
|||||||
{ href: "/", label: "Home" },
|
{ href: "/", label: "Home" },
|
||||||
{ href: "/k-suite", label: "K-Suite" },
|
{ href: "/k-suite", label: "K-Suite" },
|
||||||
{ href: "/projects", label: "Projects" },
|
{ href: "/projects", label: "Projects" },
|
||||||
{ href: "/movie-corner", label: "Movie Corner" },
|
|
||||||
{
|
{
|
||||||
href: "https://blog.gabrielkaszewski.dev/",
|
href: "https://blog.gabrielkaszewski.dev/",
|
||||||
label: "Blog",
|
label: "Blog",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.entrypoints=websecure"
|
||||||
- "traefik.http.routers.gabrielkaszewski.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.gabrielkaszewski.tls.certresolver=letsencrypt"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -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,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`).
|
|
||||||
29
lib/data.ts
29
lib/data.ts
@@ -185,14 +185,14 @@ export const projects: Project[] = [
|
|||||||
id: 6,
|
id: 6,
|
||||||
name: "Thoughts",
|
name: "Thoughts",
|
||||||
short_description:
|
short_description:
|
||||||
"Federated microblogging platform with a Frutiger Aero aesthetic.",
|
"Nostalgic microblogging platform with a Frutiger Aero aesthetic.",
|
||||||
description:
|
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",
|
category: "Web",
|
||||||
github_url: "https://git.gabrielkaszewski.dev/GKaszewski/thoughts",
|
github_url: "https://git.gabrielkaszewski.dev/GKaszewski/thoughts",
|
||||||
visit_url: "https://thoughts.gabrielkaszewski.dev/",
|
visit_url: "https://thoughts.gabrielkaszewski.dev/",
|
||||||
download_url: null,
|
download_url: null,
|
||||||
technologies: ["Rust", "Axum", "Next.js", "TailwindCSS", "ActivityPub", "PostgreSQL", "NATS"],
|
technologies: ["Rust", "Next.js", "TailwindCSS", "Axum"],
|
||||||
thumbnails: ["/images/thoughts.avif"],
|
thumbnails: ["/images/thoughts.avif"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -455,29 +455,6 @@ export const projects: Project[] = [
|
|||||||
thumbnails: ["/images/galeria_rumia.avif", "/images/galeria_rumia2.avif"],
|
thumbnails: ["/images/galeria_rumia.avif", "/images/galeria_rumia2.avif"],
|
||||||
commercial: true,
|
commercial: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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,
|
id: 23,
|
||||||
name: "Pixel palette colorizer",
|
name: "Pixel palette colorizer",
|
||||||
|
|||||||
223
lib/gravity-engine.ts
Normal file
223
lib/gravity-engine.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
interface PhysicsBody {
|
||||||
|
el: HTMLElement;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
isDragging: boolean;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GravityEngine {
|
||||||
|
private bodies: PhysicsBody[] = [];
|
||||||
|
private animationFrameId: number | null = null;
|
||||||
|
private isRunning = false;
|
||||||
|
|
||||||
|
INITIAL_FORCE = 10;
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.isRunning) return;
|
||||||
|
this.isRunning = true;
|
||||||
|
|
||||||
|
const containers = document.querySelectorAll(".gravity-body");
|
||||||
|
const targetElements: HTMLElement[] = [];
|
||||||
|
|
||||||
|
// Helper to recursively drill down to leaf nodes
|
||||||
|
const extractLeaves = (el: HTMLElement) => {
|
||||||
|
// 1. Define "solid" elements that should fall as one single piece
|
||||||
|
const isSolid = ["SVG", "IMG", "BUTTON", "IFRAME", "A"].includes(
|
||||||
|
el.tagName.toUpperCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Base case: If it's solid, or has no children, it's a target
|
||||||
|
if (isSolid || el.children.length === 0) {
|
||||||
|
// Only extract elements that actually take up visual space
|
||||||
|
if (el.offsetWidth > 0 && el.offsetHeight > 0) {
|
||||||
|
targetElements.push(el);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.style.width = `${el.offsetWidth}px`;
|
||||||
|
el.style.height = `${el.offsetHeight}px`;
|
||||||
|
|
||||||
|
Array.from(el.children).forEach((child) => {
|
||||||
|
extractLeaves(child as HTMLElement);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
containers.forEach((container) => {
|
||||||
|
const htmlContainer = container as HTMLElement;
|
||||||
|
|
||||||
|
// Lock the main container's dimensions
|
||||||
|
htmlContainer.style.width = `${htmlContainer.offsetWidth}px`;
|
||||||
|
htmlContainer.style.height = `${htmlContainer.offsetHeight}px`;
|
||||||
|
|
||||||
|
// Start the recursive extraction on its direct children
|
||||||
|
Array.from(htmlContainer.children).forEach((child) => {
|
||||||
|
extractLeaves(child as HTMLElement);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialStates = targetElements.map((el) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return { el, rect };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bodies = initialStates.map(({ el, rect }) => {
|
||||||
|
// Lock dimensions so it doesn't warp when pulled out of flex/grid
|
||||||
|
el.style.width = `${rect.width}px`;
|
||||||
|
el.style.height = `${rect.height}px`;
|
||||||
|
el.style.margin = "0px";
|
||||||
|
|
||||||
|
// Snap to fixed positioning at its exact current visual location
|
||||||
|
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: el,
|
||||||
|
x: rect.left,
|
||||||
|
y: rect.top,
|
||||||
|
vx: (Math.random() - 0.5) * 8, // slight explosion outward
|
||||||
|
vy: (Math.random() - 0.5) * 5, // slight pop upward
|
||||||
|
isDragging: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.attachMouseEvents(body);
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.isRunning = false;
|
||||||
|
if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
|
||||||
|
|
||||||
|
// Reset all children back to their normal document flow
|
||||||
|
this.bodies.forEach((body) => {
|
||||||
|
body.el.style.transform = "";
|
||||||
|
body.el.style.position = "";
|
||||||
|
body.el.style.left = "";
|
||||||
|
body.el.style.top = "";
|
||||||
|
body.el.style.width = "";
|
||||||
|
body.el.style.height = "";
|
||||||
|
body.el.style.margin = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset parent containers
|
||||||
|
const containers = document.querySelectorAll(".gravity-body");
|
||||||
|
containers.forEach((container) => {
|
||||||
|
const htmlContainer = container as HTMLElement;
|
||||||
|
htmlContainer.style.width = "";
|
||||||
|
htmlContainer.style.height = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bodies = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// Only update visually while dragging, physics are paused
|
||||||
|
body.el.style.position = "fixed";
|
||||||
|
body.el.style.left = "0px";
|
||||||
|
body.el.style.top = "0px";
|
||||||
|
body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Gravity
|
||||||
|
body.vy += gravity;
|
||||||
|
body.x += body.vx;
|
||||||
|
body.y += body.vy;
|
||||||
|
|
||||||
|
// Apply Air Friction
|
||||||
|
body.vx *= 0.99;
|
||||||
|
body.vy *= 0.99;
|
||||||
|
|
||||||
|
const rect = body.el.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Floor Collision
|
||||||
|
if (body.y + rect.height > floorY) {
|
||||||
|
body.y = floorY - rect.height;
|
||||||
|
body.vy *= bounce;
|
||||||
|
body.vx *= 0.9; // Ground friction
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Step
|
||||||
|
body.el.style.position = "fixed";
|
||||||
|
body.el.style.left = "0px";
|
||||||
|
body.el.style.top = "0px";
|
||||||
|
body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.tick);
|
||||||
|
};
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 110 KiB |
Reference in New Issue
Block a user