Compare commits
11 Commits
main
...
8abb3899a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 8abb3899a6 | |||
| c3cc9ccd01 | |||
| 6bf7ae9bef | |||
| 4b8533f9be | |||
| c0229f28a9 | |||
| 5e11d3e13e | |||
| a3e3f53548 | |||
| d55e41992d | |||
| a1019daab8 | |||
| 71c1c49e8f | |||
| 3bbcd6f345 |
@@ -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;
|
||||
}
|
||||
|
||||
49
app/not-found.tsx
Normal file
49
app/not-found.tsx
Normal 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="Windows cannot find this page">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
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: {
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Window title={postData.title}>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<Window title={postData.title} showProgress>
|
||||
<article>
|
||||
<div className="mb-4 text-gray-500 flex flex-col gap-1">
|
||||
<span>
|
||||
@@ -41,30 +40,54 @@ export default async function Post({ params }: PageProps) {
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-sm text-gray-400">
|
||||
{postData.readingTime}
|
||||
</span>
|
||||
<span className="text-sm text-gray-400">{postData.readingTime}</span>
|
||||
</div>
|
||||
<div className="prose lg:prose-xl max-w-none">
|
||||
<MDXRemote
|
||||
source={postData.content}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
rehypePlugins: [
|
||||
[
|
||||
rehypePrettyCode,
|
||||
{
|
||||
theme: "github-dark-dimmed",
|
||||
keepBackground: false,
|
||||
},
|
||||
|
||||
{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 — shown above article, hidden on lg+ */}
|
||||
{postData.headings.length > 0 && (
|
||||
<div className="lg:hidden mb-6">
|
||||
<TableOfContents headings={postData.headings} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="lg:flex lg:gap-8 lg:items-start">
|
||||
<div className="prose lg:prose-lg max-w-none flex-1 min-w-0">
|
||||
<MDXRemote
|
||||
source={postData.content}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
[
|
||||
rehypePrettyCode,
|
||||
{
|
||||
theme: "github-dark-dimmed",
|
||||
keepBackground: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop TOC sidebar — hidden on mobile */}
|
||||
{postData.headings.length > 0 && (
|
||||
<div className="hidden lg:block w-52 flex-shrink-0">
|
||||
<TableOfContents headings={postData.headings} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Window>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
|
||||
7
bun.lock
7
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=="],
|
||||
|
||||
15
components/go-back-button.tsx
Normal file
15
components/go-back-button.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function GoBackButton() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
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>
|
||||
);
|
||||
}
|
||||
33
components/reading-progress.tsx
Normal file
33
components/reading-progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
components/table-of-contents.tsx
Normal file
64
components/table-of-contents.tsx
Normal 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 className="bg-white/20 backdrop-blur border border-white/30 rounded-lg p-4 lg:sticky lg:top-8">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => (
|
||||
<svg
|
||||
@@ -45,38 +46,43 @@ interface WindowProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
showProgress?: boolean;
|
||||
}
|
||||
|
||||
export default function Window({
|
||||
title,
|
||||
children,
|
||||
className = "",
|
||||
showProgress = false,
|
||||
}: WindowProps) {
|
||||
return (
|
||||
<div
|
||||
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="flex items-center space-x-2">
|
||||
<span>{title}</span>
|
||||
<Link href="/feed.xml" title="RSS Feed">
|
||||
<RSSIcon className="h-4 w-4 opacity-80 hover:opacity-100" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex h-4 w-4 items-center justify-center opacity-80">
|
||||
<MinimizeIcon />
|
||||
<div className="rounded-t-md overflow-hidden">
|
||||
<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">
|
||||
<span>{title}</span>
|
||||
<Link href="/feed.xml" title="RSS Feed">
|
||||
<RSSIcon className="h-4 w-4 opacity-80 hover:opacity-100" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex h-4 w-4 items-center justify-center opacity-80">
|
||||
<MaximizeIcon />
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex h-4 w-4 items-center justify-center opacity-80">
|
||||
<MinimizeIcon />
|
||||
</div>
|
||||
<div className="flex h-4 w-4 items-center justify-center opacity-80">
|
||||
<MaximizeIcon />
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex h-4 w-4 items-center justify-center opacity-80"
|
||||
>
|
||||
<CloseIcon />
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex h-4 w-4 items-center justify-center opacity-80"
|
||||
>
|
||||
<CloseIcon />
|
||||
</Link>
|
||||
</div>
|
||||
{showProgress && <ReadingProgress />}
|
||||
</div>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
|
||||
29
lib/posts.ts
29
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<PostData> {
|
||||
title: matterResult.data.title,
|
||||
description: matterResult.data.description,
|
||||
readingTime: stats.text,
|
||||
headings: extractHeadings(matterResult.content),
|
||||
wip: matterResult.data.wip ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user