feat(blog): add /blog routes with dedicated editorial layout
This commit is contained in:
88
app/blog/[slug]/page.tsx
Normal file
88
app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getPosts, getPost } from '@/lib/posts'
|
||||||
|
import { tagToSlug } from '@/lib/tags'
|
||||||
|
import { TableOfContents } from '@/components/posts/TableOfContents'
|
||||||
|
import { ScrollToTop } from '@/components/ui/ScrollToTop'
|
||||||
|
import { ReadingProgress } from '@/components/ui/ReadingProgress'
|
||||||
|
|
||||||
|
export const dynamicParams = false
|
||||||
|
export const dynamic = 'force-static'
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const posts = await getPosts()
|
||||||
|
if (posts.length === 0) {
|
||||||
|
return [{ slug: '__no-posts__' }]
|
||||||
|
}
|
||||||
|
return posts.map((post) => ({ slug: post.slug }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const slug = (await params).slug
|
||||||
|
const post = await getPost(slug)
|
||||||
|
if (!post) return { title: 'Not Found' }
|
||||||
|
return { title: post.title }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const slug = (await params).slug
|
||||||
|
|
||||||
|
if (slug === '__no-posts__') notFound()
|
||||||
|
|
||||||
|
const post = await getPost(slug)
|
||||||
|
|
||||||
|
if (!post) notFound()
|
||||||
|
|
||||||
|
const { default: PostContent } = await import(
|
||||||
|
/* turbopackOptional: true */
|
||||||
|
`@/content/posts/${slug}.mdx`
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollToTop />
|
||||||
|
<ReadingProgress />
|
||||||
|
<div className="mx-auto max-w-6xl px-6 py-16">
|
||||||
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_14rem] lg:gap-12">
|
||||||
|
<article className="min-w-0">
|
||||||
|
<header className="mb-12">
|
||||||
|
<time className="font-mono text-sm text-ink-soft" dateTime={post.date}>{new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</time>
|
||||||
|
<h1 className="heading-xl text-ink mt-3 mb-2">
|
||||||
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-4 font-mono text-xs text-ink-soft">
|
||||||
|
{post.author && <span>by {post.author}</span>}
|
||||||
|
<span>{post.readingTime} min read</span>
|
||||||
|
</div>
|
||||||
|
{post.coverImage && (
|
||||||
|
<img
|
||||||
|
src={post.coverImage}
|
||||||
|
alt={`Featured image for article: ${post.title}`}
|
||||||
|
className="my-8 aspect-[16/9] w-full rounded-2xl border border-border bg-surface object-cover shadow-card"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
fetchPriority="high"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{post.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-4">
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<Link key={tag} href={`/blog/tags/${tagToSlug(tag)}/`} className="rounded-full bg-surface px-3 py-1 text-xs font-medium text-ink-soft border border-border transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
|
||||||
|
{tag}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="prose prose-lg article-prose max-w-none min-w-0">
|
||||||
|
<PostContent />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<TableOfContents />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
app/blog/layout.tsx
Normal file
16
app/blog/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Header } from '@/components/layout/Header'
|
||||||
|
import { Footer } from '@/components/layout/Footer'
|
||||||
|
import { ScrollProgress } from '@/components/ui/ScrollProgress'
|
||||||
|
|
||||||
|
export default function BlogLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollProgress />
|
||||||
|
<Header />
|
||||||
|
<main className="mx-auto max-w-5xl px-6 py-12 sm:py-16">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
app/blog/page.tsx
Normal file
30
app/blog/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { getPosts } from '@/lib/posts'
|
||||||
|
import { PostSearch } from '@/components/posts/PostSearch'
|
||||||
|
|
||||||
|
export const metadata = { title: 'Field Notes' }
|
||||||
|
|
||||||
|
export default async function PostsPage() {
|
||||||
|
const posts = await getPosts()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="mb-10 border-b border-border pb-8">
|
||||||
|
<h1 className="heading-xl m-0 text-ink">
|
||||||
|
Posts
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{posts.length > 0 ? (
|
||||||
|
<PostSearch posts={posts} />
|
||||||
|
) : (
|
||||||
|
<section className="empty-state" aria-label="No posts published">
|
||||||
|
<p className="font-mono text-xs uppercase tracking-[0.22em] text-ink-soft">No posts yet</p>
|
||||||
|
<h2 className="heading-sm m-0 text-ink">The archive is empty.</h2>
|
||||||
|
<p className="m-0 max-w-xl text-sm leading-6 text-ink-soft">
|
||||||
|
Publish an MDX post to populate this list and enable archive search.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
app/blog/tags/[tag]/page.tsx
Normal file
61
app/blog/tags/[tag]/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { PostCard } from '@/components/posts/PostCard'
|
||||||
|
import { getAllTags, getPostsByTag } from '@/lib/posts'
|
||||||
|
|
||||||
|
type TagPageProps = {
|
||||||
|
params: Promise<{ tag: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamicParams = false
|
||||||
|
export const dynamic = 'force-static'
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const tags = await getAllTags()
|
||||||
|
if (tags.length === 0) {
|
||||||
|
return [{ tag: '__no-tags__' }]
|
||||||
|
}
|
||||||
|
return tags.map((tag) => ({ tag: tag.slug }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: TagPageProps): Promise<Metadata> {
|
||||||
|
const slug = (await params).tag
|
||||||
|
const tags = await getAllTags()
|
||||||
|
const tag = tags.find((item) => item.slug === slug)
|
||||||
|
|
||||||
|
if (!tag) return { title: 'Not Found' }
|
||||||
|
|
||||||
|
return { title: `${tag.label} posts` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TagPage({ params }: TagPageProps) {
|
||||||
|
const slug = (await params).tag
|
||||||
|
|
||||||
|
if (slug === '__no-tags__') notFound()
|
||||||
|
|
||||||
|
const tags = await getAllTags()
|
||||||
|
const tag = tags.find((item) => item.slug === slug)
|
||||||
|
|
||||||
|
if (!tag) notFound()
|
||||||
|
|
||||||
|
const posts = await getPostsByTag(slug)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||||
|
<header className="mb-12">
|
||||||
|
<p className="font-mono text-sm text-ink-soft mb-3">Tag</p>
|
||||||
|
<h1 className="heading-xl text-ink mb-3">{tag.label}</h1>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
{tag.count} {tag.count === 1 ? 'post' : 'posts'}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<ul className="space-y-8">
|
||||||
|
{posts.map((post, index) => (
|
||||||
|
<li key={post.slug}>
|
||||||
|
<PostCard {...post} index={index} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
app/blog/template.tsx
Normal file
15
app/blog/template.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { m } from "motion/react";
|
||||||
|
|
||||||
|
export default function Template({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<m.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</m.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user