mirror of
https://github.com/privacy-scaling-explorations/pse.dev.git
synced 2026-01-09 14:18:02 -05:00
feat: Hyperlinkable Headings + blog heading fixes (#513)
This commit is contained in:
@@ -12,6 +12,7 @@ import { cn, getBackgroundImage } from "@/lib/utils"
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { BlogHeader } from "../sections/BlogHeader"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -101,79 +102,7 @@ export default function BlogArticle({ params }: any) {
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-start justify-center z-0 bg-cover border-tuatara-300 border-b w-full dark:border-anakiwa-800">
|
||||
<AppContent className="flex flex-col gap-8 py-10 max-w-[978px]">
|
||||
<Label.PageTitle
|
||||
label={post?.title}
|
||||
className={cn(imageAsCover && "text-white")}
|
||||
/>
|
||||
{post?.date || post?.tldr ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{post?.date && (
|
||||
<div
|
||||
className={blogArticleCardTagCardVariants({
|
||||
variant: "secondary",
|
||||
})}
|
||||
>
|
||||
{new Date(post?.date).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{post?.canonical && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm italic mt-1",
|
||||
imageAsCover ? "text-white" : "text-gray-500"
|
||||
)}
|
||||
>
|
||||
This post was originally posted in{" "}
|
||||
<a
|
||||
href={post.canonical}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer canonical"
|
||||
className={cn(
|
||||
"text-primary hover:underline",
|
||||
imageAsCover ? "text-white" : "text-gray-500"
|
||||
)}
|
||||
>
|
||||
{new URL(post.canonical).hostname.replace(/^www\./, "")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{post?.tldr && (
|
||||
<Markdown darkMode={imageAsCover}>{post?.tldr}</Markdown>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{(post?.tags ?? [])?.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm italic",
|
||||
imageAsCover ? "text-white" : "text-primary"
|
||||
)}
|
||||
>
|
||||
Tags:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post?.tags?.map((tag) => (
|
||||
<Link key={tag.id} href={`/blog/tags/${tag.id}`}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={imageAsCover ? "secondary" : "default"}
|
||||
>
|
||||
{tag.name}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AppContent>
|
||||
</div>
|
||||
<BlogHeader post={post} imageAsCover={imageAsCover} />
|
||||
</div>
|
||||
<div className="pt-10 md:pt-16 pb-32">
|
||||
<BlogContent post={post} isNewsletter={isNewsletter} />
|
||||
|
||||
114
app/(pages)/blog/sections/BlogHeader.tsx
Normal file
114
app/(pages)/blog/sections/BlogHeader.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import { blogArticleCardTagCardVariants } from "@/components/blog/blog-article-card"
|
||||
import { AppContent } from "@/components/ui/app-content"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Markdown } from "@/components/ui/markdown"
|
||||
import { cn } from "@/lib/utils"
|
||||
import Link from "next/link"
|
||||
|
||||
interface BlogHeaderProps {
|
||||
post: any
|
||||
imageAsCover: boolean
|
||||
}
|
||||
|
||||
export const BlogHeader = ({ post, imageAsCover }: BlogHeaderProps) => {
|
||||
const authors =
|
||||
(post?.authors ?? [])?.length > 0 ? post.authors?.join(", ") : null
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-center z-0 bg-cover border-tuatara-300 border-b w-full dark:border-anakiwa-800">
|
||||
<AppContent className="flex flex-col gap-8 py-10 max-w-[978px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label.PageTitle
|
||||
label={post?.title}
|
||||
className={cn(imageAsCover && "text-white")}
|
||||
/>
|
||||
{authors && (
|
||||
<span className={cn("text-sm font-sans text-tuatara-300")}>
|
||||
{authors}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{post?.date || post?.tldr ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{post?.date && (
|
||||
<div
|
||||
className={blogArticleCardTagCardVariants({
|
||||
variant: "secondary",
|
||||
})}
|
||||
>
|
||||
{new Date(post?.date).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{post?.canonical && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm italic mt-1",
|
||||
imageAsCover ? "text-white" : "text-gray-500"
|
||||
)}
|
||||
>
|
||||
This post was originally posted in{" "}
|
||||
<a
|
||||
href={post.canonical}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer canonical"
|
||||
className={cn(
|
||||
"text-primary hover:underline",
|
||||
imageAsCover ? "text-white" : "text-gray-500"
|
||||
)}
|
||||
>
|
||||
{new URL(post.canonical).hostname.replace(/^www\./, "")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{post?.tldr && (
|
||||
<Markdown
|
||||
darkMode={imageAsCover}
|
||||
components={{
|
||||
p: ({ children }) => (
|
||||
<p className="text-white dark:text-tuatara-200 font-sans text-lg font-normal">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{post?.tldr}
|
||||
</Markdown>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{(post?.tags ?? [])?.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm italic",
|
||||
imageAsCover ? "text-white" : "text-primary"
|
||||
)}
|
||||
>
|
||||
Tags:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post?.tags?.map((tag: any) => (
|
||||
<Link key={tag.id} href={`/blog/tags/${tag.id}`}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={imageAsCover ? "secondary" : "default"}
|
||||
>
|
||||
{tag.name}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AppContent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -542,6 +542,39 @@ export const Icons = {
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
copied: (props: LucideProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
),
|
||||
copy: (props: LucideProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
),
|
||||
mirror: (props: LucideProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -22,6 +22,7 @@ import "prismjs/components/prism-yaml"
|
||||
import "prismjs/components/prism-python"
|
||||
import "prismjs/components/prism-rust"
|
||||
import "prismjs/components/prism-solidity"
|
||||
import { Icons } from "../icons"
|
||||
|
||||
const SCROLL_OFFSET = 150
|
||||
|
||||
@@ -452,6 +453,85 @@ const CodeBlock = ({
|
||||
)
|
||||
}
|
||||
|
||||
const HeadingLink = ({
|
||||
level,
|
||||
children,
|
||||
}: {
|
||||
level: number
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
const [showLink, setShowLink] = React.useState(false)
|
||||
|
||||
const id = React.useMemo(() => {
|
||||
if (typeof children === "string") {
|
||||
return generateSectionId(children)
|
||||
}
|
||||
const text = React.Children.toArray(children)
|
||||
.map((child) => {
|
||||
if (typeof child === "string") return child
|
||||
if (
|
||||
React.isValidElement(child) &&
|
||||
typeof child.props?.children === "string"
|
||||
) {
|
||||
return child.props.children
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.join("")
|
||||
return generateSectionId(text)
|
||||
}, [children])
|
||||
|
||||
const copyToClipboard = React.useCallback(() => {
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = id
|
||||
navigator.clipboard.writeText(url.toString())
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [id])
|
||||
|
||||
const headingClasses = React.useMemo(() => {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return "text-4xl md:text-5xl font-bold"
|
||||
case 2:
|
||||
return "text-4xl"
|
||||
case 3:
|
||||
return "text-3xl"
|
||||
case 4:
|
||||
return "text-xl"
|
||||
case 5:
|
||||
return "text-lg font-bold"
|
||||
default:
|
||||
return "text-md font-bold"
|
||||
}
|
||||
}, [level])
|
||||
|
||||
const Component = React.createElement(
|
||||
`h${level}` as keyof JSX.IntrinsicElements,
|
||||
{
|
||||
id,
|
||||
className: `group relative flex items-center gap-2 text-primary ${headingClasses}`,
|
||||
onMouseEnter: () => setShowLink(true),
|
||||
onMouseLeave: () => setShowLink(false),
|
||||
},
|
||||
<>
|
||||
{children}
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className={`ml-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ${
|
||||
copied ? "text-green-500" : "text-tuatara-400 hover:text-tuatara-600"
|
||||
}`}
|
||||
aria-label="Copy link to section"
|
||||
>
|
||||
{copied ? <Icons.copied /> : <Icons.copy />}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return Component
|
||||
}
|
||||
|
||||
const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith("#")) {
|
||||
@@ -485,36 +565,12 @@ const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({
|
||||
</a>
|
||||
)
|
||||
},
|
||||
h1: ({ ...props }) =>
|
||||
createMarkdownElement("h1", {
|
||||
className: "text-primary text-4xl md:text-5xl font-bold",
|
||||
...props,
|
||||
}),
|
||||
h2: ({ ...props }) =>
|
||||
createMarkdownElement("h2", {
|
||||
className: "text-primary text-4xl",
|
||||
...props,
|
||||
}),
|
||||
h3: ({ ...props }) =>
|
||||
createMarkdownElement("h3", {
|
||||
className: "text-primary text-3xl",
|
||||
...props,
|
||||
}),
|
||||
h4: ({ ...props }) =>
|
||||
createMarkdownElement("h4", {
|
||||
className: "text-primary text-xl",
|
||||
...props,
|
||||
}),
|
||||
h5: ({ ...props }) =>
|
||||
createMarkdownElement("h5", {
|
||||
className: "text-primary text-lg font-bold",
|
||||
...props,
|
||||
}),
|
||||
h6: ({ ...props }) =>
|
||||
createMarkdownElement("h6", {
|
||||
className: "text-primary text-md font-bold",
|
||||
...props,
|
||||
}),
|
||||
h1: ({ children }) => <HeadingLink level={1}>{children}</HeadingLink>,
|
||||
h2: ({ children }) => <HeadingLink level={2}>{children}</HeadingLink>,
|
||||
h3: ({ children }) => <HeadingLink level={3}>{children}</HeadingLink>,
|
||||
h4: ({ children }) => <HeadingLink level={4}>{children}</HeadingLink>,
|
||||
h5: ({ children }) => <HeadingLink level={5}>{children}</HeadingLink>,
|
||||
h6: ({ children }) => <HeadingLink level={6}>{children}</HeadingLink>,
|
||||
code: ({ node, inline, className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || "")
|
||||
return !inline ? (
|
||||
@@ -841,6 +897,21 @@ export const Markdown = ({
|
||||
}: MarkdownProps) => {
|
||||
const [content, setContent] = React.useState<React.ReactNode[]>([])
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const hash = window.location.hash
|
||||
if (hash) {
|
||||
const id = hash.slice(1) // Remove the # from the hash
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
scrollToElementWithOffset(element)
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [content])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!children) {
|
||||
setContent([])
|
||||
|
||||
Reference in New Issue
Block a user