feat(blog): Medium-style redesign with PostCard grid, Prose typography; PostHeader + reading progress; back links; reading time/excerpt utils; GitHub-backed MDX provider; prev/next navigation; theme-aligned cards
This commit is contained in:
parent
ef24c27085
commit
5b07fae911
@ -1,10 +1,13 @@
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { CustomMDX } from '@/components/mdx'
|
||||
import { formatDate, getBlogPosts } from '../utils'
|
||||
import PostHeader from '@/components/blog/PostHeader'
|
||||
import ProgressBar from '@/components/blog/ProgressBar'
|
||||
import { getAllPosts, getReadingTime, findAdjacentPosts } from '../utils'
|
||||
import { baseUrl } from '../../sitemap'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
let posts = getBlogPosts()
|
||||
const posts = await getAllPosts()
|
||||
|
||||
return posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
@ -12,10 +15,10 @@ export async function generateStaticParams() {
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const post = getBlogPosts().find((post) => post.slug === slug)
|
||||
const { slug } = await params
|
||||
const post = (await getAllPosts()).find((p) => p.slug === slug)
|
||||
if (!post) {
|
||||
return
|
||||
return {}
|
||||
}
|
||||
|
||||
const {
|
||||
@ -24,6 +27,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
||||
summary: description,
|
||||
image,
|
||||
} = post.metadata
|
||||
|
||||
const ogImage = image
|
||||
? image
|
||||
: `${baseUrl}/og?title=${encodeURIComponent(title)}`
|
||||
@ -37,11 +41,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
||||
type: 'article',
|
||||
publishedTime,
|
||||
url: `${baseUrl}/blog/${post.slug}`,
|
||||
images: [
|
||||
{
|
||||
url: ogImage,
|
||||
},
|
||||
],
|
||||
images: [{ url: ogImage }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@ -54,14 +54,29 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
||||
|
||||
export default async function Blog({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const post = getBlogPosts().find((post) => post.slug === slug)
|
||||
const posts = await getAllPosts()
|
||||
const post = posts.find((p) => p.slug === slug)
|
||||
|
||||
if (!post) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const reading = getReadingTime(post.content)
|
||||
const { prev, next } = findAdjacentPosts(posts, slug)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<>
|
||||
<ProgressBar />
|
||||
<section className="mx-auto max-w-5xl px-4 md:px-6">
|
||||
<div className="mb-2">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-neutral-700 transition-colors hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-white/5 dark:hover:text-neutral-100"
|
||||
aria-label="Back to blog"
|
||||
>
|
||||
← <span className="underline-offset-4 hover:underline">Back to blog</span>
|
||||
</Link>
|
||||
</div>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
suppressHydrationWarning
|
||||
@ -84,17 +99,34 @@ export default async function Blog({ params }: { params: Promise<{ slug: string
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<h1 className="title font-semibold text-2xl tracking-tighter">
|
||||
{post.metadata.title}
|
||||
</h1>
|
||||
<div className="flex justify-between items-center mt-2 mb-8 text-sm">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{formatDate(post.metadata.publishedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<article className="prose">
|
||||
<PostHeader
|
||||
title={post.metadata.title}
|
||||
publishedAt={post.metadata.publishedAt}
|
||||
readingTimeText={reading.text}
|
||||
tags={post.metadata.tags}
|
||||
summary={post.metadata.summary}
|
||||
className="mb-6"
|
||||
/>
|
||||
<article className="prose mx-auto max-w-3xl">
|
||||
<CustomMDX source={post.content} />
|
||||
</article>
|
||||
<nav className="mx-auto mt-10 flex max-w-3xl justify-between text-sm">
|
||||
{prev ? (
|
||||
<Link href={`/blog/${prev.slug}`} className="underline-offset-4 hover:underline">
|
||||
← {prev.metadata.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{next ? (
|
||||
<Link href={`/blog/${next.slug}`} className="underline-offset-4 hover:underline">
|
||||
{next.metadata.title} →
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,15 +1,32 @@
|
||||
import { BlogPosts } from '../../components/posts'
|
||||
import Link from 'next/link'
|
||||
import PostCard from '@/components/blog/PostCard'
|
||||
import { getAllPosts } from '@/app/blog/utils'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Blog',
|
||||
description: 'Read my blog.',
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
export default async function Page() {
|
||||
const posts = await getAllPosts()
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 className="font-semibold text-2xl mb-8 tracking-tighter">My Blog</h1>
|
||||
<BlogPosts />
|
||||
<section className="mx-auto max-w-5xl px-4 md:px-6">
|
||||
<div className="mb-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-neutral-700 transition-colors hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-white/5 dark:hover:text-neutral-100"
|
||||
aria-label="Back to home"
|
||||
>
|
||||
← <span className="underline-offset-4 hover:underline">Back home</span>
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="font-semibold text-2xl mb-6 tracking-tighter">My Blog</h1>
|
||||
<div className="grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.slug} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,68 +1,211 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
type Metadata = {
|
||||
export type Metadata = {
|
||||
title: string
|
||||
publishedAt: string
|
||||
summary: string
|
||||
image?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export type Post = {
|
||||
metadata: Metadata
|
||||
slug: string
|
||||
content: string
|
||||
source: 'fs' | 'github'
|
||||
}
|
||||
|
||||
function parseFrontmatter(fileContent: string) {
|
||||
let frontmatterRegex = /---\s*([\s\S]*?)\s*---/
|
||||
let match = frontmatterRegex.exec(fileContent)
|
||||
let frontMatterBlock = match![1]
|
||||
let content = fileContent.replace(frontmatterRegex, '').trim()
|
||||
let frontMatterLines = frontMatterBlock.trim().split('\n')
|
||||
let metadata: Partial<Metadata> = {}
|
||||
const frontmatterRegex = /---\s*([\s\S]*?)\s*---/
|
||||
const match = frontmatterRegex.exec(fileContent)
|
||||
if (!match) {
|
||||
// No frontmatter, treat entire content as body with minimal metadata
|
||||
return {
|
||||
metadata: {
|
||||
title: 'Untitled',
|
||||
publishedAt: new Date().toISOString(),
|
||||
summary: '',
|
||||
} as Metadata,
|
||||
content: fileContent.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
const frontMatterBlock = match[1]
|
||||
const content = fileContent.replace(frontmatterRegex, '').trim()
|
||||
const frontMatterLines = frontMatterBlock.trim().split('\n')
|
||||
const metadata: Partial<Metadata> = {}
|
||||
|
||||
frontMatterLines.forEach((line) => {
|
||||
let [key, ...valueArr] = line.split(': ')
|
||||
const [rawKey, ...valueArr] = line.split(': ')
|
||||
const key = rawKey?.trim()
|
||||
if (!key) return
|
||||
let value = valueArr.join(': ').trim()
|
||||
value = value.replace(/^['"](.*)['"]$/, '$1') // Remove quotes
|
||||
metadata[key.trim() as keyof Metadata] = value
|
||||
|
||||
// Remove surrounding quotes
|
||||
value = value.replace(/^['"](.*)['"]$/, '$1')
|
||||
|
||||
// Support simple array syntax for tags: [tag1, tag2]
|
||||
if (key === 'tags') {
|
||||
const arr =
|
||||
value.startsWith('[') && value.endsWith(']')
|
||||
? value
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((v) => v.trim().replace(/^['"](.*)['"]$/, '$1'))
|
||||
.filter(Boolean)
|
||||
: value
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)
|
||||
;(metadata as Record<string, unknown>)[key] = arr
|
||||
} else {
|
||||
;(metadata as Record<string, unknown>)[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
return { metadata: metadata as Metadata, content }
|
||||
}
|
||||
|
||||
function getMDXFiles(dir) {
|
||||
return fs.readdirSync(dir).filter((file) => path.extname(file) === '.mdx')
|
||||
// ============ Local FS provider ============
|
||||
function getMDXFiles(dir: string) {
|
||||
return fs.existsSync(dir)
|
||||
? fs.readdirSync(dir).filter((file) => path.extname(file) === '.mdx')
|
||||
: []
|
||||
}
|
||||
|
||||
function readMDXFile(filePath) {
|
||||
let rawContent = fs.readFileSync(filePath, 'utf-8')
|
||||
function readMDXFile(filePath: string) {
|
||||
const rawContent = fs.readFileSync(filePath, 'utf-8')
|
||||
return parseFrontmatter(rawContent)
|
||||
}
|
||||
|
||||
function getMDXData(dir) {
|
||||
let mdxFiles = getMDXFiles(dir)
|
||||
function getMDXData(dir: string): Post[] {
|
||||
const mdxFiles = getMDXFiles(dir)
|
||||
return mdxFiles.map((file) => {
|
||||
let { metadata, content } = readMDXFile(path.join(dir, file))
|
||||
let slug = path.basename(file, path.extname(file))
|
||||
|
||||
const { metadata, content } = readMDXFile(path.join(dir, file))
|
||||
const slug = path.basename(file, path.extname(file))
|
||||
return {
|
||||
metadata,
|
||||
slug,
|
||||
content,
|
||||
source: 'fs',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getBlogPosts() {
|
||||
function getFSPosts(): Post[] {
|
||||
return getMDXData(path.join(process.cwd(), 'app', 'blog', 'posts'))
|
||||
}
|
||||
|
||||
// ============ GitHub provider (optional) ============
|
||||
const BLOG_REPO = process.env.BLOG_REPO // 'owner/repo'
|
||||
const BLOG_PATH = process.env.BLOG_PATH || ''
|
||||
const BLOG_BRANCH = process.env.BLOG_BRANCH || 'main'
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN
|
||||
|
||||
type GithubContentItem = {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'dir'
|
||||
}
|
||||
|
||||
type GithubFileResponse = {
|
||||
content?: string
|
||||
}
|
||||
|
||||
async function githubApi<T>(url: string): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
}
|
||||
if (GITHUB_TOKEN) headers.Authorization = `Bearer ${GITHUB_TOKEN}`
|
||||
|
||||
const res = await fetch(url, { headers, cache: 'no-store' })
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub API error ${res.status} on ${url}`)
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
async function getGithubPosts(): Promise<Post[]> {
|
||||
if (!BLOG_REPO) return []
|
||||
const [owner, repo] = BLOG_REPO.split('/')
|
||||
const base = `https://api.github.com/repos/${owner}/${repo}/contents`
|
||||
const dir = BLOG_PATH ? `/${BLOG_PATH}` : ''
|
||||
const listUrl = `${base}${dir}?ref=${encodeURIComponent(BLOG_BRANCH)}`
|
||||
|
||||
let items: GithubContentItem[]
|
||||
try {
|
||||
items = await githubApi<GithubContentItem[]>(listUrl)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const mdxItems = items.filter((it) => it.type === 'file' && it.name.endsWith('.mdx'))
|
||||
|
||||
const posts: Post[] = []
|
||||
for (const item of mdxItems) {
|
||||
// Fetch file content (base64) via contents API
|
||||
const fileUrl = `${base}/${encodeURIComponent(item.path)}?ref=${encodeURIComponent(BLOG_BRANCH)}`
|
||||
try {
|
||||
const fileJson = await githubApi<GithubFileResponse>(fileUrl)
|
||||
const contentBase64: string | undefined = fileJson.content
|
||||
if (!contentBase64) continue
|
||||
const raw = Buffer.from(contentBase64, 'base64').toString('utf8')
|
||||
const { metadata, content } = parseFrontmatter(raw)
|
||||
const slug = path.basename(item.name, '.mdx')
|
||||
posts.push({
|
||||
metadata,
|
||||
slug,
|
||||
content,
|
||||
source: 'github',
|
||||
})
|
||||
} catch {
|
||||
// skip on individual file failure
|
||||
continue
|
||||
}
|
||||
}
|
||||
return posts
|
||||
}
|
||||
|
||||
// ============ Public API ============
|
||||
|
||||
// Backward-compatible local-only function (used by older imports)
|
||||
export function getBlogPosts(): Post[] {
|
||||
return getFSPosts()
|
||||
}
|
||||
|
||||
// New unified provider that merges FS with optional GitHub content
|
||||
export async function getAllPosts(): Promise<Post[]> {
|
||||
const fsPosts = getFSPosts()
|
||||
const ghPosts = await getGithubPosts().catch(() => [])
|
||||
// Merge by slug, with GitHub taking precedence on duplicates
|
||||
const map = new Map<string, Post>()
|
||||
for (const p of fsPosts) map.set(p.slug, p)
|
||||
for (const p of ghPosts) map.set(p.slug, p)
|
||||
return Array.from(map.values())
|
||||
.filter((p) => !!p.metadata?.publishedAt && !!p.metadata?.title)
|
||||
.sort((a, b) => {
|
||||
const da = new Date(a.metadata.publishedAt).getTime()
|
||||
const db = new Date(b.metadata.publishedAt).getTime()
|
||||
return db - da
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDate(date: string, includeRelative = false) {
|
||||
let currentDate = new Date()
|
||||
const currentDate = new Date()
|
||||
if (!date.includes('T')) {
|
||||
date = `${date}T00:00:00`
|
||||
}
|
||||
let targetDate = new Date(date)
|
||||
const targetDate = new Date(date)
|
||||
|
||||
let yearsAgo = currentDate.getFullYear() - targetDate.getFullYear()
|
||||
let monthsAgo = currentDate.getMonth() - targetDate.getMonth()
|
||||
let daysAgo = currentDate.getDate() - targetDate.getDate()
|
||||
const yearsAgo = currentDate.getFullYear() - targetDate.getFullYear()
|
||||
const monthsAgo =
|
||||
currentDate.getMonth() -
|
||||
targetDate.getMonth() +
|
||||
yearsAgo * 12
|
||||
const daysAgo = Math.floor(
|
||||
(currentDate.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
|
||||
let formattedDate = ''
|
||||
|
||||
@ -76,7 +219,7 @@ export function formatDate(date: string, includeRelative = false) {
|
||||
formattedDate = 'Today'
|
||||
}
|
||||
|
||||
let fullDate = targetDate.toLocaleString('en-us', {
|
||||
const fullDate = targetDate.toLocaleString('en-us', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
@ -88,3 +231,40 @@ export function formatDate(date: string, includeRelative = false) {
|
||||
|
||||
return `${fullDate} (${formattedDate})`
|
||||
}
|
||||
|
||||
export function getReadingTime(content: string) {
|
||||
const words = (content || '')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
const minutes = Math.max(1, Math.ceil(words / 200))
|
||||
return { minutes, text: `${minutes} min read`, words }
|
||||
}
|
||||
|
||||
export function getExcerpt(summary?: string, content?: string, maxChars = 220) {
|
||||
if (summary && summary.trim().length > 0) return summary.trim()
|
||||
if (!content) return ''
|
||||
// Get first non-empty paragraph
|
||||
const firstPara =
|
||||
content
|
||||
.split(/\n{2,}/)
|
||||
.map((s) => s.trim())
|
||||
.find((p) => p.length > 0) || ''
|
||||
const clean = firstPara
|
||||
.replace(/```[\s\S]*?```/g, '') // remove code fences
|
||||
.replace(/`[^`]*`/g, '') // remove inline code
|
||||
.replace(/[#>*_~\[\]()\-]/g, '') // remove some md tokens
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (clean.length <= maxChars) return clean
|
||||
return clean.slice(0, maxChars).replace(/\s+\S*$/, '') + '…'
|
||||
}
|
||||
|
||||
export function findAdjacentPosts(posts: Post[], slug: string) {
|
||||
const idx = posts.findIndex((p) => p.slug === slug)
|
||||
if (idx === -1) return { prev: undefined, next: undefined }
|
||||
// posts expected sorted desc (newest to oldest)
|
||||
const prev = idx > 0 ? posts[idx - 1] : undefined // newer
|
||||
const next = idx < posts.length - 1 ? posts[idx + 1] : undefined // older
|
||||
return { prev, next }
|
||||
}
|
||||
|
||||
118
app/globals.css
118
app/globals.css
@ -76,3 +76,121 @@ a:visited {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Prose typography for blog posts */
|
||||
.prose {
|
||||
max-width: 65ch;
|
||||
margin: 0 auto;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4 {
|
||||
color: var(--foreground);
|
||||
line-height: 1.25;
|
||||
margin-top: 1.75em;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
|
||||
.prose h1 { font-size: 2rem; }
|
||||
.prose h2 { font-size: 1.5rem; }
|
||||
.prose h3 { font-size: 1.25rem; }
|
||||
|
||||
.prose p {
|
||||
margin: 1em 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.8;
|
||||
color: color-mix(in srgb, var(--foreground) 90%, transparent 10%);
|
||||
}
|
||||
|
||||
.prose a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
margin: 1.5em 0;
|
||||
padding-left: 1em;
|
||||
border-left: 3px solid rgba(15, 23, 32, 0.18);
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.prose blockquote { border-left-color: rgba(237, 237, 237, 0.18); }
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(15, 23, 32, 0.12);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.prose hr { border-top-color: rgba(237, 237, 237, 0.12); }
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
padding-left: 1.25rem;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.prose li { margin: 0.35em 0; }
|
||||
|
||||
.prose code {
|
||||
background: rgba(15, 23, 32, 0.06);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.prose code { background: rgba(237, 237, 237, 0.08); }
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: rgba(15, 23, 32, 0.85);
|
||||
color: #f1f5f9;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 10px;
|
||||
overflow-x: auto;
|
||||
box-shadow: var(--card-shadow);
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.prose pre { background: rgba(255, 255, 255, 0.06); }
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.prose img { border-radius: 10px; }
|
||||
|
||||
.prose figure { margin: 1.25rem 0; text-align: center; }
|
||||
.prose figcaption { color: var(--muted); font-size: 0.9rem; margin-top: 0.6rem; }
|
||||
|
||||
/* Heading anchor link emitted by CustomMDX */
|
||||
.prose .anchor {
|
||||
float: left;
|
||||
margin-left: -0.9ch;
|
||||
padding-right: 0.5ch;
|
||||
opacity: 0;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
}
|
||||
.prose .anchor::after {
|
||||
content: '#';
|
||||
color: var(--muted);
|
||||
}
|
||||
.prose h2:hover .anchor,
|
||||
.prose h3:hover .anchor,
|
||||
.prose h4:hover .anchor {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ export default function Home() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
I wanted to justify the $6.39 I spent on the domain. Building cinematic, accessible web experiences.
|
||||
I wanted to justify the $6.39 I spent on the domain.
|
||||
</p>
|
||||
|
||||
<div className="text-sm text-neutral-800 dark:text-neutral-200">
|
||||
|
||||
65
components/blog/PostCard.tsx
Normal file
65
components/blog/PostCard.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import Link from 'next/link'
|
||||
import { formatDate, getExcerpt, getReadingTime, type Post } from '@/app/blog/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
type PostCardProps = {
|
||||
post: Post
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PostCard({ post, className }: PostCardProps) {
|
||||
const { metadata, slug, content } = post
|
||||
const excerpt = getExcerpt(metadata.summary, content, 200)
|
||||
const reading = getReadingTime(content)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'group relative overflow-hidden transition-colors hover:bg-black/[0.04] dark:hover:bg-white/5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/blog/${slug}`}
|
||||
aria-label={`Read: ${metadata.title}`}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
<div className="p-5">
|
||||
<header className="mb-3">
|
||||
<h3 className="text-lg font-semibold leading-tight tracking-tight text-neutral-900 dark:text-neutral-100 underline-offset-4 group-hover:underline">
|
||||
{metadata.title}
|
||||
</h3>
|
||||
</header>
|
||||
{excerpt && (
|
||||
<p className="mb-4 line-clamp-3 text-sm leading-6 text-neutral-700 dark:text-neutral-300">
|
||||
{excerpt}
|
||||
</p>
|
||||
)}
|
||||
<footer className="flex flex-wrap items-center gap-2 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
<time dateTime={metadata.publishedAt}>{formatDate(metadata.publishedAt, false)}</time>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{reading.text}</span>
|
||||
{Array.isArray(metadata.tags) && metadata.tags.length > 0 && (
|
||||
<>
|
||||
<span aria-hidden="true">•</span>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{metadata.tags.slice(0, 3).map((tag) => (
|
||||
<li
|
||||
key={tag}
|
||||
className="rounded-full border border-black/10 bg-white/40 px-2 py-0.5 text-[11px] leading-5 dark:border-white/10 dark:bg-white/[0.06]"
|
||||
>
|
||||
{tag}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto hidden text-neutral-400 transition-colors group-hover:text-neutral-600 dark:group-hover:text-neutral-300 sm:inline">
|
||||
→
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
57
components/blog/PostHeader.tsx
Normal file
57
components/blog/PostHeader.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { formatDate } from '@/app/blog/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PostHeaderProps = {
|
||||
title: string
|
||||
publishedAt: string
|
||||
readingTimeText?: string
|
||||
tags?: string[]
|
||||
summary?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PostHeader({
|
||||
title,
|
||||
publishedAt,
|
||||
readingTimeText,
|
||||
tags,
|
||||
summary,
|
||||
className,
|
||||
}: PostHeaderProps) {
|
||||
return (
|
||||
<header className={cn('mx-auto max-w-3xl', className)}>
|
||||
<h1 className="mb-3 text-3xl font-semibold leading-tight tracking-tighter text-neutral-900 dark:text-neutral-100 sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
{summary ? (
|
||||
<p className="mb-4 text-base leading-7 text-neutral-700 dark:text-neutral-300">
|
||||
{summary}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
<time dateTime={publishedAt}>{formatDate(publishedAt, false)}</time>
|
||||
{readingTimeText ? (
|
||||
<>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{readingTimeText}</span>
|
||||
</>
|
||||
) : null}
|
||||
{Array.isArray(tags) && tags.length > 0 ? (
|
||||
<>
|
||||
<span aria-hidden="true">•</span>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{tags.slice(0, 4).map((tag) => (
|
||||
<li
|
||||
key={tag}
|
||||
className="rounded-full border border-black/10 bg-white/40 px-2 py-0.5 text-[11px] leading-5 dark:border-white/10 dark:bg-white/[0.06]"
|
||||
>
|
||||
{tag}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
34
components/blog/ProgressBar.tsx
Normal file
34
components/blog/ProgressBar.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, useSpring } from 'motion/react'
|
||||
|
||||
export default function ProgressBar() {
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = document.documentElement
|
||||
const max = scrollHeight - clientHeight
|
||||
const p = max > 0 ? scrollTop / max : 0
|
||||
setProgress(Math.min(1, Math.max(0, p)))
|
||||
}
|
||||
update()
|
||||
window.addEventListener('scroll', update, { passive: true })
|
||||
window.addEventListener('resize', update)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', update)
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const smooth = useSpring(progress, { stiffness: 140, damping: 24, mass: 0.2 })
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed left-0 top-0 z-40 h-0.5 origin-left bg-neutral-900 dark:bg-neutral-100"
|
||||
style={{ scaleX: smooth, width: '100%' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user