feat(blog): add blog pages and API endpoints
- Add /blog index page showing all posts + featured posts - Add /blog/[slug] dynamic route for individual posts - Add /api/blog/featured endpoint - Add HomeBlogSection component for homepage - Add blog utility functions (markdown processing with remark) - Dark theme styling matching site aesthetic - SEO metadata (Open Graph, Twitter Cards, JSON-LD) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
12
apps/web/src/app/api/blog/featured/route.ts
Normal file
12
apps/web/src/app/api/blog/featured/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getFeaturedPosts } from '@/lib/blog'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const posts = await getFeaturedPosts()
|
||||
return NextResponse.json(posts)
|
||||
} catch (error) {
|
||||
console.error('Error fetching featured posts:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch featured posts' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
365
apps/web/src/app/blog/[slug]/page.tsx
Normal file
365
apps/web/src/app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { getPostBySlug, getAllPostSlugs } from '@/lib/blog'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
|
||||
// Generate static params for all blog posts
|
||||
export async function generateStaticParams() {
|
||||
const slugs = getAllPostSlugs()
|
||||
return slugs.map((slug) => ({ slug }))
|
||||
}
|
||||
|
||||
// Generate metadata for SEO
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const post = await getPostBySlug(params.slug)
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://abaci.one'
|
||||
const postUrl = `${siteUrl}/blog/${params.slug}`
|
||||
|
||||
return {
|
||||
title: `${post.title} | Abaci.one Blog`,
|
||||
description: post.description,
|
||||
authors: [{ name: post.author }],
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
url: postUrl,
|
||||
siteName: 'Abaci.one',
|
||||
type: 'article',
|
||||
publishedTime: post.publishedAt,
|
||||
modifiedTime: post.updatedAt,
|
||||
authors: [post.author],
|
||||
tags: post.tags,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
},
|
||||
alternates: {
|
||||
canonical: postUrl,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BlogPost({ params }: Props) {
|
||||
let post
|
||||
try {
|
||||
post = await getPostBySlug(params.slug)
|
||||
} catch {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
const updatedDate = new Date(post.updatedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
const showUpdatedDate = post.publishedAt !== post.updatedAt
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="blog-post-page"
|
||||
className={css({
|
||||
minH: '100vh',
|
||||
bg: 'gray.900',
|
||||
pt: 'var(--app-nav-height-full)',
|
||||
})}
|
||||
>
|
||||
{/* Background pattern */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
opacity: 0.05,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
|
||||
backgroundSize: '40px 40px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
})}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
maxW: '48rem',
|
||||
mx: 'auto',
|
||||
px: { base: '1rem', md: '2rem' },
|
||||
py: { base: '2rem', md: '4rem' },
|
||||
})}
|
||||
>
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/blog"
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
mb: '2rem',
|
||||
color: 'rgba(196, 181, 253, 0.8)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
textDecoration: 'none',
|
||||
transition: 'color 0.2s',
|
||||
_hover: {
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>←</span>
|
||||
<span>Back to Blog</span>
|
||||
</Link>
|
||||
|
||||
{/* Article */}
|
||||
<article data-element="blog-article">
|
||||
<header
|
||||
data-section="article-header"
|
||||
className={css({
|
||||
mb: '3rem',
|
||||
pb: '2rem',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'rgba(75, 85, 99, 0.5)',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2rem', md: '2.5rem', lg: '3rem' },
|
||||
fontWeight: 'bold',
|
||||
lineHeight: '1.2',
|
||||
mb: '1rem',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: '1.125rem', md: '1.25rem' },
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
lineHeight: '1.6',
|
||||
mb: '1.5rem',
|
||||
})}
|
||||
>
|
||||
{post.description}
|
||||
</p>
|
||||
|
||||
<div
|
||||
data-element="article-meta"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '1rem',
|
||||
alignItems: 'center',
|
||||
fontSize: '0.875rem',
|
||||
color: 'rgba(196, 181, 253, 0.8)',
|
||||
})}
|
||||
>
|
||||
<span data-element="author">{post.author}</span>
|
||||
<span>•</span>
|
||||
<time dateTime={post.publishedAt}>{publishedDate}</time>
|
||||
{showUpdatedDate && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>Updated: {updatedDate}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{post.tags.length > 0 && (
|
||||
<div
|
||||
data-element="tags"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
mt: '1rem',
|
||||
})}
|
||||
>
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={css({
|
||||
px: '0.75rem',
|
||||
py: '0.25rem',
|
||||
bg: 'rgba(139, 92, 246, 0.2)',
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Article Content */}
|
||||
<div
|
||||
data-section="article-content"
|
||||
className={css({
|
||||
fontSize: { base: '1rem', md: '1.125rem' },
|
||||
lineHeight: '1.75',
|
||||
color: 'rgba(229, 231, 235, 0.95)',
|
||||
|
||||
// Typography styles for markdown content
|
||||
'& h1': {
|
||||
fontSize: { base: '1.875rem', md: '2.25rem' },
|
||||
fontWeight: 'bold',
|
||||
mt: '2.5rem',
|
||||
mb: '1rem',
|
||||
lineHeight: '1.25',
|
||||
color: 'white',
|
||||
},
|
||||
'& h2': {
|
||||
fontSize: { base: '1.5rem', md: '1.875rem' },
|
||||
fontWeight: 'bold',
|
||||
mt: '2rem',
|
||||
mb: '0.875rem',
|
||||
lineHeight: '1.3',
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
},
|
||||
'& h3': {
|
||||
fontSize: { base: '1.25rem', md: '1.5rem' },
|
||||
fontWeight: '600',
|
||||
mt: '1.75rem',
|
||||
mb: '0.75rem',
|
||||
lineHeight: '1.4',
|
||||
color: 'rgba(196, 181, 253, 0.9)',
|
||||
},
|
||||
'& p': {
|
||||
mb: '1.25rem',
|
||||
},
|
||||
'& strong': {
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
},
|
||||
'& a': {
|
||||
color: 'rgba(147, 197, 253, 1)',
|
||||
textDecoration: 'underline',
|
||||
_hover: {
|
||||
color: 'rgba(59, 130, 246, 1)',
|
||||
},
|
||||
},
|
||||
'& ul, & ol': {
|
||||
pl: '1.5rem',
|
||||
mb: '1.25rem',
|
||||
},
|
||||
'& li': {
|
||||
mb: '0.5rem',
|
||||
},
|
||||
'& code': {
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
px: '0.375rem',
|
||||
py: '0.125rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.875em',
|
||||
fontFamily: 'monospace',
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
border: '1px solid',
|
||||
borderColor: 'rgba(139, 92, 246, 0.3)',
|
||||
},
|
||||
'& pre': {
|
||||
bg: 'rgba(0, 0, 0, 0.5)',
|
||||
border: '1px solid',
|
||||
borderColor: 'rgba(139, 92, 246, 0.3)',
|
||||
color: 'rgba(229, 231, 235, 0.95)',
|
||||
p: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
overflow: 'auto',
|
||||
mb: '1.25rem',
|
||||
},
|
||||
'& pre code': {
|
||||
bg: 'transparent',
|
||||
p: '0',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeft: '4px solid',
|
||||
borderColor: 'rgba(139, 92, 246, 0.5)',
|
||||
pl: '1rem',
|
||||
py: '0.5rem',
|
||||
my: '1.5rem',
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
fontStyle: 'italic',
|
||||
bg: 'rgba(139, 92, 246, 0.05)',
|
||||
borderRadius: '0 0.25rem 0.25rem 0',
|
||||
},
|
||||
'& hr': {
|
||||
my: '2rem',
|
||||
borderColor: 'rgba(75, 85, 99, 0.5)',
|
||||
},
|
||||
'& table': {
|
||||
width: '100%',
|
||||
mb: '1.25rem',
|
||||
borderCollapse: 'collapse',
|
||||
},
|
||||
'& th': {
|
||||
bg: 'rgba(139, 92, 246, 0.2)',
|
||||
px: '1rem',
|
||||
py: '0.75rem',
|
||||
textAlign: 'left',
|
||||
fontWeight: '600',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: 'rgba(139, 92, 246, 0.5)',
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
},
|
||||
'& td': {
|
||||
px: '1rem',
|
||||
py: '0.75rem',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 0.9)',
|
||||
},
|
||||
'& tr:hover td': {
|
||||
bg: 'rgba(139, 92, 246, 0.05)',
|
||||
},
|
||||
})}
|
||||
dangerouslySetInnerHTML={{ __html: post.html }}
|
||||
/>
|
||||
</article>
|
||||
|
||||
{/* JSON-LD Structured Data */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.title,
|
||||
description: post.description,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: post.author,
|
||||
},
|
||||
datePublished: post.publishedAt,
|
||||
dateModified: post.updatedAt,
|
||||
keywords: post.tags.join(', '),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
334
apps/web/src/app/blog/page.tsx
Normal file
334
apps/web/src/app/blog/page.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getAllPostsMetadata, getFeaturedPosts } from '@/lib/blog'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog | Abaci.one',
|
||||
description:
|
||||
'Articles about educational technology, pedagogy, and innovative approaches to learning with the abacus.',
|
||||
openGraph: {
|
||||
title: 'Abaci.one Blog',
|
||||
description:
|
||||
'Articles about educational technology, pedagogy, and innovative approaches to learning with the abacus.',
|
||||
url: `${process.env.NEXT_PUBLIC_SITE_URL || 'https://abaci.one'}/blog`,
|
||||
siteName: 'Abaci.one',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
export default async function BlogIndex() {
|
||||
const featuredPosts = await getFeaturedPosts()
|
||||
const allPosts = await getAllPostsMetadata()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="blog-index-page"
|
||||
className={css({
|
||||
minH: '100vh',
|
||||
bg: 'gray.900',
|
||||
pt: 'var(--app-nav-height-full)',
|
||||
})}
|
||||
>
|
||||
{/* Background pattern */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
opacity: 0.05,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
|
||||
backgroundSize: '40px 40px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
})}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
maxW: '64rem',
|
||||
mx: 'auto',
|
||||
px: { base: '1rem', md: '2rem' },
|
||||
py: { base: '2rem', md: '4rem' },
|
||||
})}
|
||||
>
|
||||
{/* Page Header */}
|
||||
<header
|
||||
data-section="page-header"
|
||||
className={css({
|
||||
mb: '3rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2.5rem', md: '3.5rem' },
|
||||
fontWeight: 'bold',
|
||||
mb: '1rem',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
Blog
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: '1.125rem', md: '1.25rem' },
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
maxW: '42rem',
|
||||
mx: 'auto',
|
||||
})}
|
||||
>
|
||||
Exploring educational technology, pedagogy, and innovative approaches to learning.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Featured Posts */}
|
||||
{featuredPosts.length > 0 && (
|
||||
<section
|
||||
data-section="featured-posts"
|
||||
className={css({
|
||||
mb: '4rem',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '1.5rem', md: '1.875rem' },
|
||||
fontWeight: 'bold',
|
||||
mb: '1.5rem',
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
})}
|
||||
>
|
||||
Featured
|
||||
</h2>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(auto-fit, minmax(300px, 1fr))' },
|
||||
gap: '1.5rem',
|
||||
})}
|
||||
>
|
||||
{featuredPosts.map((post) => {
|
||||
const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/blog/${post.slug}`}
|
||||
data-action="view-featured-post"
|
||||
className={css({
|
||||
display: 'block',
|
||||
p: '1.5rem',
|
||||
bg: 'rgba(139, 92, 246, 0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'rgba(139, 92, 246, 0.3)',
|
||||
transition: 'all 0.3s',
|
||||
_hover: {
|
||||
bg: 'rgba(139, 92, 246, 0.15)',
|
||||
borderColor: 'rgba(139, 92, 246, 0.5)',
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 8px 24px rgba(139, 92, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: '1.25rem', md: '1.5rem' },
|
||||
fontWeight: '600',
|
||||
mb: '0.5rem',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
mb: '1rem',
|
||||
lineHeight: '1.6',
|
||||
})}
|
||||
>
|
||||
{post.excerpt || post.description}
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
fontSize: '0.875rem',
|
||||
color: 'rgba(196, 181, 253, 0.8)',
|
||||
})}
|
||||
>
|
||||
<span>{post.author}</span>
|
||||
<span>•</span>
|
||||
<time dateTime={post.publishedAt}>{publishedDate}</time>
|
||||
</div>
|
||||
{post.tags.length > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
mt: '1rem',
|
||||
})}
|
||||
>
|
||||
{post.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={css({
|
||||
px: '0.5rem',
|
||||
py: '0.125rem',
|
||||
bg: 'rgba(139, 92, 246, 0.2)',
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '500',
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* All Posts */}
|
||||
<section data-section="all-posts">
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '1.5rem', md: '1.875rem' },
|
||||
fontWeight: 'bold',
|
||||
mb: '1.5rem',
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
})}
|
||||
>
|
||||
All Posts
|
||||
</h2>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2rem',
|
||||
})}
|
||||
>
|
||||
{allPosts.map((post) => {
|
||||
const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<article
|
||||
key={post.slug}
|
||||
data-element="post-preview"
|
||||
className={css({
|
||||
pb: '2rem',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'rgba(75, 85, 99, 0.5)',
|
||||
_last: {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
data-action="view-post"
|
||||
className={css({
|
||||
display: 'block',
|
||||
_hover: {
|
||||
'& h3': {
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: '1.5rem', md: '1.875rem' },
|
||||
fontWeight: '600',
|
||||
mb: '0.5rem',
|
||||
color: 'white',
|
||||
transition: 'color 0.2s',
|
||||
})}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
fontSize: '0.875rem',
|
||||
color: 'rgba(196, 181, 253, 0.7)',
|
||||
mb: '1rem',
|
||||
})}
|
||||
>
|
||||
<span>{post.author}</span>
|
||||
<span>•</span>
|
||||
<time dateTime={post.publishedAt}>{publishedDate}</time>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
lineHeight: '1.6',
|
||||
mb: '1rem',
|
||||
})}
|
||||
>
|
||||
{post.excerpt || post.description}
|
||||
</p>
|
||||
|
||||
{post.tags.length > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={css({
|
||||
px: '0.5rem',
|
||||
py: '0.125rem',
|
||||
bg: 'rgba(75, 85, 99, 0.5)',
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '500',
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
apps/web/src/components/HomeBlogSection.tsx
Normal file
213
apps/web/src/components/HomeBlogSection.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BlogPostMetadata } from '@/lib/blog'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
export function HomeBlogSection() {
|
||||
const [featuredPosts, setFeaturedPosts] = useState<BlogPostMetadata[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch featured posts from API
|
||||
async function fetchPosts() {
|
||||
try {
|
||||
const response = await fetch('/api/blog/featured')
|
||||
if (response.ok) {
|
||||
const posts = await response.json()
|
||||
setFeaturedPosts(posts)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch featured blog posts:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchPosts()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return null // Don't show anything while loading
|
||||
}
|
||||
|
||||
if (featuredPosts.length === 0) {
|
||||
return null // Don't show section if no posts
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-section="blog-preview"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8',
|
||||
})}
|
||||
>
|
||||
{/* Section Header */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
mb: '2',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
From the Blog
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
})}
|
||||
>
|
||||
Insights on ed-tech and pedagogy
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Featured Posts List */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4',
|
||||
})}
|
||||
>
|
||||
{featuredPosts.map((post) => {
|
||||
const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/blog/${post.slug}`}
|
||||
className={css({
|
||||
display: 'block',
|
||||
p: '4',
|
||||
bg: 'rgba(139, 92, 246, 0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'rgba(139, 92, 246, 0.3)',
|
||||
transition: 'all 0.3s',
|
||||
_hover: {
|
||||
bg: 'rgba(139, 92, 246, 0.15)',
|
||||
borderColor: 'rgba(139, 92, 246, 0.5)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 24px rgba(139, 92, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
fontWeight: '600',
|
||||
mb: '2',
|
||||
color: 'white',
|
||||
lineHeight: '1.3',
|
||||
})}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
mb: '3',
|
||||
lineHeight: '1.5',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{post.excerpt || post.description}
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '2',
|
||||
alignItems: 'center',
|
||||
fontSize: 'xs',
|
||||
color: 'rgba(196, 181, 253, 0.8)',
|
||||
})}
|
||||
>
|
||||
<span>{post.author}</span>
|
||||
<span>•</span>
|
||||
<time dateTime={post.publishedAt}>{publishedDate}</time>
|
||||
</div>
|
||||
{post.tags.length > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '1.5',
|
||||
mt: '2',
|
||||
})}
|
||||
>
|
||||
{post.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={css({
|
||||
px: '1.5',
|
||||
py: '0.25',
|
||||
bg: 'rgba(139, 92, 246, 0.2)',
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '2xs',
|
||||
fontWeight: '500',
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* View All Link */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href="/blog"
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
px: '4',
|
||||
py: '2',
|
||||
bg: 'rgba(139, 92, 246, 0.2)',
|
||||
color: 'rgba(196, 181, 253, 1)',
|
||||
fontWeight: '600',
|
||||
fontSize: 'sm',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid',
|
||||
borderColor: 'rgba(139, 92, 246, 0.3)',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'rgba(139, 92, 246, 0.3)',
|
||||
borderColor: 'rgba(139, 92, 246, 0.5)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>View All Posts</span>
|
||||
<span>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
110
apps/web/src/lib/blog.ts
Normal file
110
apps/web/src/lib/blog.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import matter from 'gray-matter'
|
||||
import { remark } from 'remark'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkHtml from 'remark-html'
|
||||
|
||||
const postsDirectory = path.join(process.cwd(), 'content', 'blog')
|
||||
|
||||
export interface BlogPost {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
publishedAt: string
|
||||
updatedAt: string
|
||||
tags: string[]
|
||||
featured: boolean
|
||||
content: string
|
||||
html: string
|
||||
}
|
||||
|
||||
export interface BlogPostMetadata extends Omit<BlogPost, 'content' | 'html'> {
|
||||
excerpt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blog post slugs (filenames without .md extension)
|
||||
*/
|
||||
export function getAllPostSlugs(): string[] {
|
||||
try {
|
||||
const fileNames = fs.readdirSync(postsDirectory)
|
||||
return fileNames
|
||||
.filter((fileName) => fileName.endsWith('.md'))
|
||||
.map((fileName) => fileName.replace(/\.md$/, ''))
|
||||
} catch {
|
||||
// Directory doesn't exist yet or is empty
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for all posts (without full content)
|
||||
*/
|
||||
export async function getAllPostsMetadata(): Promise<BlogPostMetadata[]> {
|
||||
const slugs = getAllPostSlugs()
|
||||
const posts = await Promise.all(
|
||||
slugs.map(async (slug) => {
|
||||
const post = await getPostBySlug(slug)
|
||||
const { content, html, ...metadata } = post
|
||||
// Create excerpt from first paragraph
|
||||
const firstPara = content.split('\n\n')[0]
|
||||
const excerpt = `${firstPara.replace(/^#+\s+/, '').substring(0, 200)}...`
|
||||
return { ...metadata, excerpt }
|
||||
})
|
||||
)
|
||||
|
||||
// Sort by published date, newest first
|
||||
return posts.sort((a, b) => {
|
||||
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single post by slug with full content
|
||||
*/
|
||||
export async function getPostBySlug(slug: string): Promise<BlogPost> {
|
||||
const fullPath = path.join(postsDirectory, `${slug}.md`)
|
||||
const fileContents = fs.readFileSync(fullPath, 'utf8')
|
||||
|
||||
// Parse frontmatter
|
||||
const { data, content } = matter(fileContents)
|
||||
|
||||
// Convert markdown to HTML
|
||||
const processedContent = await remark()
|
||||
.use(remarkGfm) // GitHub Flavored Markdown (tables, strikethrough, etc.)
|
||||
.use(remarkHtml, { sanitize: false })
|
||||
.process(content)
|
||||
|
||||
const html = processedContent.toString()
|
||||
|
||||
return {
|
||||
slug,
|
||||
title: data.title || 'Untitled',
|
||||
description: data.description || '',
|
||||
author: data.author || 'Anonymous',
|
||||
publishedAt: data.publishedAt || new Date().toISOString(),
|
||||
updatedAt: data.updatedAt || data.publishedAt || new Date().toISOString(),
|
||||
tags: data.tags || [],
|
||||
featured: data.featured || false,
|
||||
content,
|
||||
html,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured posts for homepage
|
||||
*/
|
||||
export async function getFeaturedPosts(): Promise<BlogPostMetadata[]> {
|
||||
const allPosts = await getAllPostsMetadata()
|
||||
return allPosts.filter((post) => post.featured).slice(0, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get posts by tag
|
||||
*/
|
||||
export async function getPostsByTag(tag: string): Promise<BlogPostMetadata[]> {
|
||||
const allPosts = await getAllPostsMetadata()
|
||||
return allPosts.filter((post) => post.tags.includes(tag))
|
||||
}
|
||||
Reference in New Issue
Block a user