From 3bbcd6f345e422a85d037c19c76c5e081203f7ef Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:22:08 +0200 Subject: [PATCH 01/21] feat: add heading extraction and wip flag to posts --- bun.lock | 7 +++++++ lib/posts.ts | 29 ++++++++++++++++++++++++++++- package.json | 1 + 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 34f5e1a..21156fc 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "react-dom": "19.1.0", "reading-time": "^1.5.0", "rehype-pretty-code": "^0.14.3", + "rehype-slug": "^6.0.0", "remark": "^15.0.1", "remark-html": "^16.0.1", "rss": "^1.2.2", @@ -274,6 +275,8 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], @@ -282,6 +285,8 @@ "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + "hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="], + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], @@ -494,6 +499,8 @@ "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + "rehype-slug": ["rehype-slug@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "github-slugger": "^2.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-to-string": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A=="], + "remark": ["remark@15.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A=="], "remark-html": ["remark-html@16.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "hast-util-sanitize": "^5.0.0", "hast-util-to-html": "^9.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0" } }, "sha512-B9JqA5i0qZe0Nsf49q3OXyGvyXuZFDzAP2iOFLEumymuYJITVpiH1IgsTEwTpdptDmZlMDMWeDmSawdaJIGCXQ=="], diff --git a/lib/posts.ts b/lib/posts.ts index 02b44de..fa621ed 100644 --- a/lib/posts.ts +++ b/lib/posts.ts @@ -5,6 +5,12 @@ import readingTime from 'reading-time'; const postsDirectory = path.join(process.cwd(), 'posts'); +export interface Heading { + level: number; + text: string; + slug: string; +} + export interface PostData { id: string; date: string; @@ -12,6 +18,8 @@ export interface PostData { description: string; content: string; readingTime: string; + headings: Heading[]; + wip?: boolean; } export interface PostMeta { @@ -20,6 +28,22 @@ export interface PostMeta { title: string; description: string; readingTime: string; + wip?: boolean; +} + +function extractHeadings(content: string): Heading[] { + const regex = /^(#{2,3})\s+(.+)$/gm; + const headings: Heading[] = []; + let match; + while ((match = regex.exec(content)) !== null) { + const text = match[2].trim(); + const slug = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + headings.push({ level: match[1].length, text, slug }); + } + return headings; } export function getSortedPostsData(): PostMeta[] { @@ -40,6 +64,7 @@ export function getSortedPostsData(): PostMeta[] { title: matterResult.data.title as string, description: matterResult.data.description as string, readingTime: stats.text, + wip: matterResult.data.wip ?? false, }; }); @@ -76,5 +101,7 @@ export async function getPostData(id: string): Promise { title: matterResult.data.title, description: matterResult.data.description, readingTime: stats.text, + headings: extractHeadings(matterResult.content), + wip: matterResult.data.wip ?? false, }; -} \ No newline at end of file +} diff --git a/package.json b/package.json index b6e1c08..72bdc28 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "react-dom": "19.1.0", "reading-time": "^1.5.0", "rehype-pretty-code": "^0.14.3", + "rehype-slug": "^6.0.0", "remark": "^15.0.1", "remark-html": "^16.0.1", "rss": "^1.2.2", -- 2.49.1 From 71c1c49e8fee1c427edaceabe3f8714dfaeb3f9f Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:23:47 +0200 Subject: [PATCH 02/21] fix: make wip field non-optional to match runtime guarantee --- lib/posts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/posts.ts b/lib/posts.ts index fa621ed..aec81b4 100644 --- a/lib/posts.ts +++ b/lib/posts.ts @@ -19,7 +19,7 @@ export interface PostData { content: string; readingTime: string; headings: Heading[]; - wip?: boolean; + wip: boolean; } export interface PostMeta { @@ -28,7 +28,7 @@ export interface PostMeta { title: string; description: string; readingTime: string; - wip?: boolean; + wip: boolean; } function extractHeadings(content: string): Heading[] { -- 2.49.1 From a1019daab872a12c7e202fb21363bb71644ed807 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:24:14 +0200 Subject: [PATCH 03/21] feat: add ReadingProgress client component --- components/reading-progress.tsx | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 components/reading-progress.tsx diff --git a/components/reading-progress.tsx b/components/reading-progress.tsx new file mode 100644 index 0000000..b6bfba2 --- /dev/null +++ b/components/reading-progress.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export default function ReadingProgress() { + const [progress, setProgress] = useState(0); + + useEffect(() => { + const handleScroll = () => { + const scrollable = document.body.scrollHeight - window.innerHeight; + if (scrollable > 0) { + setProgress((window.scrollY / scrollable) * 100); + } + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + return ( +
+
+
+ ); +} -- 2.49.1 From d55e41992d3579758c9e310d1cdaafb64363a617 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:24:16 +0200 Subject: [PATCH 04/21] feat: add blink keyframe animation --- app/globals.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/globals.css b/app/globals.css index 8fe3d0f..3585fdc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -130,3 +130,13 @@ body { .prose :not(pre) > code::after { content: none !important; } + +/* ── Corner ribbon animation ── */ +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.animate-blink { + animation: blink 0.8s ease-in-out infinite; +} -- 2.49.1 From a3e3f53548bcb1fe348a35f881c063464fddecbe Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:26:22 +0200 Subject: [PATCH 05/21] fix: use @utility for animate-blink, call handleScroll on mount --- app/globals.css | 2 +- components/reading-progress.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/globals.css b/app/globals.css index 3585fdc..35070a2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -137,6 +137,6 @@ body { 50% { opacity: 0.4; } } -.animate-blink { +@utility animate-blink { animation: blink 0.8s ease-in-out infinite; } diff --git a/components/reading-progress.tsx b/components/reading-progress.tsx index b6bfba2..fda10e6 100644 --- a/components/reading-progress.tsx +++ b/components/reading-progress.tsx @@ -13,6 +13,7 @@ export default function ReadingProgress() { } }; + handleScroll(); window.addEventListener("scroll", handleScroll, { passive: true }); return () => window.removeEventListener("scroll", handleScroll); }, []); -- 2.49.1 From 5e11d3e13e1252ab69f2fdc9c595660df7b04aae Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:27:05 +0200 Subject: [PATCH 06/21] feat: add TableOfContents component with IntersectionObserver --- components/table-of-contents.tsx | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 components/table-of-contents.tsx diff --git a/components/table-of-contents.tsx b/components/table-of-contents.tsx new file mode 100644 index 0000000..a0e42db --- /dev/null +++ b/components/table-of-contents.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Heading } from "@/lib/posts"; + +interface TableOfContentsProps { + headings: Heading[]; +} + +export default function TableOfContents({ headings }: TableOfContentsProps) { + const [activeSlug, setActiveSlug] = useState(""); + + useEffect(() => { + if (headings.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + setActiveSlug(entry.target.id); + } + } + }, + { rootMargin: "0% 0% -70% 0%", threshold: 0 } + ); + + headings.forEach(({ slug }) => { + const el = document.getElementById(slug); + if (el) observer.observe(el); + }); + + return () => observer.disconnect(); + }, [headings]); + + if (headings.length === 0) return null; + + return ( + + ); +} -- 2.49.1 From c0229f28a97b5a1c1cb4f6e1ad460736cc9d60cd Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:27:13 +0200 Subject: [PATCH 07/21] feat: add showProgress prop to Window with reading progress strip --- components/window.tsx | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/components/window.tsx b/components/window.tsx index a137868..97e1b87 100644 --- a/components/window.tsx +++ b/components/window.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import React from "react"; import RSSIcon from "./rss-icon"; +import ReadingProgress from "./reading-progress"; const CloseIcon = () => ( -
-
- {title} - - - -
-
-
- +
+
+
+ {title} + + +
-
- +
+
+ +
+
+ +
+ + +
- - -
+ {showProgress && }
{children}
-- 2.49.1 From 4b8533f9bece02664030ab2499ab1584cef98950 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:28:54 +0200 Subject: [PATCH 08/21] =?UTF-8?q?fix:=20polish=20TableOfContents=20?= =?UTF-8?q?=E2=80=94=20initial=20state,=20Tailwind=20indent,=20aria-curren?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/table-of-contents.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/table-of-contents.tsx b/components/table-of-contents.tsx index a0e42db..17abcb8 100644 --- a/components/table-of-contents.tsx +++ b/components/table-of-contents.tsx @@ -8,7 +8,7 @@ interface TableOfContentsProps { } export default function TableOfContents({ headings }: TableOfContentsProps) { - const [activeSlug, setActiveSlug] = useState(""); + const [activeSlug, setActiveSlug] = useState(() => headings[0]?.slug ?? ""); useEffect(() => { if (headings.length === 0) return; @@ -43,10 +43,11 @@ export default function TableOfContents({ headings }: TableOfContentsProps) { {headings.map(({ slug, text, level }) => (
  • Date: Tue, 31 Mar 2026 02:29:54 +0200 Subject: [PATCH 09/21] feat: add TOC, reading progress, WIP banner, and rehype-slug to post page --- app/posts/[slug]/page.tsx | 71 ++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/app/posts/[slug]/page.tsx b/app/posts/[slug]/page.tsx index f2de3b6..a8cc6fe 100644 --- a/app/posts/[slug]/page.tsx +++ b/app/posts/[slug]/page.tsx @@ -2,8 +2,10 @@ import Link from "next/link"; import { getPostData, getAllPostIds } from "@/lib/posts"; import type { PostData } from "@/lib/posts"; import Window from "../../../components/window"; +import TableOfContents from "../../../components/table-of-contents"; import { MDXRemote } from "next-mdx-remote/rsc"; import rehypePrettyCode from "rehype-pretty-code"; +import rehypeSlug from "rehype-slug"; interface PageProps { params: { @@ -11,13 +13,11 @@ interface PageProps { }; } -// This function tells Next.js which blog posts exist at build time export async function generateStaticParams() { const paths = getAllPostIds(); return paths.map((path) => ({ slug: path.params.slug })); } -// Generates metadata (like the title tag) for each blog post page export async function generateMetadata({ params }: PageProps) { const postData = await getPostData(params.slug); return { @@ -26,12 +26,11 @@ export async function generateMetadata({ params }: PageProps) { } export default async function Post({ params }: PageProps) { - // Fetch the specific post's content based on the URL slug const postData: PostData = await getPostData(params.slug); return ( -
    - +
    +
    @@ -41,30 +40,54 @@ export default async function Post({ params }: PageProps) { day: "numeric", })} - - {postData.readingTime} - + {postData.readingTime}
    -
    - + 🚧 + This post is a work in progress — content may change. +
    + )} + + {/* Mobile TOC — shown above article, hidden on lg+ */} + {postData.headings.length > 0 && ( +
    + +
    + )} + +
    +
    + + }, + }} + /> +
    + + {/* Desktop TOC sidebar — hidden on mobile */} + {postData.headings.length > 0 && ( +
    + +
    + )}
    +
    Date: Tue, 31 Mar 2026 02:30:39 +0200 Subject: [PATCH 10/21] chore: use @/ alias for component imports in post page --- app/posts/[slug]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/posts/[slug]/page.tsx b/app/posts/[slug]/page.tsx index a8cc6fe..bc9e184 100644 --- a/app/posts/[slug]/page.tsx +++ b/app/posts/[slug]/page.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; import { getPostData, getAllPostIds } from "@/lib/posts"; import type { PostData } from "@/lib/posts"; -import Window from "../../../components/window"; -import TableOfContents from "../../../components/table-of-contents"; +import Window from "@/components/window"; +import TableOfContents from "@/components/table-of-contents"; import { MDXRemote } from "next-mdx-remote/rsc"; import rehypePrettyCode from "rehype-pretty-code"; import rehypeSlug from "rehype-slug"; -- 2.49.1 From 8abb3899a66e688221945dfc97bd49d47563b9de Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:31:30 +0200 Subject: [PATCH 11/21] feat: add Vista-style 404 page --- app/not-found.tsx | 49 +++++++++++++++++++++++++++++++++++ components/go-back-button.tsx | 15 +++++++++++ 2 files changed, 64 insertions(+) create mode 100644 app/not-found.tsx create mode 100644 components/go-back-button.tsx diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..9ee4dbc --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,49 @@ +import Link from "next/link"; +import Window from "@/components/window"; +import GoBackButton from "@/components/go-back-button"; + +export default function NotFound() { + return ( +
    + +
    +
    + + + +
    +
    +

    + 404 — Not Found +

    +

    + The page you're looking for doesn't exist. Check the address or + head back home. +

    +
    +
    + +
    + + + Home + +
    +
    +
    + ); +} diff --git a/components/go-back-button.tsx b/components/go-back-button.tsx new file mode 100644 index 0000000..224d7b1 --- /dev/null +++ b/components/go-back-button.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +export default function GoBackButton() { + const router = useRouter(); + return ( + + ); +} -- 2.49.1 From c20b33e6afbc0e5c9b81edd6fb7be806d1bd316e Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:31:36 +0200 Subject: [PATCH 12/21] feat: add NEW! and WIP corner ribbons to post list --- app/page.tsx | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 9f3cfbb..0b7532d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,11 +2,18 @@ import { getSortedPostsData, PostMeta } from "../lib/posts"; import Link from "next/link"; import Window from "../components/window"; +function isNew(dateStr: string): boolean { + const postDate = new Date(dateStr); + const now = new Date(); + const diffDays = (now.getTime() - postDate.getTime()) / (1000 * 60 * 60 * 24); + return diffDays <= 30; +} + export default function Home() { const allPostsData: PostMeta[] = getSortedPostsData(); return ( -
    +

    Gabriel's Kaszewski Blog @@ -17,16 +24,25 @@ export default function Home() {

    - {/* The list of posts is displayed inside our custom Window component */} {allPostsData.length > 0 ? (
      - {allPostsData.map(({ id, date, title }) => ( + {allPostsData.map(({ id, date, title, wip }) => (
    • + {isNew(date) && !wip && ( +
      + NEW! +
      + )} + {wip && ( +
      + 🚧 WIP +
      + )}

      {title}

      {new Date(date).toLocaleDateString("en-US", { -- 2.49.1 From 6de2a1c9fabe2ba6b5a435d1a08d465a70bbe752 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:34:02 +0200 Subject: [PATCH 13/21] fix: fall back to home when no history in GoBackButton --- components/go-back-button.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/components/go-back-button.tsx b/components/go-back-button.tsx index 224d7b1..3cb2b1e 100644 --- a/components/go-back-button.tsx +++ b/components/go-back-button.tsx @@ -4,9 +4,19 @@ import { useRouter } from "next/navigation"; export default function GoBackButton() { const router = useRouter(); + + const handleBack = () => { + if (window.history.length > 1) { + router.back(); + } else { + router.push("/"); + } + }; + return (
    + +
    + + + +
    ); } diff --git a/components/badges.tsx b/components/badges.tsx new file mode 100644 index 0000000..caeb10b --- /dev/null +++ b/components/badges.tsx @@ -0,0 +1,78 @@ +import React from "react"; + +interface BadgeProps { + href?: string; + children: React.ReactNode; + style?: React.CSSProperties; +} + +function Badge({ href, children, style }: BadgeProps) { + const base = ( +
    + {children} +
    + ); + + if (href) { + return ( +
    + {base} + + ); + } + return base; +} + +export default function Badges() { + return ( +
    + + ⬛ Next.js + + + + 🍞 Powered by Bun + + + +
    +
    Best viewed
    +
    1024×768
    +
    +
    + + + 📡 Valid RSS + + + + Made with ♥ + + + +
    +
    ✦ Frutiger
    +
    Aero ✦
    +
    +
    +
    + ); +} -- 2.49.1 From 04ada8ad51dc6022240be95f2a46d4b75024300e Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:35:58 +0200 Subject: [PATCH 15/21] fix: remove unused React import, use Link for internal badge hrefs --- components/badges.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/components/badges.tsx b/components/badges.tsx index caeb10b..0b68fb2 100644 --- a/components/badges.tsx +++ b/components/badges.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import Link from "next/link"; interface BadgeProps { href?: string; @@ -17,10 +17,17 @@ function Badge({ href, children, style }: BadgeProps) { ); if (href) { + if (href.startsWith("http")) { + return ( + + {base} + + ); + } return ( - + {base} - + ); } return base; -- 2.49.1 From b2a611ce5b2c4d9806f9bdd044173a6051be92dd Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:36:37 +0200 Subject: [PATCH 16/21] chore: ignore .superpowers brainstorm directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5ef6a52..5f91469 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# brainstorm +.superpowers/ -- 2.49.1 From 7faaac2081427f1f8242d3c45bfd587f551ac7ad Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:39:33 +0200 Subject: [PATCH 17/21] fix: notFound() on missing post, await params for Next.js 15 --- app/posts/[slug]/page.tsx | 10 +++++----- lib/posts.ts | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/posts/[slug]/page.tsx b/app/posts/[slug]/page.tsx index bc9e184..0e6e55a 100644 --- a/app/posts/[slug]/page.tsx +++ b/app/posts/[slug]/page.tsx @@ -8,9 +8,7 @@ import rehypePrettyCode from "rehype-pretty-code"; import rehypeSlug from "rehype-slug"; interface PageProps { - params: { - slug: string; - }; + params: Promise<{ slug: string }>; } export async function generateStaticParams() { @@ -19,14 +17,16 @@ export async function generateStaticParams() { } export async function generateMetadata({ params }: PageProps) { - const postData = await getPostData(params.slug); + const { slug } = await params; + const postData = await getPostData(slug); return { title: `${postData.title} | Gabriel's Kaszewski Blog`, }; } export default async function Post({ params }: PageProps) { - const postData: PostData = await getPostData(params.slug); + const { slug } = await params; + const postData: PostData = await getPostData(slug); return (
    diff --git a/lib/posts.ts b/lib/posts.ts index aec81b4..4877cfb 100644 --- a/lib/posts.ts +++ b/lib/posts.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import matter from 'gray-matter'; import readingTime from 'reading-time'; +import { notFound } from 'next/navigation'; const postsDirectory = path.join(process.cwd(), 'posts'); @@ -90,6 +91,7 @@ export function getAllPostIds() { export async function getPostData(id: string): Promise { const fullPath = path.join(postsDirectory, `${id}.mdx`); + if (!fs.existsSync(fullPath)) notFound(); const fileContents = fs.readFileSync(fullPath, 'utf8'); const matterResult = matter(fileContents); const stats = readingTime(matterResult.content); -- 2.49.1 From 893760751b428778dc59e8e47aa590bda6d71f2a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:43:45 +0200 Subject: [PATCH 18/21] feat: TOC as separate Aero Window on desktop --- app/posts/[slug]/page.tsx | 72 ++++++++++++++++---------------- components/table-of-contents.tsx | 2 +- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/app/posts/[slug]/page.tsx b/app/posts/[slug]/page.tsx index 0e6e55a..956a993 100644 --- a/app/posts/[slug]/page.tsx +++ b/app/posts/[slug]/page.tsx @@ -29,36 +29,36 @@ export default async function Post({ params }: PageProps) { const postData: PostData = await getPostData(slug); return ( -
    - -
    -
    - - {new Date(postData.date).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - })} - - {postData.readingTime} -
    - - {postData.wip && ( -
    - 🚧 - This post is a work in progress — content may change. +
    +
    + +
    +
    + + {new Date(postData.date).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} + + {postData.readingTime}
    - )} - {/* Mobile TOC — shown above article, hidden on lg+ */} - {postData.headings.length > 0 && ( -
    - -
    - )} + {postData.wip && ( +
    + 🚧 + This post is a work in progress — content may change. +
    + )} -
    -
    + {/* Mobile TOC — inline above article, hidden on lg+ */} + {postData.headings.length > 0 && ( +
    + +
    + )} + +
    +
    +
    - {/* Desktop TOC sidebar — hidden on mobile */} - {postData.headings.length > 0 && ( -
    - -
    - )} + {/* Desktop TOC — separate Aero Window, sticky on the right */} + {postData.headings.length > 0 && ( +
    + + +
    -
    -
    + )} +
    +
    ); } diff --git a/components/window.tsx b/components/window.tsx index 97e1b87..da4633b 100644 --- a/components/window.tsx +++ b/components/window.tsx @@ -47,6 +47,7 @@ interface WindowProps { children: React.ReactNode; className?: string; showProgress?: boolean; + showRss?: boolean; } export default function Window({ @@ -54,6 +55,7 @@ export default function Window({ children, className = "", showProgress = false, + showRss = false, }: WindowProps) { return (
    {title} - - - + {showRss && ( + + + + )}
    -- 2.49.1 From ce915d666b0cfaa713163d5e04b30e07b7a055b4 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:48:22 +0200 Subject: [PATCH 20/21] feat: sticky window title bar so progress bar stays visible on scroll --- components/window.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/window.tsx b/components/window.tsx index da4633b..b7a1c42 100644 --- a/components/window.tsx +++ b/components/window.tsx @@ -61,7 +61,7 @@ export default function Window({
    -
    +
    {title} -- 2.49.1 From 7e97459c937484907cb3b4d2d99733ae15ba485c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 02:49:51 +0200 Subject: [PATCH 21/21] feat: update NotFound window title for better user experience --- app/not-found.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/not-found.tsx b/app/not-found.tsx index 9ee4dbc..6ff142f 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -5,7 +5,7 @@ import GoBackButton from "@/components/go-back-button"; export default function NotFound() { return (
    - +