feat: blog page redesign (#372)

* feat: blog page redesign
This commit is contained in:
Kalidou Diagne
2025-05-04 01:13:47 +03:00
committed by GitHub
parent 420a804e2e
commit 0621817de1
14 changed files with 762 additions and 189 deletions

View File

@@ -3,12 +3,17 @@ import { AppContent } from "@/components/ui/app-content"
import { Label } from "@/components/ui/label"
import { Metadata } from "next"
import { Suspense } from "react"
import { Article, getArticles } from "@/lib/blog"
import { BlogArticleCard } from "@/components/blog/blog-article-card"
import Link from "next/link"
import {
HydrationBoundary,
QueryClient,
dehydrate,
} from "@tanstack/react-query"
import ArticlesList from "@/components/blog/ArticlesList"
export const dynamic = "force-dynamic"
export const revalidate = 60 // Revalidate every 60 seconds
export const metadata: Metadata = {
title: "Blog",
description: "",
@@ -19,55 +24,38 @@ interface BlogPageProps {
searchParams?: { [key: string]: string | string[] | undefined }
}
const ArticlesGrid = ({
articles,
lang,
}: {
articles: Article[]
lang: string
}) => {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{articles.length === 0 && (
<p className="col-span-full text-center text-gray-500">
No articles found for this tag.
</p>
)}
{articles.map(
({ id, title, image, tldr = "", date, authors, content }: Article) => {
const url = `/${lang}/blog/${id}`
return (
<div key={id} className="flex h-full">
<Link
className="flex-1 w-full hover:opacity-90 transition-opacity duration-300 rounded-xl overflow-hidden bg-white shadow-sm border border-slate-900/10"
href={url}
rel="noreferrer"
>
<BlogArticleCard
id={id}
image={image}
title={title}
date={date}
authors={authors}
content={content}
/>
</Link>
</div>
)
}
)}
</div>
)
}
const BlogPage = async ({ params: { lang }, searchParams }: BlogPageProps) => {
const { t } = await useTranslation(lang, "blog-page")
const tag = searchParams?.tag as string | undefined
const articles =
getArticles({
tag,
}) ?? []
// Initialize QueryClient at request time
const queryClient = new QueryClient()
// Prefetch the query with a simpler approach
await queryClient.prefetchQuery({
queryKey: ["articles", tag],
queryFn: async () => {
try {
const params = new URLSearchParams()
if (tag) params.append("tag", tag)
// Use a relative URL to avoid environment-specific issues
const response = await fetch(`/api/articles?${params.toString()}`, {
next: { revalidate },
})
if (!response.ok) {
throw new Error(`Failed to fetch articles: ${response.status}`)
}
const data = await response.json()
return data.articles || []
} catch (error) {
console.error("Error fetching articles:", error)
return []
}
},
})
return (
<div className="flex flex-col">
@@ -85,13 +73,15 @@ const BlogPage = async ({ params: { lang }, searchParams }: BlogPageProps) => {
</AppContent>
</div>
<AppContent className="flex flex-col gap-10 py-10">
<AppContent className="flex flex-col gap-10 lg:gap-16 pb-10 lg:py-10 lg:max-w-[978px]">
<Suspense
fallback={
<div className="flex justify-center py-10">Loading articles...</div>
}
>
<ArticlesGrid articles={articles} lang={lang} />
<HydrationBoundary state={dehydrate(queryClient)}>
<ArticlesList lang={lang} tag={tag} />
</HydrationBoundary>
</Suspense>
</AppContent>
</div>

View File

@@ -11,7 +11,9 @@ function BlogSection({ lang }: { lang: string }) {
return (
<Suspense
fallback={
<div className="py-10 lg:py-16">Loading recent articles...</div>
<div className="py-10 lg:py-16 text-center">
Loading recent articles...
</div>
}
>
{/* @ts-expect-error - This is a valid server component pattern */}

View File

@@ -235,6 +235,8 @@ export const ProjectContent = ({
</div>
<ProjectExtraLinks project={project} lang={lang} />
</div>
<ProjectBlogArticles project={project} lang={lang} />
</div>
</div>
{!isResearchProject && (
@@ -261,7 +263,6 @@ export const ProjectContent = ({
</div>
</AppContent>
<ProjectBlogArticles project={project} lang={lang} />
<DiscoverMoreProjects project={project} lang={lang} />
</Divider.Section>
</div>

View File

@@ -1,19 +1,34 @@
import { NextResponse } from "next/server"
import { NextRequest, NextResponse } from "next/server"
import { getArticles } from "@/lib/blog"
export async function GET(request: Request) {
// Cache control
export const revalidate = 60 // Revalidate cache after 60 seconds
export const dynamic = "force-dynamic" // Ensure the route is always evaluated
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const project = searchParams.get("project")
const limit = searchParams.get("limit")
? parseInt(searchParams.get("limit") || "100")
: undefined
const tag = searchParams.get("tag") || undefined
const limit = searchParams.get("limit")
? parseInt(searchParams.get("limit") as string, 10)
: undefined
const project = searchParams.get("project") || undefined
const articles = getArticles({
project: project || undefined,
limit,
tag,
})
try {
const articles = getArticles({
tag,
limit,
project,
})
return NextResponse.json({ articles })
return NextResponse.json({
articles,
success: true,
})
} catch (error) {
console.error("Error fetching articles:", error)
return NextResponse.json(
{ error: "Failed to fetch articles", success: false },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server"
// These should be the same indexes used in the search route
// to ensure consistency
const allIndexes = ["blog", "projects"]
export async function GET() {
try {
return NextResponse.json({
indexes: allIndexes,
status: "success",
})
} catch (error) {
console.error("Error fetching search indexes:", error)
return NextResponse.json(
{ error: "Failed to fetch search indexes" },
{ status: 500 }
)
}
}

View File

@@ -1,8 +1,6 @@
{
"title": "Research",
"subtitle": "Our research model is exploratory, iterative, and full-stack. We work on areas that are often overlooked by academia or industry — foundational concepts that need clarity, implementation gaps that have stalled progress, and risky ideas with uncertain but transformative potential. Sometimes our research unlocks a new breakthrough that leads to prototypes, and sometimes we just clarify assumptions and fill in theoretical gaps.
We believe meaningful progress comes from applying research to real-world needs. Our work is open source, and we maintain a feedback loop so that challenges from the field can help shape our focus. We aim to support the broader ecosystem by systematizing knowledge, benchmarking new primitives, and offering credible guidance to advance the world of cryptography.",
"subtitle": "Our research model is exploratory, iterative, and full-stack. We work on areas that are often overlooked by academia or industry — foundational concepts that need clarity, implementation gaps that have stalled progress, and risky ideas with uncertain but transformative potential. Sometimes our research unlocks a new breakthrough that leads to prototypes, and sometimes we just clarify assumptions and fill in theoretical gaps. We believe meaningful progress comes from applying research to real-world needs. Our work is open source, and we maintain a feedback loop so that challenges from the field can help shape our focus. We aim to support the broader ecosystem by systematizing knowledge, benchmarking new primitives, and offering credible guidance to advance the world of cryptography.",
"activeResearch": "Active Research",
"pastResearch": "Past Research"
}

View File

@@ -1,14 +1,43 @@
import "@/styles/globals.css"
import { Metadata } from "next"
import { Metadata, Viewport } from "next"
import { siteConfig } from "@/config/site"
import { languages } from "./i18n/settings"
import { QueryClientProviderLayout } from "@/components/layouts/QueryProviderLayout"
import { DM_Sans, Inter, Space_Grotesk } from "next/font/google"
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
weight: ["400", "500", "600", "700"],
})
const display = Space_Grotesk({
subsets: ["latin"],
variable: "--font-display",
weight: ["400", "500", "600", "700"],
})
const sans = DM_Sans({
subsets: ["latin"],
variable: "--font-sans",
weight: ["400", "500", "600", "700"],
})
const fonts = [inter, display, sans]
export async function generateStaticParams() {
return languages.map((language) => ({ language }))
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
export const metadata: Metadata = {
metadataBase: new URL("https://pse.dev"),
title: {
@@ -16,12 +45,6 @@ export const metadata: Metadata = {
template: `%s - ${siteConfig.name}`,
},
description: siteConfig.description,
viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
},
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
@@ -44,5 +67,11 @@ interface RootLayoutProps {
}
export default function RootLayout({ children }: RootLayoutProps) {
return <QueryClientProviderLayout>{children}</QueryClientProviderLayout>
return (
<QueryClientProviderLayout>
<html lang="en" className={fonts.map((font) => font.className).join(" ")}>
<body>{children}</body>
</html>
</QueryClientProviderLayout>
)
}

View File

@@ -0,0 +1,215 @@
"use client"
import { useQuery } from "@tanstack/react-query"
import { Article } from "@/lib/blog"
import { ArticleListCard } from "./article-list-card"
import { cn } from "@/lib/utils"
import Link from "next/link"
import { useTranslation } from "react-i18next"
const ArticleInEvidenceCard = ({
article,
size = "lg",
variant = "default",
className,
asLink = false,
titleClassName = "",
contentClassName = "",
showDate = true,
}: {
article: Article
showReadMore?: boolean
size?: "sm" | "lg" | "xl"
variant?: "default" | "compact" | "xl"
className?: string
asLink?: boolean
titleClassName?: string
contentClassName?: string
showDate?: boolean
}) => {
const { t } = useTranslation("blog-page")
const hideTldr = variant === "compact"
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
}
const AsLinkWrapper = ({
children,
href,
asLink,
}: {
children: React.ReactNode
href: string
asLink: boolean
}) => {
return asLink ? (
<Link className="group" href={href}>
{children}
</Link>
) : (
<>{children}</>
)
}
return (
<AsLinkWrapper href={`/blog/${article.id}`} asLink={asLink}>
<div
className={cn(
"min-h-[177px] lg:min-h-[190px] relative flex flex-col gap-5 w-full items-center after:absolute after:inset-0 after:content-[''] after:bg-black after:opacity-20 group-hover:after:opacity-80 transition-opacity duration-300 after:z-[0]",
{
"aspect-video": !className?.includes("h-full"),
},
className
)}
style={{
backgroundImage: `url(${article.image ?? "/fallback.webp"})`,
backgroundSize: "cover",
backgroundPosition: "center centers",
}}
>
<div
className={cn(
"duration-200 flex flex-col gap-[10px] text-left relative z-[1] w-full h-full",
{
"px-5 lg:px-16 py-6 lg:py-16 ": size === "lg",
"px-6 py-4 lg:p-8": size === "sm",
"px-6 lg:p-16": size === "xl",
},
contentClassName
)}
>
{article.date && showDate && (
<span className="text-white text-xs font-sans font-bold tracking-[2.5px] text-left uppercase">
{formatDate(article.date)}
</span>
)}
<Link
href={`/blog/${article.id}`}
className={cn(
" text-white font-display hover:text-anakiwa-400 transition-colors",
{
"text-[20px] font-semibold lg:font-bold lg:text-lg line-clamp-2 mt-auto":
variant === "compact",
"text-[20px] font-semibold lg:font-bold line-clamp-3 mt-auto":
variant === "default",
"text-[20px] font-bold lg:!text-[40px] lg:!leading-[44px] mt-auto":
variant === "xl",
},
titleClassName
)}
>
{article.title}
</Link>
<span className="text-sm text-white/80 uppercase font-inter">
{article.authors?.join(", ")}
</span>
{article.tldr && !hideTldr && (
<span
className={
"text-sm font-sans text-white font-normal line-clamp-2 lg:line-clamp-5 mt-auto hidden lg:block"
}
>
{article.tldr}
</span>
)}
</div>
</div>
</AsLinkWrapper>
)
}
async function fetchArticles(tag?: string) {
try {
const params = new URLSearchParams()
if (tag) params.append("tag", tag)
const response = await fetch(`/api/articles?${params.toString()}`, {
cache: "default",
})
if (!response.ok) {
throw new Error(`Failed to fetch articles: ${response.status}`)
}
const data = await response.json()
return data.articles || []
} catch (error) {
console.error("Error fetching articles:", error)
return []
}
}
interface ArticlesListProps {
lang: string
tag?: string
}
const ArticlesList = ({ lang, tag }: ArticlesListProps) => {
const {
data: articles = [],
isLoading,
isError,
} = useQuery({
queryKey: ["articles", tag],
queryFn: () => fetchArticles(tag),
})
if (isLoading || articles.length === 0) {
return <div className="flex justify-center py-10">Loading articles...</div>
}
if (isError) {
return (
<div className="py-10 text-center">
<p className="text-lg text-red-600">Error loading articles</p>
</div>
)
}
const lastArticle = articles[0]
const featuredArticles = articles.slice(1, 5)
const otherArticles = articles.slice(5)
return (
<div className="flex flex-col gap-10 lg:gap-16">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:gap-6 items-stretch">
<ArticleInEvidenceCard
article={lastArticle}
size="sm"
className="h-full"
asLink
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6 lg:col-span-2 h-full">
{featuredArticles?.map((article: Article) => {
return (
<ArticleInEvidenceCard
key={article.id}
article={article}
variant="compact"
size="sm"
className="h-full"
asLink
/>
)
})}
</div>
</div>
<div className="flex flex-col gap-5 lg:gap-14">
{otherArticles.map((article: Article) => {
return (
<ArticleListCard key={article.id} lang={lang} article={article} />
)
})}
</div>
</div>
)
}
export default ArticlesList

View File

@@ -0,0 +1,139 @@
"use client"
import { Icons } from "../icons"
import { Button } from "../ui/button"
import { Article } from "@/lib/blog"
import { cn } from "@/lib/utils"
import Link from "next/link"
import { useTranslation } from "react-i18next"
interface ArticleInEvidenceCardProps {
article: Article
showReadMore?: boolean
size?: "sm" | "lg" | "xl"
variant?: "default" | "compact" | "xl"
className?: string
asLink?: boolean
titleClassName?: string
contentClassName?: string
showDate?: boolean
}
const AsLinkWrapper = ({
children,
href,
asLink,
}: {
children: React.ReactNode
href: string
asLink: boolean
}) => {
return asLink ? (
<Link className="group" href={href}>
{children}
</Link>
) : (
<>{children}</>
)
}
export const ArticleInEvidenceCard = async ({
article,
showReadMore = false,
size = "lg",
variant = "default",
className,
asLink = false,
titleClassName = "",
contentClassName = "",
showDate = true,
}: ArticleInEvidenceCardProps) => {
const { t } = useTranslation("blog-page")
const hideTldr = variant === "compact"
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
}
return (
<AsLinkWrapper href={`/blog/${article.id}`} asLink={asLink}>
<div
className={cn(
"min-h-[177px] lg:min-h-[190px] relative flex flex-col gap-5 w-full items-center after:absolute after:inset-0 after:content-[''] after:bg-black after:opacity-20 group-hover:after:opacity-80 transition-opacity duration-300 after:z-[0]",
{
"aspect-video": !className?.includes("h-full"),
},
className
)}
style={{
backgroundImage: `url(${article.image ?? "/fallback.webp"})`,
backgroundSize: "cover",
backgroundPosition: "center centers",
}}
>
<div
className={cn(
"duration-200 flex flex-col gap-[10px] text-left relative z-[1] w-full h-full",
{
"px-5 lg:px-16 py-6 lg:py-16 ": size === "lg",
"px-6 py-4 lg:p-8": size === "sm",
"px-6 lg:p-16": size === "xl",
},
contentClassName
)}
>
{article.date && showDate && (
<span className="text-white text-xs font-sans font-bold tracking-[2.5px] text-left uppercase">
{formatDate(article.date)}
</span>
)}
<Link
href={`/blog/${article.id}`}
className={cn(
" text-white font-display hover:text-anakiwa-400 transition-colors",
{
"text-[20px] font-semibold lg:font-bold lg:text-lg line-clamp-2 mt-auto":
variant === "compact",
"text-[20px] font-semibold lg:font-bold line-clamp-3 mt-auto":
variant === "default",
"text-[20px] font-bold lg:!text-[40px] lg:!leading-[44px] mt-auto":
variant === "xl",
},
titleClassName
)}
>
{article.title}
</Link>
<span className="text-sm text-white/80 uppercase font-inter">
{article.authors?.join(", ")}
</span>
{article.tldr && !hideTldr && (
<span
className={
"text-sm font-sans text-white font-normal line-clamp-2 lg:line-clamp-5 mt-auto hidden lg:block"
}
>
{article.tldr}
</span>
)}
{showReadMore && (
<Link href={`/blog/${article.id}`} className="ml-auto">
<Button className="uppercase ml-auto mt-4" variant="secondary">
<div className="flex items-center gap-2">
<span className="!text-center">{t("readMore")}</span>
<Icons.arrowRight className="w-4 h-4" />
</div>
</Button>
</Link>
)}
</div>
</div>
</AsLinkWrapper>
)
}

View File

@@ -0,0 +1,97 @@
"use client"
import Link from "next/link"
import { Markdown } from "../ui/markdown"
import { Article } from "@/lib/blog"
export const ArticleListCard = ({
lang,
article,
}: {
lang: string
article: Article
}) => {
const url = `/${lang}/blog/${article.id}`
const formattedDate = new Date(article.date).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
return (
<div className="flex h-full">
<Link className="flex-1 w-full group" href={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"
style={{
backgroundImage: `url(${article.image})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
></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">
{article.title}
</span>
<span className="lg:uppercase text-tuatara-400 lg:text-sm text-[10px] leading-none font-inter">
{article.authors?.map((author) => author).join(", ")}
</span>
<div className="hidden lg:block">
<Markdown
components={{
h1: ({ children }) => (
<h1 className="font-sans text-sm text-tuatara-600 group-hover:text-tuatara-950 duration-200">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="font-sans text-sm text-tuatara-600 group-hover:text-tuatara-950 duration-200">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="font-sans text-sm text-tuatara-600 group-hover:text-tuatara-950 duration-200">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="font-sans text-sm text-tuatara-600 group-hover:text-tuatara-950 duration-200">
{children}
</h4>
),
h5: ({ children }) => (
<h5 className="font-sans text-sm text-tuatara-600 group-hover:text-tuatara-950 duration-200">
{children}
</h5>
),
h6: ({ children }) => (
<h6 className="font-sans text-sm text-tuatara-600 group-hover:text-tuatara-950 duration-200">
{children}
</h6>
),
p: ({ children }) => (
<p className="font-sans text-sm text-tuatara-600 group-hover:text-tuatara-950 duration-200">
{children}
</p>
),
img: ({ src, alt }) => null,
}}
>
{article.tldr || ""}
</Markdown>
</div>
</div>
<span
className="order-1 lg:order-2 text-[10px] font-bold tracking-[2.1px] text-tuatara-400 font-sans
uppercase"
>
{formattedDate}
</span>
</div>
</div>
</Link>
</div>
)
}

View File

@@ -1,11 +1,138 @@
import { useTranslation } from "@/app/i18n"
import { AppContent } from "../ui/app-content"
import { getArticles } from "@/lib/blog"
import { getArticles, Article } from "@/lib/blog"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { Button } from "../ui/button"
import { Icons } from "../icons"
const ArticleInEvidenceCard = ({
article,
showReadMore = false,
size = "lg",
variant = "default",
className,
asLink = false,
titleClassName = "",
contentClassName = "",
showDate = true,
}: {
article: Article
showReadMore?: boolean
size?: "sm" | "lg" | "xl"
variant?: "default" | "compact" | "xl"
className?: string
asLink?: boolean
titleClassName?: string
contentClassName?: string
showDate?: boolean
}) => {
const hideTldr = variant === "compact"
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
}
const AsLinkWrapper = ({
children,
href,
asLink,
}: {
children: React.ReactNode
href: string
asLink: boolean
}) => {
return asLink ? (
<Link className="group" href={href}>
{children}
</Link>
) : (
<>{children}</>
)
}
return (
<AsLinkWrapper href={`/blog/${article.id}`} asLink={asLink}>
<div
className={cn(
"min-h-[177px] lg:min-h-[190px] relative flex flex-col gap-5 w-full items-center after:absolute after:inset-0 after:content-[''] after:bg-black after:opacity-20 group-hover:after:opacity-80 transition-opacity duration-300 after:z-[0]",
{
"aspect-video": !className?.includes("h-full"),
},
className
)}
style={{
backgroundImage: `url(${article.image ?? "/fallback.webp"})`,
backgroundSize: "cover",
backgroundPosition: "center centers",
}}
>
<div
className={cn(
"duration-200 flex flex-col gap-[10px] text-left relative z-[1] w-full h-full",
{
"px-5 lg:px-16 py-6 lg:py-16 ": size === "lg",
"px-6 py-4 lg:p-8": size === "sm",
"px-6 lg:p-16": size === "xl",
},
contentClassName
)}
>
{article.date && showDate && (
<span className="text-white text-xs font-sans font-bold tracking-[2.5px] text-left uppercase">
{formatDate(article.date)}
</span>
)}
<Link
href={`/blog/${article.id}`}
className={cn(
" text-white font-display hover:text-anakiwa-400 transition-colors",
{
"text-[20px] font-semibold lg:font-bold lg:text-lg line-clamp-2 mt-auto":
variant === "compact",
"text-[20px] font-semibold lg:font-bold line-clamp-3 mt-auto":
variant === "default",
"text-[20px] font-bold lg:!text-[40px] lg:!leading-[44px] mt-auto":
variant === "xl",
},
titleClassName
)}
>
{article.title}
</Link>
<span className="text-sm text-white/80 uppercase font-inter">
{article.authors?.join(", ")}
</span>
{article.tldr && !hideTldr && (
<span
className={
"text-sm font-sans text-white font-normal line-clamp-2 lg:line-clamp-5 mt-auto hidden lg:block"
}
>
{article.tldr}
</span>
)}
{showReadMore && (
<Link href={`/blog/${article.id}`} className="ml-auto">
<Button className="uppercase ml-auto mt-4" variant="secondary">
<div className="flex items-center gap-2">
<span className="!text-center">Read More</span>
<Icons.arrowRight className="w-4 h-4" />
</div>
</Button>
</Link>
)}
</div>
</div>
</AsLinkWrapper>
)
}
export async function BlogRecentArticles({ lang }: { lang: any }) {
const articles = getArticles({ limit: 4 })
const { t } = await useTranslation(lang, "blog-page")
@@ -22,42 +149,13 @@ export async function BlogRecentArticles({ lang }: { lang: any }) {
</h3>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-10 lg:gap-x-14 lg:max-w-[1200px] mx-auto relative">
<div className="inset-0 relative lg:col-span-3">
<div
className="flex flex-col gap-5 w-full items-center aspect-video after:absolute after:inset-0 after:content-[''] after:bg-black after:opacity-20 group-hover:after:opacity-50 transition-opacity duration-200 after:z-[0]"
style={{
backgroundImage: `url(${lastArticle.image ?? "/fallback.webp"})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
<div className="duration-200 flex flex-col gap-[10px] text-left px-5 lg:px-16 py-6 lg:py-16 relative z-[1] w-full">
<Link
href={`/blog/${lastArticle.id}`}
className="text-4xl font-bold text-white font-display hover:text-anakiwa-400 transition-colors"
>
{lastArticle.title}
</Link>
<span className="text-sm font-sans text-white/80 uppercase">
{lastArticle.authors?.join(", ")}
</span>
{lastArticle.tldr && (
<span className="text-base font-sans text-white/80 font-normal line-clamp-2 lg:line-clamp-3">
{lastArticle.tldr}
</span>
)}
<Link href={`/blog/${lastArticle.id}`} className="ml-auto">
<Button
className="uppercase ml-auto mt-4"
variant="secondary"
>
<div className="flex items-center gap-2">
<span className="!text-center">{t("readMore")}</span>
<Icons.arrowRight className="w-4 h-4" />
</div>
</Button>
</Link>
</div>
</div>
<ArticleInEvidenceCard
article={lastArticle}
showReadMore
showDate={false}
variant="xl"
size="xl"
/>
</div>
<div className="flex flex-col gap-6 lg:col-span-2">

View File

@@ -4,9 +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 Link from "next/link"
import { BlogArticleCard } from "./blog-article-card"
import { useEffect, useState } from "react"
import { ArticleListCard } from "./article-list-card"
async function fetchArticles(project: string) {
const response = await fetch(`/api/articles?project=${project}`)
@@ -14,47 +13,6 @@ async function fetchArticles(project: string) {
return data.articles
}
function ArticlesGrid({
articles,
lang,
}: {
articles: Article[]
lang: string
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{articles.length === 0 && (
<p className="col-span-full text-center text-gray-500">
No articles found for this project.
</p>
)}
{articles.map(
({ id, title, image, tldr = "", date, authors, content }: Article) => {
const url = `/${lang}/blog/${id}`
return (
<div key={id} className="flex h-full">
<Link
className="flex-1 w-full hover:opacity-90 transition-opacity duration-300 rounded-xl overflow-hidden bg-white shadow-sm border border-slate-900/10"
href={url}
rel="noreferrer"
>
<BlogArticleCard
id={id}
image={image}
title={title}
date={date}
authors={authors}
content={content}
/>
</Link>
</div>
)
}
)}
</div>
)
}
export const ProjectBlogArticles = ({
project,
lang,
@@ -86,10 +44,10 @@ export const ProjectBlogArticles = ({
<div 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-center uppercase tracking-[3.36px]">
<h3 className="text-base font-bold font-sans text-left uppercase tracking-[3.36px]">
Related articles
</h3>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<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>
@@ -108,14 +66,23 @@ export const ProjectBlogArticles = ({
return (
<div 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-center uppercase tracking-[3.36px]">
Related articles
</h3>
<ArticlesGrid articles={articles} lang={lang} />
<div className="flex flex-col gap-10">
<h3 className="text-[22px] font-bold text-tuatara-700">
Related articles
</h3>
<div className="grid grid-cols-1 gap-4 lg:gap-8">
{articles.length === 0 && (
<p className="col-span-full text-center text-gray-500">
No articles found for this project.
</p>
)}
{articles.map((article: Article) => {
return (
<ArticleListCard key={article.id} lang={lang} article={article} />
)
})}
</div>
</AppContent>
</div>
</div>
)
}

View File

@@ -84,25 +84,18 @@ function Hit({
{snippet && (
<Markdown
components={{
h1: ({ children }) => (
<h1 className="text-base font-semibold">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-base font-semibold">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-semibold">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-base font-semibold">{children}</h4>
),
h5: ({ children }) => (
<h5 className="text-base font-semibold">{children}</h5>
),
h6: ({ children }) => (
<h6 className="text-base font-semibold">{children}</h6>
),
h1: ({ children }) => null,
h2: ({ children }) => null,
h3: ({ children }) => null,
h4: ({ children }) => null,
h5: ({ children }) => null,
h6: ({ children }) => null,
img: ({ src, alt }) => null,
a: ({ href, children }) => (
<span className="text-tuatara-700 font-sans text-base font-normal">
{children}
</span>
),
}}
>
{snippet}
@@ -316,9 +309,20 @@ const MultiIndexSearchView = ({
return <DirectSearchResults query={searchQuery} setOpen={setOpen} />
}
// Sort indexes to ensure projects appear first, followed by blog
const sortedIndexes = [...visibleIndexes].sort((a, b) => {
if (a.toLowerCase().includes("project")) return -1
if (b.toLowerCase().includes("project")) return 1
if (a.toLowerCase().includes("blog")) return 1
if (b.toLowerCase().includes("blog")) return -1
return a.localeCompare(b)
})
return (
<div>
{visibleIndexes.map((indexName: string) => (
{sortedIndexes.map((indexName: string) => (
<div key={indexName} className="mb-6">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-2">
{indexName.replace(/-/g, " ")}

View File

@@ -1,6 +1,3 @@
// eslint-disable-next-line no-undef, @typescript-eslint/no-require-imports
const { fontFamily } = require("tailwindcss/defaultTheme")
/** @type {import('tailwindcss').Config} */
// eslint-disable-next-line no-undef
module.exports = {
@@ -102,8 +99,9 @@ module.exports = {
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
display: ["var(--font-display)", "Space Grotesk"],
sans: ["var(--font-sans)"],
display: ["var(--font-display)"],
inter: ["var(--font-inter)"],
},
keyframes: {
"accordion-down": {