feat: auto-link to projects page from blog posts (#383)

This commit is contained in:
Kalidou Diagne
2025-05-08 15:01:41 +03:00
committed by GitHub
parent 67124d12de
commit 5be431f57c
8 changed files with 191 additions and 75 deletions

View File

@@ -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>
)

View File

@@ -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",

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>
)

View File

@@ -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

View File

@@ -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>

View 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,
}
}