From 30fd9ddad2c167ac97e027d807cbd360fd00b48a Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Sat, 17 Jan 2026 23:35:16 +0700 Subject: [PATCH] feat: init blog --- .../src/app/(landings)/blog/[slug]/page.tsx | 80 +++++++++++++++++ .../blog/_components/BlogBreadcrumbs.tsx | 50 +++++++++++ .../(landings)/blog/_components/BlogCTA.tsx | 28 ++++++ .../(landings)/blog/_components/BlogCard.tsx | 47 ++++++++++ .../blog/_components/BlogCodeBlock.tsx | 26 ++++++ .../blog/_components/BlogHeading.tsx | 21 +++++ .../(landings)/blog/_components/BlogImage.tsx | 28 ++++++ .../blog/_components/BlogPostHeader.tsx | 47 ++++++++++ .../(landings)/blog/_components/BlogQuote.tsx | 15 ++++ .../blog/_components/BlogSidebar.tsx | 83 +++++++++++++++++ .../(landings)/blog/_components/BlogTOC.tsx | 78 ++++++++++++++++ .../app/(landings)/blog/_components/index.ts | 10 +++ .../blog/_posts/placeholder-images-guide.tsx | 90 +++++++++++++++++++ .../src/app/(landings)/blog/blog-posts.json | 22 +++++ apps/landing/src/app/(landings)/blog/page.tsx | 38 ++++++++ apps/landing/src/app/(landings)/blog/types.ts | 29 ++++++ apps/landing/src/app/(landings)/blog/utils.ts | 35 ++++++++ apps/landing/src/config/footer.ts | 1 + 18 files changed, 728 insertions(+) create mode 100644 apps/landing/src/app/(landings)/blog/[slug]/page.tsx create mode 100644 apps/landing/src/app/(landings)/blog/_components/BlogBreadcrumbs.tsx create mode 100644 apps/landing/src/app/(landings)/blog/_components/BlogCTA.tsx create mode 100644 apps/landing/src/app/(landings)/blog/_components/BlogCard.tsx create mode 100644 apps/landing/src/app/(landings)/blog/_components/BlogCodeBlock.tsx create mode 100644 apps/landing/src/app/(landings)/blog/_components/BlogHeading.tsx create mode 100644 apps/landing/src/app/(landings)/blog/_components/BlogImage.tsx create mode 100644 apps/landing/src/app/(landings)/blog/_components/BlogPostHeader.tsx create mode 100644 apps/landing/src/app/(landings)/blog/_components/BlogQuote.tsx create mode 100644 apps/landing/src/app/(landings)/blog/_components/BlogSidebar.tsx create mode 100644 apps/landing/src/app/(landings)/blog/_components/BlogTOC.tsx create mode 100644 apps/landing/src/app/(landings)/blog/_components/index.ts create mode 100644 apps/landing/src/app/(landings)/blog/_posts/placeholder-images-guide.tsx create mode 100644 apps/landing/src/app/(landings)/blog/blog-posts.json create mode 100644 apps/landing/src/app/(landings)/blog/page.tsx create mode 100644 apps/landing/src/app/(landings)/blog/types.ts create mode 100644 apps/landing/src/app/(landings)/blog/utils.ts diff --git a/apps/landing/src/app/(landings)/blog/[slug]/page.tsx b/apps/landing/src/app/(landings)/blog/[slug]/page.tsx new file mode 100644 index 0000000..b4ee8a4 --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/[slug]/page.tsx @@ -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 { + 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 ( +
+
+
+ +
+
+ + + +
+
+
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/landing/src/app/(landings)/blog/_components/BlogBreadcrumbs.tsx b/apps/landing/src/app/(landings)/blog/_components/BlogBreadcrumbs.tsx new file mode 100644 index 0000000..67cb5c4 --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_components/BlogBreadcrumbs.tsx @@ -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 ( + + ); +}; diff --git a/apps/landing/src/app/(landings)/blog/_components/BlogCTA.tsx b/apps/landing/src/app/(landings)/blog/_components/BlogCTA.tsx new file mode 100644 index 0000000..24bcee7 --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_components/BlogCTA.tsx @@ -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 ( +
+

{title}

+

{description}

+ + {buttonText} + +
+ ); +}; diff --git a/apps/landing/src/app/(landings)/blog/_components/BlogCard.tsx b/apps/landing/src/app/(landings)/blog/_components/BlogCard.tsx new file mode 100644 index 0000000..85bd49c --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_components/BlogCard.tsx @@ -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 ( + +
+ {post.title} +
+
+
{post.category}
+

{post.title}

+

+ {post.description} +

+
+
+ {post.author.name} + {post.author.name} +
+ {formatDate(post.date)} + {post.readTime} +
+
+ + ); +}; diff --git a/apps/landing/src/app/(landings)/blog/_components/BlogCodeBlock.tsx b/apps/landing/src/app/(landings)/blog/_components/BlogCodeBlock.tsx new file mode 100644 index 0000000..3cfdafa --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_components/BlogCodeBlock.tsx @@ -0,0 +1,26 @@ +interface BlogCodeBlockProps { + children: string; + language?: string; +} + +export const BlogCodeBlock = ({ + children, + language = 'text', +}: BlogCodeBlockProps) => { + return ( +
+ {language && ( +
+ {language} +
+ )} +
+        {children}
+      
+
+ ); +}; diff --git a/apps/landing/src/app/(landings)/blog/_components/BlogHeading.tsx b/apps/landing/src/app/(landings)/blog/_components/BlogHeading.tsx new file mode 100644 index 0000000..185ff4c --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_components/BlogHeading.tsx @@ -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 ( + + {children} + + ); +}; diff --git a/apps/landing/src/app/(landings)/blog/_components/BlogImage.tsx b/apps/landing/src/app/(landings)/blog/_components/BlogImage.tsx new file mode 100644 index 0000000..a869261 --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_components/BlogImage.tsx @@ -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 ( +
+
+ {alt} +
+ {caption && ( +
+ {caption} +
+ )} +
+ ); +}; diff --git a/apps/landing/src/app/(landings)/blog/_components/BlogPostHeader.tsx b/apps/landing/src/app/(landings)/blog/_components/BlogPostHeader.tsx new file mode 100644 index 0000000..0afd13d --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_components/BlogPostHeader.tsx @@ -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 ( +
+
+
{post.category}
+

{post.title}

+

{post.description}

+ +
+ {post.author.name} +
+
{post.author.name}
+
+ {formatDate(post.date)} · {post.readTime} +
+
+
+
+ +
+
+ {post.title} +
+
+
+ ); +}; diff --git a/apps/landing/src/app/(landings)/blog/_components/BlogQuote.tsx b/apps/landing/src/app/(landings)/blog/_components/BlogQuote.tsx new file mode 100644 index 0000000..fcb1a47 --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_components/BlogQuote.tsx @@ -0,0 +1,15 @@ +interface BlogQuoteProps { + children: React.ReactNode; + author?: string; +} + +export const BlogQuote = ({ children, author }: BlogQuoteProps) => { + return ( +
+

{children}

+ {author && ( +
— {author}
+ )} +
+ ); +}; diff --git a/apps/landing/src/app/(landings)/blog/_components/BlogSidebar.tsx b/apps/landing/src/app/(landings)/blog/_components/BlogSidebar.tsx new file mode 100644 index 0000000..848493f --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_components/BlogSidebar.tsx @@ -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> = { + book: BookOpen, + code: Code, + file: FileText, +}; + +export const BlogSidebar = ({ + relatedArticles, + relatedDocs, +}: BlogSidebarProps) => { + if (relatedArticles.length === 0 && relatedDocs.length === 0) { + return null; + } + + return ( + + ); +}; diff --git a/apps/landing/src/app/(landings)/blog/_components/BlogTOC.tsx b/apps/landing/src/app/(landings)/blog/_components/BlogTOC.tsx new file mode 100644 index 0000000..2910f34 --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_components/BlogTOC.tsx @@ -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(''); + + 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 ( + + ); +}; diff --git a/apps/landing/src/app/(landings)/blog/_components/index.ts b/apps/landing/src/app/(landings)/blog/_components/index.ts new file mode 100644 index 0000000..47272ad --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_components/index.ts @@ -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'; diff --git a/apps/landing/src/app/(landings)/blog/_posts/placeholder-images-guide.tsx b/apps/landing/src/app/(landings)/blog/_posts/placeholder-images-guide.tsx new file mode 100644 index 0000000..f5a38cd --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/_posts/placeholder-images-guide.tsx @@ -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 = () => ( + <> + + Introduction + +

+ 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. +

+

+ Banatie takes this concept to the next level with AI-generated + contextual placeholders that actually match your design intent. +

+ + + Why AI Placeholders? + +

+ 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. +

+ + + Finally, placeholder images that actually look like what the final + product will have. No more explaining to clients why there are cats + everywhere. + + + + Getting Started + +

+ Getting started with Banatie is simple. You can use our CDN URLs + directly in your HTML or integrate with our API for more control. +

+ + + API Usage + +

+ Here is a simple example of how to use Banatie in your HTML: +

+ + + {`Office workspace`} + + +

+ The prompt parameter tells our AI what kind of image to generate. + Be descriptive for best results! +

+ + + Next Steps + +

+ Ready to start using AI-powered placeholders in your projects? + Check out our documentation for more detailed examples and API reference. +

+ + + +); diff --git a/apps/landing/src/app/(landings)/blog/blog-posts.json b/apps/landing/src/app/(landings)/blog/blog-posts.json new file mode 100644 index 0000000..a1e6bea --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/blog-posts.json @@ -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" } + ] + } + ] +} diff --git a/apps/landing/src/app/(landings)/blog/page.tsx b/apps/landing/src/app/(landings)/blog/page.tsx new file mode 100644 index 0000000..9c92d04 --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/page.tsx @@ -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 ( +
+
+ + +

Blog

+

+ Articles, guides, and updates about AI-powered images. +

+ +
+ {posts.map((post) => ( + + ))} +
+ + {posts.length === 0 && ( +

+ No articles yet. Check back soon! +

+ )} +
+
+ ); +} diff --git a/apps/landing/src/app/(landings)/blog/types.ts b/apps/landing/src/app/(landings)/blog/types.ts new file mode 100644 index 0000000..dc7daca --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/types.ts @@ -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; +} diff --git a/apps/landing/src/app/(landings)/blog/utils.ts b/apps/landing/src/app/(landings)/blog/utils.ts new file mode 100644 index 0000000..f7e5d3e --- /dev/null +++ b/apps/landing/src/app/(landings)/blog/utils.ts @@ -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', + }); +}; diff --git a/apps/landing/src/config/footer.ts b/apps/landing/src/config/footer.ts index 1811c88..6f2d98c 100644 --- a/apps/landing/src/config/footer.ts +++ b/apps/landing/src/config/footer.ts @@ -1,4 +1,5 @@ export const footerLinks = [ + { label: 'Blog', href: '/blog/' }, { label: 'Documentation', href: '/docs/' }, { label: 'API Reference', href: '/docs/api/' }, // { label: 'Pricing', href: '#' },