mirror of
https://github.com/privacy-scaling-explorations/pse.dev.git
synced 2026-01-09 14:18:02 -05:00
feat: auto-link to projects page from blog posts (#383)
This commit is contained in:
@@ -150,7 +150,7 @@ export default function BlogArticle({ params }: any) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-10 md:pt-16 pb-32">
|
||||
<BlogContent post={post} />
|
||||
<BlogContent post={post} lang={params.lang} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ image: "/articles/reflecting-on-maci-platform/reflecting-on-maci-platform.png"
|
||||
tldr: "After a year of development and experimentation, the MACI Platform project is being sunset. In this retrospective, we share what we built, what we learned, and how the work can continue."
|
||||
date: "2025-05-01"
|
||||
canonical: ""
|
||||
projects: ["maci"]
|
||||
tags:
|
||||
[
|
||||
"MACI",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import Link from "next/link"
|
||||
import { Markdown } from "../ui/markdown"
|
||||
import { Article } from "@/lib/blog"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export const ArticleListCard = ({
|
||||
lang,
|
||||
@@ -18,9 +19,17 @@ export const ArticleListCard = ({
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<Link className="flex-1 w-full group" href={url} rel="noreferrer">
|
||||
<div
|
||||
className="flex-1 w-full group cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push(url)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="grid grid-cols-[80px_1fr] lg:grid-cols-[120px_1fr] items-center gap-4 lg:gap-10">
|
||||
<div
|
||||
className="size-[80px] lg:size-[120px] rounded-full bg-slate-200"
|
||||
@@ -32,7 +41,7 @@ export const ArticleListCard = ({
|
||||
></div>
|
||||
<div className="flex flex-col gap-2 lg:gap-5">
|
||||
<div className="flex flex-col gap-2 order-2 lg:order-1">
|
||||
<span className="text-xs font-display lg:text-[22px] font-bold text-tuatara-950 group-hover:text-anakiwa-500 duration-200 leading-none">
|
||||
<span className="text-xs font-display lg:text-[22px] font-bold text-tuatara-950 group-hover:text-anakiwa-500 group-hover:underline duration-200 leading-none">
|
||||
{article.title}
|
||||
</span>
|
||||
<span className="lg:uppercase text-tuatara-400 lg:text-sm text-[10px] leading-none font-inter">
|
||||
@@ -91,7 +100,7 @@ export const ArticleListCard = ({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
43
components/blog/blog-article-related-projects.tsx
Normal file
43
components/blog/blog-article-related-projects.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { LocaleTypes } from "@/app/i18n/settings"
|
||||
import { useProjectFiltersState } from "@/state/useProjectFiltersState"
|
||||
import ProjectCard from "../project/project-card"
|
||||
|
||||
interface BlogArticleRelatedProjectsProps {
|
||||
projectsIds: string[]
|
||||
lang: LocaleTypes
|
||||
}
|
||||
|
||||
export const BlogArticleRelatedProjects = ({
|
||||
projectsIds,
|
||||
lang,
|
||||
}: BlogArticleRelatedProjectsProps) => {
|
||||
const { projects: allProjects } = useProjectFiltersState((state) => state)
|
||||
|
||||
const projects = allProjects.filter((project) =>
|
||||
projectsIds.includes(project.id)
|
||||
)
|
||||
|
||||
if (projects.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<h3 className="text-tuatara-950 text-lg font-semibold leading-6">
|
||||
Related projects
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-x-6 md:gap-y-10 lg:grid-cols-3">
|
||||
{projects?.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
lang={lang}
|
||||
showBanner
|
||||
border
|
||||
showLinks={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,12 @@ import Link from "next/link"
|
||||
import { AppContent } from "../ui/app-content"
|
||||
import { Markdown } from "../ui/markdown"
|
||||
import { BlogArticleCard } from "./blog-article-card"
|
||||
import { BlogArticleRelatedProjects } from "./blog-article-related-projects"
|
||||
import { LocaleTypes } from "@/app/i18n/settings"
|
||||
|
||||
interface BlogContentProps {
|
||||
post: Article
|
||||
lang: LocaleTypes
|
||||
}
|
||||
|
||||
interface BlogImageProps {
|
||||
@@ -31,7 +35,7 @@ export function BlogImage({ image, alt, description }: BlogImageProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function BlogContent({ post }: BlogContentProps) {
|
||||
export function BlogContent({ post, lang }: BlogContentProps) {
|
||||
const articles = getArticles() ?? []
|
||||
const articleIndex = articles.findIndex((article) => article.id === post.id)
|
||||
|
||||
@@ -101,6 +105,11 @@ export function BlogContent({ post }: BlogContentProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BlogArticleRelatedProjects
|
||||
projectsIds={post.projects ?? []}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
</AppContent>
|
||||
)
|
||||
|
||||
@@ -4,14 +4,8 @@ import { LocaleTypes } from "@/app/i18n/settings"
|
||||
import { ProjectInterface } from "@/lib/types"
|
||||
import { AppContent } from "../ui/app-content"
|
||||
import { Article } from "@/lib/blog"
|
||||
import { useEffect, useState } from "react"
|
||||
import { ArticleListCard } from "./article-list-card"
|
||||
|
||||
async function fetchArticles(project: string) {
|
||||
const response = await fetch(`/api/articles?project=${project}`)
|
||||
const data = await response.json()
|
||||
return data.articles
|
||||
}
|
||||
import { useGetProjectRelatedArticles } from "@/hooks/useGetProjectRelatedArticles"
|
||||
|
||||
export const ProjectBlogArticles = ({
|
||||
project,
|
||||
@@ -20,28 +14,17 @@ export const ProjectBlogArticles = ({
|
||||
project: ProjectInterface
|
||||
lang: LocaleTypes
|
||||
}) => {
|
||||
const [articles, setArticles] = useState<Article[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { articles, loading } = useGetProjectRelatedArticles({
|
||||
projectId: project.id,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const getArticles = async () => {
|
||||
try {
|
||||
const data = await fetchArticles(project.id)
|
||||
setArticles(data)
|
||||
} catch (error) {
|
||||
console.error("Error fetching articles:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
getArticles()
|
||||
}, [project.id])
|
||||
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-10 lg:py-16 w-full">
|
||||
<div
|
||||
id="related-articles"
|
||||
data-section-id="related-articles"
|
||||
className="py-10 lg:py-16 w-full"
|
||||
>
|
||||
<AppContent>
|
||||
<div className="flex flex-col gap-10">
|
||||
<h3 className="text-base font-bold font-sans text-left uppercase tracking-[3.36px]">
|
||||
@@ -49,8 +32,17 @@ export const ProjectBlogArticles = ({
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex h-full animate-pulse">
|
||||
<div className="flex-1 w-full rounded-xl overflow-hidden bg-slate-200 h-64"></div>
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-[80px_1fr] lg:grid-cols-[120px_1fr] items-center gap-4 lg:gap-10"
|
||||
>
|
||||
<div className="size-[80px] lg:size-[120px] rounded-full bg-slate-200 animate-pulse"></div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-5 w-full bg-slate-200 animate-pulse"></div>
|
||||
<div className="h-4 w-full bg-slate-200 animate-pulse"></div>
|
||||
<div className="h-4 w-2/3 bg-slate-200 animate-pulse "></div>
|
||||
<div className="h-2 w-14 bg-slate-200 animate-pulse "></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -65,7 +57,11 @@ export const ProjectBlogArticles = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-10 lg:py-16 w-full">
|
||||
<div
|
||||
id="related-articles"
|
||||
data-section-id="related-articles"
|
||||
className="py-10 lg:py-16 w-full"
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
<h3 className="text-[22px] font-bold text-tuatara-700">
|
||||
Related articles
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils"
|
||||
import { useTranslation } from "@/app/i18n/client"
|
||||
|
||||
import { Icons } from "./icons"
|
||||
import { useGetProjectRelatedArticles } from "@/hooks/useGetProjectRelatedArticles"
|
||||
|
||||
interface Section {
|
||||
level: number
|
||||
@@ -21,6 +22,33 @@ interface WikiSideNavigationProps {
|
||||
project?: any
|
||||
}
|
||||
|
||||
const SideNavigationItem = ({
|
||||
text,
|
||||
id,
|
||||
activeSection,
|
||||
onClick,
|
||||
}: {
|
||||
text: string
|
||||
id: string
|
||||
activeSection: string | null
|
||||
onClick: () => void
|
||||
}) => {
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
className={cn(
|
||||
"flex min-h-8 items-center border-l-2 border-l-anakiwa-200 px-2 duration-200 cursor-pointer w-full",
|
||||
{
|
||||
"border-l-anakiwa-500 text-anakiwa-500 font-medium":
|
||||
activeSection === id,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button onClick={onClick}>{text}</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export const WikiSideNavigation = ({
|
||||
className,
|
||||
lang = "en",
|
||||
@@ -32,7 +60,10 @@ export const WikiSideNavigation = ({
|
||||
const [activeSection, setActiveSection] = useState<string | null>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
|
||||
// Extract sections from content
|
||||
const { articles, loading } = useGetProjectRelatedArticles({
|
||||
projectId: project.id,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!content) return
|
||||
const sectionsRegex = /^(#{1,3})\s(.+)/gm
|
||||
@@ -51,7 +82,6 @@ export const WikiSideNavigation = ({
|
||||
}
|
||||
|
||||
setSections(extractedSections)
|
||||
// Set the first section as active by default
|
||||
if (extractedSections.length > 0) {
|
||||
setActiveSection(extractedSections[0].id)
|
||||
}
|
||||
@@ -85,7 +115,7 @@ export const WikiSideNavigation = ({
|
||||
observerRef.current.disconnect()
|
||||
}
|
||||
}
|
||||
}, [sections])
|
||||
}, [sections, loading])
|
||||
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
const element = document.querySelector(`[data-section-id="${sectionId}"]`)
|
||||
@@ -128,6 +158,8 @@ export const WikiSideNavigation = ({
|
||||
|
||||
const { extraLinks = {} } = project
|
||||
|
||||
const hasRelatedArticles = articles.length > 0 && !loading
|
||||
|
||||
if (sections.length === 0 || content.length === 0) return null
|
||||
|
||||
return (
|
||||
@@ -138,23 +170,13 @@ export const WikiSideNavigation = ({
|
||||
</h6>
|
||||
<ul className="pt-4 font-sans text-black text-normal">
|
||||
{sections.map((section, index) => (
|
||||
<li
|
||||
<SideNavigationItem
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex h-8 items-center border-l-2 border-l-anakiwa-200 px-3 duration-200 cursor-pointer ",
|
||||
{
|
||||
"border-l-anakiwa-500 text-anakiwa-500 font-medium":
|
||||
activeSection === section.id,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => scrollToSection(section.id)}
|
||||
className="w-full overflow-hidden text-left line-clamp-1"
|
||||
>
|
||||
{section.text}
|
||||
</button>
|
||||
</li>
|
||||
activeSection={activeSection}
|
||||
text={section.text}
|
||||
id={section.id}
|
||||
onClick={() => scrollToSection(section.id)}
|
||||
/>
|
||||
))}
|
||||
{Object.entries(ExtraLinkLabelMapping).map(([key]) => {
|
||||
const links = extraLinks[key as ProjectExtraLinkType] ?? []
|
||||
@@ -162,34 +184,31 @@ export const WikiSideNavigation = ({
|
||||
const { label } = ExtraLinkLabelMapping?.[key as any] ?? {}
|
||||
if (!links.length) return null // no links hide the section
|
||||
return (
|
||||
<li
|
||||
<SideNavigationItem
|
||||
key={key}
|
||||
onClick={() => scrollToSection(key)}
|
||||
className={cn(
|
||||
"flex h-8 items-center border-l-2 border-l-anakiwa-200 px-3 duration-200 cursor-pointer",
|
||||
{
|
||||
"border-l-anakiwa-500 text-anakiwa-500 font-medium":
|
||||
activeSection === key,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</li>
|
||||
activeSection={activeSection}
|
||||
text={label}
|
||||
id={key}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<li
|
||||
{hasRelatedArticles && (
|
||||
<SideNavigationItem
|
||||
key="related-articles"
|
||||
onClick={() => scrollToSection("related-articles")}
|
||||
activeSection={activeSection}
|
||||
text="Related articles"
|
||||
id="related-articles"
|
||||
/>
|
||||
)}
|
||||
<SideNavigationItem
|
||||
key="edit"
|
||||
onClick={() => scrollToSection("edit-this-page")}
|
||||
className={cn(
|
||||
"flex h-8 items-center border-l-2 border-l-anakiwa-200 px-3 duration-200 cursor-pointer",
|
||||
{
|
||||
"border-l-anakiwa-500 text-anakiwa-500 font-medium":
|
||||
activeSection === "edit-this-page",
|
||||
}
|
||||
)}
|
||||
>
|
||||
Edit this page
|
||||
</li>
|
||||
activeSection={activeSection}
|
||||
text="Edit this page"
|
||||
id="edit-this-page"
|
||||
/>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
39
hooks/useGetProjectRelatedArticles.ts
Normal file
39
hooks/useGetProjectRelatedArticles.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Article } from "@/lib/blog"
|
||||
|
||||
interface UseGetProjectRelatedArticlesProps {
|
||||
projectId: string
|
||||
}
|
||||
|
||||
async function fetchArticles(project: string) {
|
||||
const response = await fetch(`/api/articles?project=${project}`)
|
||||
const data = await response.json()
|
||||
return data.articles
|
||||
}
|
||||
|
||||
export function useGetProjectRelatedArticles({
|
||||
projectId,
|
||||
}: UseGetProjectRelatedArticlesProps) {
|
||||
const [articles, setArticles] = useState<Article[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const getArticles = async () => {
|
||||
try {
|
||||
const data = await fetchArticles(projectId)
|
||||
setArticles(data)
|
||||
} catch (error) {
|
||||
console.error("Error fetching articles:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
getArticles()
|
||||
}, [projectId])
|
||||
|
||||
return {
|
||||
articles,
|
||||
loading,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user