mirror of
https://github.com/privacy-scaling-explorations/pse.dev.git
synced 2026-01-10 22:58:06 -05:00
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
20
app/api/search/indexes/route.ts
Normal file
20
app/api/search/indexes/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
215
components/blog/ArticlesList.tsx
Normal file
215
components/blog/ArticlesList.tsx
Normal 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
|
||||
139
components/blog/article-in-evidance-card.tsx
Normal file
139
components/blog/article-in-evidance-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
components/blog/article-list-card.tsx
Normal file
97
components/blog/article-list-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, " ")}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user