feat: init blog
This commit is contained in:
parent
3ced2ec0ec
commit
30fd9ddad2
|
|
@ -0,0 +1,80 @@
|
|||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import {
|
||||
getAllPosts,
|
||||
getPostBySlug,
|
||||
getPostsBySlugs,
|
||||
generatePostMetadata,
|
||||
} from '../utils';
|
||||
import {
|
||||
BlogPostHeader,
|
||||
BlogBreadcrumbs,
|
||||
BlogTOC,
|
||||
BlogSidebar,
|
||||
} from '../_components';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = getAllPosts();
|
||||
return posts.map((post) => ({ slug: post.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = getPostBySlug(slug);
|
||||
if (!post) return {};
|
||||
return generatePostMetadata(post);
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const post = getPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { Content, tocItems } = await import(`../_posts/${slug}`);
|
||||
const relatedArticles = getPostsBySlugs(post.relatedArticles);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="bg-slate-900 py-6 px-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<BlogBreadcrumbs
|
||||
items={[
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
{ label: post.category },
|
||||
{ label: post.title },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BlogPostHeader post={post} />
|
||||
|
||||
<div className="bg-white py-12">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="flex gap-12">
|
||||
<article className="flex-1 max-w-3xl prose-styles">
|
||||
<Content />
|
||||
</article>
|
||||
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<div className="sticky top-24 space-y-8">
|
||||
<BlogTOC items={tocItems} />
|
||||
<BlogSidebar
|
||||
relatedArticles={relatedArticles}
|
||||
relatedDocs={post.relatedDocs}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import Link from 'next/link';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface BlogBreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
variant?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
export const BlogBreadcrumbs = ({
|
||||
items,
|
||||
variant = 'dark',
|
||||
}: BlogBreadcrumbsProps) => {
|
||||
const textColor = variant === 'dark' ? 'text-gray-400' : 'text-gray-600';
|
||||
const hoverColor =
|
||||
variant === 'dark' ? 'hover:text-white' : 'hover:text-gray-900';
|
||||
const activeColor = variant === 'dark' ? 'text-white' : 'text-gray-900';
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-2 text-sm" aria-label="Breadcrumb">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
|
||||
return (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
{index > 0 && (
|
||||
<ChevronRight className={`w-4 h-4 ${textColor}`} />
|
||||
)}
|
||||
{item.href && !isLast ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`${textColor} ${hoverColor} transition-colors`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={isLast ? activeColor : textColor}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import Link from 'next/link';
|
||||
|
||||
interface BlogCTAProps {
|
||||
title: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
buttonHref: string;
|
||||
}
|
||||
|
||||
export const BlogCTA = ({
|
||||
title,
|
||||
description,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
}: BlogCTAProps) => {
|
||||
return (
|
||||
<div className="my-8 p-6 bg-gradient-to-r from-purple-50 to-indigo-50 rounded-lg border border-purple-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-600 mb-4">{description}</p>
|
||||
<Link
|
||||
href={buttonHref}
|
||||
className="inline-block px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
{buttonText}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { BlogPost } from '../types';
|
||||
import { formatDate } from '../utils';
|
||||
|
||||
interface BlogCardProps {
|
||||
post: BlogPost;
|
||||
}
|
||||
|
||||
export const BlogCard = ({ post }: BlogCardProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="block border border-white/10 rounded-lg overflow-hidden hover:border-purple-500/50 transition-colors"
|
||||
>
|
||||
<div className="aspect-video relative bg-slate-800">
|
||||
<Image
|
||||
src={post.heroImage}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-xs text-purple-400 mb-2">{post.category}</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{post.title}</h3>
|
||||
<p className="text-sm text-gray-400 mb-4 line-clamp-2">
|
||||
{post.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src={post.author.avatar}
|
||||
alt={post.author.name}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<span>{post.author.name}</span>
|
||||
</div>
|
||||
<span>{formatDate(post.date)}</span>
|
||||
<span>{post.readTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
interface BlogCodeBlockProps {
|
||||
children: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export const BlogCodeBlock = ({
|
||||
children,
|
||||
language = 'text',
|
||||
}: BlogCodeBlockProps) => {
|
||||
return (
|
||||
<div className="my-6">
|
||||
{language && (
|
||||
<div className="text-xs text-gray-500 bg-gray-100 px-4 py-1 rounded-t-lg border border-b-0 border-gray-200 inline-block">
|
||||
{language}
|
||||
</div>
|
||||
)}
|
||||
<pre
|
||||
className={`bg-gray-50 border border-gray-200 p-4 overflow-x-auto text-sm ${
|
||||
language ? 'rounded-tl-none rounded-lg' : 'rounded-lg'
|
||||
}`}
|
||||
>
|
||||
<code className="text-gray-800">{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
interface BlogHeadingProps {
|
||||
id: string;
|
||||
level: 2 | 3;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BlogHeading = ({ id, level, children }: BlogHeadingProps) => {
|
||||
const Tag = `h${level}` as const;
|
||||
|
||||
const baseStyles = 'scroll-mt-24 text-gray-900 font-semibold';
|
||||
const levelStyles = {
|
||||
2: 'text-2xl mt-12 mb-4 first:mt-0',
|
||||
3: 'text-xl mt-8 mb-3',
|
||||
};
|
||||
|
||||
return (
|
||||
<Tag id={id} className={`${baseStyles} ${levelStyles[level]}`}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import Image from 'next/image';
|
||||
|
||||
interface BlogImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const BlogImage = ({
|
||||
src,
|
||||
alt,
|
||||
caption,
|
||||
fullWidth = false,
|
||||
}: BlogImageProps) => {
|
||||
return (
|
||||
<figure className={`my-8 ${fullWidth ? '-mx-6' : ''}`}>
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden bg-gray-100">
|
||||
<Image src={src} alt={alt} fill className="object-cover" />
|
||||
</div>
|
||||
{caption && (
|
||||
<figcaption className="mt-2 text-sm text-gray-500 text-center">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import Image from 'next/image';
|
||||
import type { BlogPost } from '../types';
|
||||
import { formatDate } from '../utils';
|
||||
|
||||
interface BlogPostHeaderProps {
|
||||
post: BlogPost;
|
||||
}
|
||||
|
||||
export const BlogPostHeader = ({ post }: BlogPostHeaderProps) => {
|
||||
return (
|
||||
<header className="bg-slate-900 py-12">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<div className="text-sm text-purple-400 mb-4">{post.category}</div>
|
||||
<h1 className="text-4xl font-bold text-white mb-4">{post.title}</h1>
|
||||
<p className="text-xl text-gray-400 mb-8">{post.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Image
|
||||
src={post.author.avatar}
|
||||
alt={post.author.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-white font-medium">{post.author.name}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDate(post.date)} · {post.readTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-6 mt-8">
|
||||
<div className="aspect-video relative rounded-lg overflow-hidden bg-slate-800">
|
||||
<Image
|
||||
src={post.heroImage}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
interface BlogQuoteProps {
|
||||
children: React.ReactNode;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export const BlogQuote = ({ children, author }: BlogQuoteProps) => {
|
||||
return (
|
||||
<blockquote className="my-8 border-l-4 border-purple-500 pl-6 py-2">
|
||||
<p className="text-lg text-gray-700 italic">{children}</p>
|
||||
{author && (
|
||||
<footer className="mt-2 text-sm text-gray-500">— {author}</footer>
|
||||
)}
|
||||
</blockquote>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { BookOpen, Code, FileText, ExternalLink } from 'lucide-react';
|
||||
import type { BlogPost, RelatedDoc } from '../types';
|
||||
|
||||
interface BlogSidebarProps {
|
||||
relatedArticles: BlogPost[];
|
||||
relatedDocs: RelatedDoc[];
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
book: BookOpen,
|
||||
code: Code,
|
||||
file: FileText,
|
||||
};
|
||||
|
||||
export const BlogSidebar = ({
|
||||
relatedArticles,
|
||||
relatedDocs,
|
||||
}: BlogSidebarProps) => {
|
||||
if (relatedArticles.length === 0 && relatedDocs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="space-y-8">
|
||||
{relatedArticles.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">
|
||||
Related Articles
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{relatedArticles.map((article) => (
|
||||
<li key={article.slug}>
|
||||
<Link
|
||||
href={`/blog/${article.slug}`}
|
||||
className="flex items-start gap-3 group"
|
||||
>
|
||||
<div className="w-16 h-12 relative rounded overflow-hidden flex-shrink-0 bg-gray-100">
|
||||
<Image
|
||||
src={article.heroImage}
|
||||
alt={article.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 group-hover:text-purple-600 transition-colors line-clamp-2">
|
||||
{article.title}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{relatedDocs.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">
|
||||
Related Documentation
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{relatedDocs.map((doc) => {
|
||||
const Icon = iconMap[doc.icon] || FileText;
|
||||
return (
|
||||
<li key={doc.href}>
|
||||
<Link
|
||||
href={doc.href}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-purple-600 transition-colors"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{doc.title}</span>
|
||||
<ExternalLink className="w-3 h-3 ml-auto" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { TocItem } from '../types';
|
||||
|
||||
interface BlogTOCProps {
|
||||
items: TocItem[];
|
||||
}
|
||||
|
||||
export const BlogTOC = ({ items }: BlogTOCProps) => {
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(entry.target.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: '-20% 0px -35% 0px' }
|
||||
);
|
||||
|
||||
items.forEach((item) => {
|
||||
const element = document.getElementById(item.id);
|
||||
if (element) observer.observe(element);
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [items]);
|
||||
|
||||
const scrollToSection = (id: string) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="p-6 sticky top-24" aria-label="Table of contents">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
||||
On This Page
|
||||
</h3>
|
||||
|
||||
<ul className="space-y-2.5 text-sm">
|
||||
{items.map((item) => {
|
||||
const isActive = activeId === item.id;
|
||||
const isH3 = item.level === 3;
|
||||
|
||||
return (
|
||||
<li key={item.id} className={isH3 ? 'ml-4' : ''}>
|
||||
<button
|
||||
onClick={() => scrollToSection(item.id)}
|
||||
className={`
|
||||
flex items-start gap-2 text-left w-full transition-colors group
|
||||
${isActive ? 'text-purple-600' : 'text-gray-500 hover:text-gray-700'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
flex-shrink-0 w-1 h-1 rounded-full mt-2 transition-colors
|
||||
${isActive ? 'bg-purple-600' : 'bg-gray-300 group-hover:bg-gray-400'}
|
||||
`}
|
||||
></span>
|
||||
<span className="line-clamp-2">{item.text}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export { BlogCard } from './BlogCard';
|
||||
export { BlogTOC } from './BlogTOC';
|
||||
export { BlogPostHeader } from './BlogPostHeader';
|
||||
export { BlogBreadcrumbs } from './BlogBreadcrumbs';
|
||||
export { BlogSidebar } from './BlogSidebar';
|
||||
export { BlogHeading } from './BlogHeading';
|
||||
export { BlogImage } from './BlogImage';
|
||||
export { BlogQuote } from './BlogQuote';
|
||||
export { BlogCTA } from './BlogCTA';
|
||||
export { BlogCodeBlock } from './BlogCodeBlock';
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import {
|
||||
BlogHeading,
|
||||
BlogImage,
|
||||
BlogQuote,
|
||||
BlogCTA,
|
||||
BlogCodeBlock,
|
||||
} from '../_components';
|
||||
import type { TocItem } from '../types';
|
||||
|
||||
export const tocItems: TocItem[] = [
|
||||
{ id: 'introduction', text: 'Introduction', level: 2 },
|
||||
{ id: 'why-ai-placeholders', text: 'Why AI Placeholders?', level: 2 },
|
||||
{ id: 'getting-started', text: 'Getting Started', level: 2 },
|
||||
{ id: 'api-usage', text: 'API Usage', level: 3 },
|
||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
||||
];
|
||||
|
||||
export const Content = () => (
|
||||
<>
|
||||
<BlogHeading id="introduction" level={2}>
|
||||
Introduction
|
||||
</BlogHeading>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
Placeholder images have been a staple of web development for decades.
|
||||
From simple gray boxes to stock photos, developers have always needed
|
||||
a way to visualize layouts before final content is ready.
|
||||
</p>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
Banatie takes this concept to the next level with AI-generated
|
||||
contextual placeholders that actually match your design intent.
|
||||
</p>
|
||||
|
||||
<BlogHeading id="why-ai-placeholders" level={2}>
|
||||
Why AI Placeholders?
|
||||
</BlogHeading>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
Traditional placeholder services give you random images that rarely
|
||||
match your actual content needs. With AI-powered placeholders, you get
|
||||
images that are contextually relevant to your project.
|
||||
</p>
|
||||
|
||||
<BlogQuote author="A Developer">
|
||||
Finally, placeholder images that actually look like what the final
|
||||
product will have. No more explaining to clients why there are cats
|
||||
everywhere.
|
||||
</BlogQuote>
|
||||
|
||||
<BlogHeading id="getting-started" level={2}>
|
||||
Getting Started
|
||||
</BlogHeading>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
Getting started with Banatie is simple. You can use our CDN URLs
|
||||
directly in your HTML or integrate with our API for more control.
|
||||
</p>
|
||||
|
||||
<BlogHeading id="api-usage" level={3}>
|
||||
API Usage
|
||||
</BlogHeading>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
Here is a simple example of how to use Banatie in your HTML:
|
||||
</p>
|
||||
|
||||
<BlogCodeBlock language="html">
|
||||
{`<img
|
||||
src="https://cdn.banatie.app/demo/live/hero?prompt=modern+office+workspace"
|
||||
alt="Office workspace"
|
||||
/>`}
|
||||
</BlogCodeBlock>
|
||||
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
The prompt parameter tells our AI what kind of image to generate.
|
||||
Be descriptive for best results!
|
||||
</p>
|
||||
|
||||
<BlogHeading id="next-steps" level={2}>
|
||||
Next Steps
|
||||
</BlogHeading>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
Ready to start using AI-powered placeholders in your projects?
|
||||
Check out our documentation for more detailed examples and API reference.
|
||||
</p>
|
||||
|
||||
<BlogCTA
|
||||
title="Ready to get started?"
|
||||
description="Join our early access program and start generating contextual placeholder images today."
|
||||
buttonText="Get Early Access"
|
||||
buttonHref="/#get-access"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"posts": [
|
||||
{
|
||||
"slug": "placeholder-images-guide",
|
||||
"title": "Getting Started with AI Placeholder Images",
|
||||
"description": "Learn how to use Banatie to generate contextual placeholder images for your projects.",
|
||||
"heroImage": "/blog/placeholder-guide-hero.jpg",
|
||||
"category": "guides",
|
||||
"date": "2025-01-15",
|
||||
"author": {
|
||||
"name": "Banatie Team",
|
||||
"avatar": "/blog/authors/default.jpg"
|
||||
},
|
||||
"readTime": "5 min",
|
||||
"relatedArticles": [],
|
||||
"relatedDocs": [
|
||||
{ "title": "API Reference", "href": "/docs/api/", "icon": "code" },
|
||||
{ "title": "Quick Start", "href": "/docs/guides/", "icon": "book" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { getAllPosts } from './utils';
|
||||
import { BlogCard, BlogBreadcrumbs } from './_components';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog | Banatie',
|
||||
description:
|
||||
'Articles, guides, and updates about AI-powered placeholder images.',
|
||||
};
|
||||
|
||||
export default function BlogPage() {
|
||||
const posts = getAllPosts();
|
||||
|
||||
return (
|
||||
<main className="py-12 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<BlogBreadcrumbs items={[{ label: 'Blog' }]} />
|
||||
|
||||
<h1 className="text-4xl font-bold text-white mt-8 mb-4">Blog</h1>
|
||||
<p className="text-xl text-gray-400 mb-12">
|
||||
Articles, guides, and updates about AI-powered images.
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{posts.map((post) => (
|
||||
<BlogCard key={post.slug} post={post} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{posts.length === 0 && (
|
||||
<p className="text-gray-500 text-center py-12">
|
||||
No articles yet. Check back soon!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
export interface BlogAuthor {
|
||||
name: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface RelatedDoc {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
heroImage: string;
|
||||
category: string;
|
||||
date: string;
|
||||
author: BlogAuthor;
|
||||
readTime: string;
|
||||
relatedArticles: string[];
|
||||
relatedDocs: RelatedDoc[];
|
||||
}
|
||||
|
||||
export interface TocItem {
|
||||
id: string;
|
||||
text: string;
|
||||
level: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { Metadata } from 'next';
|
||||
import type { BlogPost } from './types';
|
||||
import postsData from './blog-posts.json';
|
||||
|
||||
export const getAllPosts = (): BlogPost[] => postsData.posts as BlogPost[];
|
||||
|
||||
export const getPostBySlug = (slug: string): BlogPost | undefined =>
|
||||
(postsData.posts as BlogPost[]).find((p) => p.slug === slug);
|
||||
|
||||
export const getPostsBySlugs = (slugs: string[]): BlogPost[] =>
|
||||
slugs
|
||||
.map((slug) => getPostBySlug(slug))
|
||||
.filter((post): post is BlogPost => post !== undefined);
|
||||
|
||||
export const generatePostMetadata = (post: BlogPost): Metadata => ({
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
images: [post.heroImage],
|
||||
type: 'article',
|
||||
publishedTime: post.date,
|
||||
authors: [post.author.name],
|
||||
},
|
||||
});
|
||||
|
||||
export const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export const footerLinks = [
|
||||
{ label: 'Blog', href: '/blog/' },
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'API Reference', href: '/docs/api/' },
|
||||
// { label: 'Pricing', href: '#' },
|
||||
|
|
|
|||
Loading…
Reference in New Issue