From 1ba5ee1b410e3988ec5d88b99a8b9c6ced1aeac9 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 31 Mar 2026 00:50:16 +0000 Subject: [PATCH] fun improvements (#1) Reviewed-on: https://git.gabrielkaszewski.dev/GKaszewski/blog/pulls/1 --- .gitignore | 3 + app/globals.css | 10 +++ app/not-found.tsx | 49 ++++++++++++++ app/page.tsx | 27 ++++++-- app/posts/[slug]/page.tsx | 109 +++++++++++++++++++------------ bun.lock | 7 ++ components/badges.tsx | 85 ++++++++++++++++++++++++ components/go-back-button.tsx | 25 +++++++ components/reading-progress.tsx | 33 ++++++++++ components/table-of-contents.tsx | 64 ++++++++++++++++++ components/window.tsx | 46 ++++++++----- lib/posts.ts | 31 ++++++++- package.json | 1 + 13 files changed, 424 insertions(+), 66 deletions(-) create mode 100644 app/not-found.tsx create mode 100644 components/badges.tsx create mode 100644 components/go-back-button.tsx create mode 100644 components/reading-progress.tsx create mode 100644 components/table-of-contents.tsx 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/ diff --git a/app/globals.css b/app/globals.css index 8fe3d0f..35070a2 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; } +} + +@utility animate-blink { + animation: blink 0.8s ease-in-out infinite; +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..6ff142f --- /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/app/page.tsx b/app/page.tsx index 9f3cfbb..533b25b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,12 +1,20 @@ import { getSortedPostsData, PostMeta } from "../lib/posts"; import Link from "next/link"; import Window from "../components/window"; +import Badges from "../components/badges"; + +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 +25,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", { diff --git a/app/posts/[slug]/page.tsx b/app/posts/[slug]/page.tsx index f2de3b6..956a993 100644 --- a/app/posts/[slug]/page.tsx +++ b/app/posts/[slug]/page.tsx @@ -1,70 +1,95 @@ import Link from "next/link"; import { getPostData, getAllPostIds } from "@/lib/posts"; import type { PostData } from "@/lib/posts"; -import Window from "../../../components/window"; +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: { - slug: string; - }; + params: Promise<{ slug: string }>; } -// 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); + const { slug } = await params; + const postData = await getPostData(slug); return { title: `${postData.title} | Gabriel's Kaszewski Blog`, }; } 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); + const { slug } = await params; + const postData: PostData = await getPostData(slug); return ( -
    - -
    -
    - - {new Date(postData.date).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - })} - - - {postData.readingTime} - -
    -
    - +
    + +
    +
    + + {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. +
    + )} + + {/* Mobile TOC — inline above article, hidden on lg+ */} + {postData.headings.length > 0 && ( +
    + +
    + )} + +
    + + }, + }} + /> +
    +
    +
    + + {/* Desktop TOC — separate Aero Window, sticky on the right */} + {postData.headings.length > 0 && ( +
    + + +
    -
    -
    + )} +
    +
    + {children} +
    + ); + + if (href) { + if (href.startsWith("http")) { + return ( + + {base} + + ); + } + return ( + + {base} + + ); + } + return base; +} + +export default function Badges() { + return ( +
    + + ⬛ Next.js + + + + 🍞 Powered by Bun + + + +
    +
    Best viewed
    +
    1024×768
    +
    +
    + + + 📡 Valid RSS + + + + Made with ♥ + + + +
    +
    ✦ Frutiger
    +
    Aero ✦
    +
    +
    +
    + ); +} diff --git a/components/go-back-button.tsx b/components/go-back-button.tsx new file mode 100644 index 0000000..3cb2b1e --- /dev/null +++ b/components/go-back-button.tsx @@ -0,0 +1,25 @@ +"use client"; + +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/reading-progress.tsx b/components/reading-progress.tsx new file mode 100644 index 0000000..fda10e6 --- /dev/null +++ b/components/reading-progress.tsx @@ -0,0 +1,33 @@ +"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); + } + }; + + handleScroll(); + window.addEventListener("scroll", handleScroll, { passive: true }); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + return ( +
    +
    +
    + ); +} diff --git a/components/table-of-contents.tsx b/components/table-of-contents.tsx new file mode 100644 index 0000000..60a994d --- /dev/null +++ b/components/table-of-contents.tsx @@ -0,0 +1,64 @@ +"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(() => headings[0]?.slug ?? ""); + + 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 ( + + ); +} diff --git a/components/window.tsx b/components/window.tsx index a137868..b7a1c42 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} + {showRss && ( + + + + )}
    -
    - +
    +
    + +
    +
    + +
    + + +
    - - -
    + {showProgress && }
    {children}
    diff --git a/lib/posts.ts b/lib/posts.ts index 02b44de..4877cfb 100644 --- a/lib/posts.ts +++ b/lib/posts.ts @@ -2,9 +2,16 @@ 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'); +export interface Heading { + level: number; + text: string; + slug: string; +} + export interface PostData { id: string; date: string; @@ -12,6 +19,8 @@ export interface PostData { description: string; content: string; readingTime: string; + headings: Heading[]; + wip: boolean; } export interface PostMeta { @@ -20,6 +29,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 +65,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, }; }); @@ -65,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); @@ -76,5 +103,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",