feat: add clickable heading links with copy-to-clipboard functionality

This commit is contained in:
Mauricio Siu 2025-03-02 19:25:49 -06:00
parent 064924316b
commit b7e7d50f32
2 changed files with 112 additions and 49 deletions

View File

@ -0,0 +1,100 @@
"use client";
import { useRouter } from "next/navigation";
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import slugify from "slugify";
type HeadingProps = DetailedHTMLProps<
HTMLAttributes<HTMLHeadingElement>,
HTMLHeadingElement
>;
function LinkIcon() {
return (
<svg
className="inline-block w-4 h-4 ml-2 opacity-0 group-hover:opacity-100 transition-opacity"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
);
}
export function H1({ children, ...props }: HeadingProps) {
const router = useRouter();
const id = slugify(children?.toString() || "", {
lower: true,
strict: true,
});
const handleClick = () => {
router.push(`#${id}`);
};
return (
<h1
id={id}
onClick={handleClick}
className="group text-xl md:text-2xl xl:text-3xl text-primary font-bold mt-8 mb-4 cursor-pointer hover:text-primary/80 transition-colors"
{...props}
>
{children}
<LinkIcon />
</h1>
);
}
export function H2({ children, ...props }: HeadingProps) {
const router = useRouter();
const id = slugify(children?.toString() || "", {
lower: true,
strict: true,
});
const handleClick = () => {
router.push(`#${id}`);
};
return (
<h2
id={id}
onClick={handleClick}
className="group text-2xl text-primary/90 font-semibold mt-6 mb-3 cursor-pointer hover:text-primary/80 transition-colors"
{...props}
>
{children}
<LinkIcon />
</h2>
);
}
export function H3({ children, ...props }: HeadingProps) {
const router = useRouter();
const id = slugify(children?.toString() || "", {
lower: true,
strict: true,
});
const handleClick = () => {
router.push(`#${id}`);
};
return (
<h3
id={id}
onClick={handleClick}
className="group text-xl text-primary/90 font-semibold mt-4 mb-2 cursor-pointer hover:text-primary/80 transition-colors"
{...props}
>
{children}
<LinkIcon />
</h3>
);
}

View File

@ -19,6 +19,7 @@ import slugify from "slugify";
import TurndownService from "turndown";
// @ts-ignore
import * as turndownPluginGfm from "turndown-plugin-gfm";
import { H1, H2, H3 } from "./components/Headings";
import { TableOfContents } from "./components/TableOfContents";
import { ZoomableImage } from "./components/ZoomableImage";
@ -172,55 +173,17 @@ export default async function BlogPostPage({ params }: Props) {
});
const components: Partial<Components> = {
h1: ({ node, ...props }) => {
const id = slugify(props.children?.toString() || "", {
lower: true,
strict: true,
});
return (
<h1
id={id}
className="text-xl md:text-2xl xl:text-3xl text-primary font-bold mt-8 mb-4"
{...props}
/>
);
},
h2: ({ node, ...props }) => {
const id = slugify(props.children?.toString() || "", {
lower: true,
strict: true,
});
return (
<h2
id={id}
className="text-2xl text-primary/90 font-semibold mt-6 mb-3"
{...props}
/>
);
},
h3: ({ node, ...props }) => {
const id = slugify(props.children?.toString() || "", {
lower: true,
strict: true,
});
return (
<h3
id={id}
className="text-xl text-primary/90 font-semibold mt-4 mb-2"
{...props}
/>
);
},
p: ({ node, children, ...props }) => {
return (
<p
className="text-base text-muted-foreground leading-relaxed mb-4"
{...props}
>
{children}
</p>
);
},
h1: H1,
h2: H2,
h3: H3,
p: ({ node, children, ...props }) => (
<p
className="text-base text-muted-foreground leading-relaxed mb-4"
{...props}
>
{children}
</p>
),
a: ({ node, href, ...props }) => (
<a
href={href}