feat: init blog

This commit is contained in:
Oleg Proskurin 2026-01-17 23:35:16 +07:00
parent 3ced2ec0ec
commit 30fd9ddad2
18 changed files with 728 additions and 0 deletions

View File

@ -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>
);
}

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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)} &middot; {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>
);
};

View File

@ -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">&mdash; {author}</footer>
)}
</blockquote>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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';

View File

@ -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"
/>
</>
);

View File

@ -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" }
]
}
]
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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',
});
};

View File

@ -1,4 +1,5 @@
export const footerLinks = [ export const footerLinks = [
{ label: 'Blog', href: '/blog/' },
{ label: 'Documentation', href: '/docs/' }, { label: 'Documentation', href: '/docs/' },
{ label: 'API Reference', href: '/docs/api/' }, { label: 'API Reference', href: '/docs/api/' },
// { label: 'Pricing', href: '#' }, // { label: 'Pricing', href: '#' },