CI / build-and-test (push) Has been cancelled
- Static prerender + sitemap/robots for marketing, blog, and tool pages (entry-server, Seo component, prerender/sitemap scripts, NotFoundPage) - Register aiRoutes and extend CSP for Turnstile + Spaces image hosts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
128 lines
4.4 KiB
TypeScript
128 lines
4.4 KiB
TypeScript
import { Link, useParams } from 'react-router-dom';
|
|
import { ArrowLeft, Clock, Info, AlertTriangle } from 'lucide-react';
|
|
import { PublicLayout } from '@/components/public/PublicLayout';
|
|
import { getPost, type Block } from '@/content/posts';
|
|
import { formatDate } from '@/lib/format';
|
|
import { cn } from '@/lib/cn';
|
|
|
|
export default function BlogPostPage() {
|
|
const { slug } = useParams<{ slug: string }>();
|
|
const post = slug ? getPost(slug) : undefined;
|
|
|
|
if (!post) {
|
|
return (
|
|
<PublicLayout>
|
|
<section className="container py-24 max-w-2xl text-center">
|
|
<h1 className="text-2xl font-bold text-ink-950 font-display">Post not found</h1>
|
|
<Link to="/blog" className="mt-4 inline-block text-brand-600 hover:text-brand-700">
|
|
← Back to blog
|
|
</Link>
|
|
</section>
|
|
</PublicLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PublicLayout>
|
|
<article className="container py-12 max-w-3xl">
|
|
<Link to="/blog" className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-6">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
All posts
|
|
</Link>
|
|
|
|
<header className="mb-10">
|
|
<p className="text-xs uppercase tracking-wider text-brand-600 font-semibold">Blog</p>
|
|
<h1 className="mt-3 text-3xl md:text-5xl font-bold text-ink-950 font-display leading-tight">
|
|
{post.title}
|
|
</h1>
|
|
<p className="mt-4 text-lg text-ink-600 leading-relaxed">{post.description}</p>
|
|
<div className="mt-6 flex items-center gap-3 text-sm text-ink-500">
|
|
<span>{post.author}</span>
|
|
<span className="text-ink-300">·</span>
|
|
<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
|
|
<span className="text-ink-300">·</span>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
{post.readMinutes} min read
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
{post.coverImage && (
|
|
<img
|
|
src={post.coverImage}
|
|
alt=""
|
|
className="mb-10 aspect-[16/9] w-full rounded-2xl border border-ink-100 object-cover"
|
|
/>
|
|
)}
|
|
|
|
<div className="space-y-5">
|
|
{post.body.map((b, i) => (
|
|
<RenderBlock key={i} block={b} />
|
|
))}
|
|
</div>
|
|
|
|
<footer className="mt-16 border-t border-ink-100 pt-8 flex items-center justify-between">
|
|
<Link to="/blog" className="text-sm text-brand-600 hover:text-brand-700">
|
|
← All posts
|
|
</Link>
|
|
<Link to="/signup" className="btn-primary text-sm">
|
|
Try eLegal Software free
|
|
</Link>
|
|
</footer>
|
|
</article>
|
|
</PublicLayout>
|
|
);
|
|
}
|
|
|
|
function RenderBlock({ block }: { block: Block }) {
|
|
switch (block.type) {
|
|
case 'p':
|
|
return <p className="text-ink-800 leading-relaxed">{block.text}</p>;
|
|
case 'h2':
|
|
return <h2 className="mt-10 text-2xl md:text-3xl font-bold text-ink-950 font-display">{block.text}</h2>;
|
|
case 'h3':
|
|
return <h3 className="mt-8 text-xl font-semibold text-ink-900 font-display">{block.text}</h3>;
|
|
case 'ul':
|
|
return (
|
|
<ul className="list-disc pl-6 space-y-2 text-ink-800">
|
|
{block.items.map((item, i) => (
|
|
<li key={i}>{item}</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
case 'ol':
|
|
return (
|
|
<ol className="list-decimal pl-6 space-y-2 text-ink-800">
|
|
{block.items.map((item, i) => (
|
|
<li key={i}>{item}</li>
|
|
))}
|
|
</ol>
|
|
);
|
|
case 'quote':
|
|
return (
|
|
<blockquote className="border-l-4 border-brand-500 pl-4 italic text-ink-700">{block.text}</blockquote>
|
|
);
|
|
case 'callout': {
|
|
const tone = block.tone;
|
|
const Icon = tone === 'amber' ? AlertTriangle : Info;
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'rounded-2xl p-5 my-2 flex items-start gap-3',
|
|
tone === 'amber'
|
|
? 'border border-amber-200 bg-amber-50/50 text-amber-900'
|
|
: 'border border-brand-200 bg-brand-50/50 text-ink-800',
|
|
)}
|
|
>
|
|
<Icon className={cn('h-5 w-5 flex-none mt-0.5', tone === 'amber' ? 'text-amber-600' : 'text-brand-600')} />
|
|
<div>
|
|
<p className="font-semibold">{block.title}</p>
|
|
<p className="mt-1 text-sm leading-relaxed">{block.text}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
}
|