Compare commits

...

21 Commits

Author SHA1 Message Date
7e97459c93 feat: update NotFound window title for better user experience 2026-03-31 02:49:51 +02:00
ce915d666b feat: sticky window title bar so progress bar stays visible on scroll 2026-03-31 02:48:22 +02:00
2d6239d2d9 feat: make RSS icon opt-in via showRss prop on Window 2026-03-31 02:45:29 +02:00
893760751b feat: TOC as separate Aero Window on desktop 2026-03-31 02:43:45 +02:00
7faaac2081 fix: notFound() on missing post, await params for Next.js 15 2026-03-31 02:39:33 +02:00
b2a611ce5b chore: ignore .superpowers brainstorm directory 2026-03-31 02:36:37 +02:00
04ada8ad51 fix: remove unused React import, use Link for internal badge hrefs 2026-03-31 02:35:58 +02:00
86d76d39ac feat: add 88x31 retro badges in About this site window 2026-03-31 02:35:06 +02:00
6de2a1c9fa fix: fall back to home when no history in GoBackButton 2026-03-31 02:34:02 +02:00
c20b33e6af feat: add NEW! and WIP corner ribbons to post list 2026-03-31 02:31:36 +02:00
8abb3899a6 feat: add Vista-style 404 page 2026-03-31 02:31:30 +02:00
c3cc9ccd01 chore: use @/ alias for component imports in post page 2026-03-31 02:30:39 +02:00
6bf7ae9bef feat: add TOC, reading progress, WIP banner, and rehype-slug to post page 2026-03-31 02:29:54 +02:00
4b8533f9be fix: polish TableOfContents — initial state, Tailwind indent, aria-current 2026-03-31 02:28:54 +02:00
c0229f28a9 feat: add showProgress prop to Window with reading progress strip 2026-03-31 02:27:13 +02:00
5e11d3e13e feat: add TableOfContents component with IntersectionObserver 2026-03-31 02:27:05 +02:00
a3e3f53548 fix: use @utility for animate-blink, call handleScroll on mount 2026-03-31 02:26:22 +02:00
d55e41992d feat: add blink keyframe animation 2026-03-31 02:24:16 +02:00
a1019daab8 feat: add ReadingProgress client component 2026-03-31 02:24:14 +02:00
71c1c49e8f fix: make wip field non-optional to match runtime guarantee 2026-03-31 02:23:47 +02:00
3bbcd6f345 feat: add heading extraction and wip flag to posts 2026-03-31 02:22:08 +02:00
13 changed files with 424 additions and 66 deletions

3
.gitignore vendored
View File

@@ -39,3 +39,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# brainstorm
.superpowers/

View File

@@ -130,3 +130,13 @@ body {
.prose :not(pre) > code::after { .prose :not(pre) > code::after {
content: none !important; 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;
}

49
app/not-found.tsx Normal file
View File

@@ -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 (
<div className="mx-auto max-w-md pt-16">
<Window title="Oopsie doopsie! You're lost!">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0 flex h-10 w-10 items-center justify-center rounded-full bg-red-600 shadow-inner">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3L15 15M3 15L15 3"
stroke="white"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
</div>
<div>
<h2 className="font-bold text-gray-900 text-base mb-1">
404 Not Found
</h2>
<p className="text-sm text-gray-600 leading-relaxed">
The page you're looking for doesn't exist. Check the address or
head back home.
</p>
</div>
</div>
<div className="border-t border-gray-200/60 pt-4 flex justify-end gap-2">
<GoBackButton />
<Link
href="/"
className="px-4 py-1.5 text-sm bg-gradient-to-b from-blue-100 to-blue-200 border border-blue-300 rounded hover:from-blue-200 hover:to-blue-300 transition-colors"
>
Home
</Link>
</div>
</Window>
</div>
);
}

View File

@@ -1,12 +1,20 @@
import { getSortedPostsData, PostMeta } from "../lib/posts"; import { getSortedPostsData, PostMeta } from "../lib/posts";
import Link from "next/link"; import Link from "next/link";
import Window from "../components/window"; 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() { export default function Home() {
const allPostsData: PostMeta[] = getSortedPostsData(); const allPostsData: PostMeta[] = getSortedPostsData();
return ( return (
<div className="space-y-12"> <div className="space-y-6">
<header className="text-center"> <header className="text-center">
<h1 className="text-5xl font-bold text-white [text-shadow:_2px_2px_4px_rgb(0_0_0_/_40%)]"> <h1 className="text-5xl font-bold text-white [text-shadow:_2px_2px_4px_rgb(0_0_0_/_40%)]">
Gabriel's Kaszewski Blog Gabriel's Kaszewski Blog
@@ -17,16 +25,25 @@ export default function Home() {
</header> </header>
<section> <section>
{/* The list of posts is displayed inside our custom Window component */} <Window title="Blog Posts" showRss>
<Window title="Blog Posts">
{allPostsData.length > 0 ? ( {allPostsData.length > 0 ? (
<ul className="space-y-4"> <ul className="space-y-4">
{allPostsData.map(({ id, date, title }) => ( {allPostsData.map(({ id, date, title, wip }) => (
<li key={id}> <li key={id}>
<Link <Link
href={`/posts/${id}`} href={`/posts/${id}`}
className="block rounded-md bg-white/50 p-4 transition-all duration-200 hover:bg-white/80 hover:shadow-md" className="relative block overflow-hidden rounded-md bg-white/50 p-4 transition-all duration-200 hover:bg-white/80 hover:shadow-md"
> >
{isNew(date) && !wip && (
<div className="animate-blink absolute top-[10px] right-[-24px] w-[100px] rotate-[35deg] bg-[#e05010] py-0.5 text-center text-[9px] font-bold text-white shadow-sm">
NEW!
</div>
)}
{wip && (
<div className="absolute top-[10px] right-[-24px] w-[100px] rotate-[35deg] bg-amber-500 py-0.5 text-center text-[9px] font-bold text-white shadow-sm">
🚧 WIP
</div>
)}
<h3 className="font-bold text-lg text-blue-800">{title}</h3> <h3 className="font-bold text-lg text-blue-800">{title}</h3>
<small className="text-gray-600"> <small className="text-gray-600">
{new Date(date).toLocaleDateString("en-US", { {new Date(date).toLocaleDateString("en-US", {

View File

@@ -1,37 +1,37 @@
import Link from "next/link"; import Link from "next/link";
import { getPostData, getAllPostIds } from "@/lib/posts"; import { getPostData, getAllPostIds } from "@/lib/posts";
import type { PostData } 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 { MDXRemote } from "next-mdx-remote/rsc";
import rehypePrettyCode from "rehype-pretty-code"; import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
interface PageProps { interface PageProps {
params: { params: Promise<{ slug: string }>;
slug: string;
};
} }
// This function tells Next.js which blog posts exist at build time
export async function generateStaticParams() { export async function generateStaticParams() {
const paths = getAllPostIds(); const paths = getAllPostIds();
return paths.map((path) => ({ slug: path.params.slug })); 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) { export async function generateMetadata({ params }: PageProps) {
const postData = await getPostData(params.slug); const { slug } = await params;
const postData = await getPostData(slug);
return { return {
title: `${postData.title} | Gabriel's Kaszewski Blog`, title: `${postData.title} | Gabriel's Kaszewski Blog`,
}; };
} }
export default async function Post({ params }: PageProps) { export default async function Post({ params }: PageProps) {
// Fetch the specific post's content based on the URL slug const { slug } = await params;
const postData: PostData = await getPostData(params.slug); const postData: PostData = await getPostData(slug);
return ( return (
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-6xl">
<Window title={postData.title}> <div className="lg:flex lg:gap-4 lg:items-start">
<Window title={postData.title} showProgress className="lg:flex-1 min-w-0">
<article> <article>
<div className="mb-4 text-gray-500 flex flex-col gap-1"> <div className="mb-4 text-gray-500 flex flex-col gap-1">
<span> <span>
@@ -41,16 +41,30 @@ export default async function Post({ params }: PageProps) {
day: "numeric", day: "numeric",
})} })}
</span> </span>
<span className="text-sm text-gray-400"> <span className="text-sm text-gray-400">{postData.readingTime}</span>
{postData.readingTime}
</span>
</div> </div>
<div className="prose lg:prose-xl max-w-none">
{postData.wip && (
<div className="mb-6 flex items-center gap-3 rounded-lg border border-amber-300 bg-amber-50/70 px-4 py-3 text-sm text-amber-800">
<span className="text-lg">🚧</span>
<span>This post is a work in progress content may change.</span>
</div>
)}
{/* Mobile TOC — inline above article, hidden on lg+ */}
{postData.headings.length > 0 && (
<div className="lg:hidden mb-6 bg-white/20 backdrop-blur border border-white/30 rounded-lg p-4">
<TableOfContents headings={postData.headings} />
</div>
)}
<div className="prose lg:prose-lg max-w-none">
<MDXRemote <MDXRemote
source={postData.content} source={postData.content}
options={{ options={{
mdxOptions: { mdxOptions: {
rehypePlugins: [ rehypePlugins: [
rehypeSlug,
[ [
rehypePrettyCode, rehypePrettyCode,
{ {
@@ -65,6 +79,17 @@ export default async function Post({ params }: PageProps) {
</div> </div>
</article> </article>
</Window> </Window>
{/* Desktop TOC — separate Aero Window, sticky on the right */}
{postData.headings.length > 0 && (
<div className="hidden lg:block w-56 flex-shrink-0 sticky top-8">
<Window title="Contents">
<TableOfContents headings={postData.headings} />
</Window>
</div>
)}
</div>
<div className="mt-8 text-center"> <div className="mt-8 text-center">
<Link <Link
href="/" href="/"

View File

@@ -14,6 +14,7 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rehype-pretty-code": "^0.14.3", "rehype-pretty-code": "^0.14.3",
"rehype-slug": "^6.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-html": "^16.0.1", "remark-html": "^16.0.1",
"rss": "^1.2.2", "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=="], "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=="], "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=="], "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-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-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=="], "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-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": ["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=="], "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=="],

85
components/badges.tsx Normal file
View File

@@ -0,0 +1,85 @@
import Link from "next/link";
interface BadgeProps {
href?: string;
children: React.ReactNode;
style?: React.CSSProperties;
}
function Badge({ href, children, style }: BadgeProps) {
const base = (
<div
className="flex items-center justify-center border border-white/40 text-center font-mono leading-tight select-none"
style={{ width: 88, height: 31, fontSize: 9, ...style }}
>
{children}
</div>
);
if (href) {
if (href.startsWith("http")) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className="hover:opacity-80 transition-opacity">
{base}
</a>
);
}
return (
<Link href={href} className="hover:opacity-80 transition-opacity">
{base}
</Link>
);
}
return base;
}
export default function Badges() {
return (
<div className="flex flex-wrap gap-1.5">
<Badge
href="https://nextjs.org"
style={{ background: "linear-gradient(135deg, #0d0d0d, #1a1a2e)", color: "#7dd3fc", borderColor: "#334155" }}
>
<span> Next.js</span>
</Badge>
<Badge
href="https://bun.sh"
style={{ background: "linear-gradient(135deg, #1a0a00, #2d1500)", color: "#fb923c", borderColor: "#7c2d12" }}
>
🍞 Powered by Bun
</Badge>
<Badge
style={{ background: "linear-gradient(135deg, #0c1a3a, #1e3a6e)", color: "#93c5fd", borderColor: "#1e40af" }}
>
<div>
<div>Best viewed</div>
<div>1024×768</div>
</div>
</Badge>
<Badge
href="/feed.xml"
style={{ background: "linear-gradient(135deg, #431407, #7c2d12)", color: "#fde68a", borderColor: "#b45309" }}
>
📡 Valid RSS
</Badge>
<Badge
style={{ background: "linear-gradient(135deg, #0f172a, #1e1b4b)", color: "#c4b5fd", borderColor: "#4c1d95" }}
>
Made with
</Badge>
<Badge
style={{ background: "linear-gradient(135deg, #0369a1, #0ea5e9)", color: "white", borderColor: "#38bdf8" }}
>
<div>
<div style={{ fontSize: 8 }}> Frutiger</div>
<div style={{ fontSize: 8 }}>Aero </div>
</div>
</Badge>
</div>
);
}

View File

@@ -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 (
<button
onClick={handleBack}
aria-label="Go back to previous page"
className="px-4 py-1.5 text-sm bg-gradient-to-b from-blue-100 to-blue-200 border border-blue-300 rounded hover:from-blue-200 hover:to-blue-300 transition-colors"
>
Go Back
</button>
);
}

View File

@@ -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 (
<div className="h-[3px] w-full bg-black/20">
<div
className="h-full transition-[width] duration-100"
style={{
width: `${progress}%`,
background:
"linear-gradient(90deg, rgba(255,255,255,0.9) 0%, rgba(180,230,255,0.75) 100%)",
}}
/>
</div>
);
}

View File

@@ -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 (
<nav>
<p className="text-xs font-bold uppercase tracking-widest text-blue-800 mb-3">
Contents
</p>
<ul className="space-y-1.5">
{headings.map(({ slug, text, level }) => (
<li
key={slug}
className={level === 3 ? "pl-3" : ""}
>
<a
href={`#${slug}`}
aria-current={activeSlug === slug ? "true" : undefined}
className={`text-sm leading-snug transition-colors duration-150 ${
activeSlug === slug
? "text-blue-700 font-semibold"
: "text-gray-600 hover:text-blue-600"
}`}
>
{text}
</a>
</li>
))}
</ul>
</nav>
);
}

View File

@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
import RSSIcon from "./rss-icon"; import RSSIcon from "./rss-icon";
import ReadingProgress from "./reading-progress";
const CloseIcon = () => ( const CloseIcon = () => (
<svg <svg
@@ -45,23 +46,30 @@ interface WindowProps {
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
showProgress?: boolean;
showRss?: boolean;
} }
export default function Window({ export default function Window({
title, title,
children, children,
className = "", className = "",
showProgress = false,
showRss = false,
}: WindowProps) { }: WindowProps) {
return ( return (
<div <div
className={`rounded-lg border border-white/30 bg-white/70 shadow-window backdrop-blur-xl ${className}`} className={`rounded-lg border border-white/30 bg-white/70 shadow-window backdrop-blur-xl ${className}`}
> >
<div className="flex select-none items-center justify-between rounded-t-md bg-gradient-to-b from-blue-500 to-window-title px-4 py-1.5 font-bold text-white text-sm"> <div className="rounded-t-md overflow-hidden sticky top-0 z-10">
<div className="flex select-none items-center justify-between bg-gradient-to-b from-blue-500 to-window-title px-4 py-1.5 font-bold text-white text-sm">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span>{title}</span> <span>{title}</span>
{showRss && (
<Link href="/feed.xml" title="RSS Feed"> <Link href="/feed.xml" title="RSS Feed">
<RSSIcon className="h-4 w-4 opacity-80 hover:opacity-100" /> <RSSIcon className="h-4 w-4 opacity-80 hover:opacity-100" />
</Link> </Link>
)}
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="flex h-4 w-4 items-center justify-center opacity-80"> <div className="flex h-4 w-4 items-center justify-center opacity-80">
@@ -78,6 +86,8 @@ export default function Window({
</Link> </Link>
</div> </div>
</div> </div>
{showProgress && <ReadingProgress />}
</div>
<div className="p-6">{children}</div> <div className="p-6">{children}</div>
</div> </div>
); );

View File

@@ -2,9 +2,16 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import matter from 'gray-matter'; import matter from 'gray-matter';
import readingTime from 'reading-time'; import readingTime from 'reading-time';
import { notFound } from 'next/navigation';
const postsDirectory = path.join(process.cwd(), 'posts'); const postsDirectory = path.join(process.cwd(), 'posts');
export interface Heading {
level: number;
text: string;
slug: string;
}
export interface PostData { export interface PostData {
id: string; id: string;
date: string; date: string;
@@ -12,6 +19,8 @@ export interface PostData {
description: string; description: string;
content: string; content: string;
readingTime: string; readingTime: string;
headings: Heading[];
wip: boolean;
} }
export interface PostMeta { export interface PostMeta {
@@ -20,6 +29,22 @@ export interface PostMeta {
title: string; title: string;
description: string; description: string;
readingTime: 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[] { export function getSortedPostsData(): PostMeta[] {
@@ -40,6 +65,7 @@ export function getSortedPostsData(): PostMeta[] {
title: matterResult.data.title as string, title: matterResult.data.title as string,
description: matterResult.data.description as string, description: matterResult.data.description as string,
readingTime: stats.text, readingTime: stats.text,
wip: matterResult.data.wip ?? false,
}; };
}); });
@@ -65,6 +91,7 @@ export function getAllPostIds() {
export async function getPostData(id: string): Promise<PostData> { export async function getPostData(id: string): Promise<PostData> {
const fullPath = path.join(postsDirectory, `${id}.mdx`); const fullPath = path.join(postsDirectory, `${id}.mdx`);
if (!fs.existsSync(fullPath)) notFound();
const fileContents = fs.readFileSync(fullPath, 'utf8'); const fileContents = fs.readFileSync(fullPath, 'utf8');
const matterResult = matter(fileContents); const matterResult = matter(fileContents);
const stats = readingTime(matterResult.content); const stats = readingTime(matterResult.content);
@@ -76,5 +103,7 @@ export async function getPostData(id: string): Promise<PostData> {
title: matterResult.data.title, title: matterResult.data.title,
description: matterResult.data.description, description: matterResult.data.description,
readingTime: stats.text, readingTime: stats.text,
headings: extractHeadings(matterResult.content),
wip: matterResult.data.wip ?? false,
}; };
} }

View File

@@ -18,6 +18,7 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rehype-pretty-code": "^0.14.3", "rehype-pretty-code": "^0.14.3",
"rehype-slug": "^6.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-html": "^16.0.1", "remark-html": "^16.0.1",
"rss": "^1.2.2", "rss": "^1.2.2",