Compare commits

...

4 Commits

Author SHA1 Message Date
Oleg Proskurin fd89bc424e fix: lcp 2026-01-20 21:06:34 +07:00
Oleg Proskurin b6dde32c35 feat: blog metatags 2026-01-20 20:03:34 +07:00
Oleg Proskurin 7a9997cf79 feat: update main blog 2026-01-20 19:28:34 +07:00
Oleg Proskurin cff9197a6b feat: redesign main blog 2026-01-20 17:58:25 +07:00
12 changed files with 426 additions and 25 deletions

View File

@ -0,0 +1,64 @@
import Link from 'next/link';
import Image from 'next/image';
import type { BlogPost } from '../types';
interface BlogArticleCardProps {
post: BlogPost;
}
const categoryColors: Record<string, string> = {
guides: 'bg-violet-500/10',
tutorials: 'bg-blue-500/10',
'use-cases': 'bg-pink-500/10',
news: 'bg-emerald-500/10',
};
const formatShortDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
};
export const BlogArticleCard = ({ post }: BlogArticleCardProps) => {
const overlayColor = categoryColors[post.category] || 'bg-violet-500/10';
return (
<Link
href={`/blog/${post.slug}`}
className="group flex flex-col bg-[#111827] rounded-xl overflow-hidden border border-white/5 hover:border-violet-500/50 transition-all hover:shadow-lg hover:shadow-violet-500/5 h-full"
>
<div className="aspect-video w-full relative overflow-hidden bg-gray-900">
<div
className={`absolute inset-0 ${overlayColor} group-hover:bg-transparent transition-colors z-10`}
/>
<Image
src={post.heroImage}
alt={post.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105 opacity-80 group-hover:opacity-100"
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
/>
<div className="absolute top-3 left-3 z-20">
<span className="inline-flex items-center rounded-md bg-black/60 backdrop-blur-md px-2.5 py-1 text-xs font-medium text-white ring-1 ring-inset ring-white/10 capitalize">
{post.category}
</span>
</div>
</div>
<div className="p-5 flex flex-col flex-1">
<h3 className="text-lg font-bold text-white mb-3 line-clamp-2 group-hover:text-violet-400 transition-colors leading-snug">
{post.title}
</h3>
<p className="text-sm text-gray-400 mb-4 line-clamp-2">
{post.description}
</p>
<div className="mt-auto flex items-center text-xs text-gray-500 font-medium">
<span>{formatShortDate(post.date)}</span>
<span className="mx-2"></span>
<span>{post.readTime}</span>
</div>
</div>
</Link>
);
};

View File

@ -0,0 +1,8 @@
export const BlogBackground = () => {
return (
<div className="fixed inset-0 z-0 pointer-events-none overflow-hidden">
<div className="absolute -top-[20%] -right-[10%] w-[800px] h-[800px] bg-violet-500/5 rounded-full blur-[120px]" />
<div className="absolute top-[10%] left-0 w-[500px] h-[500px] bg-blue-600/5 rounded-full blur-[100px]" />
</div>
);
};

View File

@ -0,0 +1,52 @@
import Link from 'next/link';
interface BlogCategoriesProps {
categories: Record<string, number>;
}
const categoryColors: Record<string, string> = {
guides: 'bg-violet-500',
tutorials: 'bg-blue-500',
'use-cases': 'bg-pink-500',
news: 'bg-emerald-500',
engineering: 'bg-blue-500',
product: 'bg-purple-500',
design: 'bg-green-500',
culture: 'bg-orange-500',
};
export const BlogCategories = ({ categories }: BlogCategoriesProps) => {
const entries = Object.entries(categories);
if (entries.length === 0) {
return null;
}
return (
<div className="rounded-xl bg-[#111827] border border-white/5 p-6">
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4 border-b border-white/5 pb-2">
Categories
</h4>
<nav className="flex flex-col space-y-1">
{entries.map(([category, count]) => {
const dotColor = categoryColors[category] || 'bg-gray-500';
return (
<Link
key={category}
href={`/blog?category=${category}`}
className="flex items-center justify-between px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-white/5 hover:text-white transition-colors group"
>
<div className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
<span className="capitalize">{category}</span>
</div>
<span className="text-xs bg-white/5 px-2 py-0.5 rounded text-gray-500 group-hover:text-gray-300 transition-colors">
{count}
</span>
</Link>
);
})}
</nav>
</div>
);
};

View File

@ -0,0 +1,58 @@
'use client';
import { useState } from 'react';
import { submitEmail } from '@/lib/actions/waitlistActions';
export const BlogNewsletter = () => {
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
setStatus('loading');
const result = await submitEmail(email);
if (result.success) {
setStatus('success');
setEmail('');
} else {
setStatus('error');
setTimeout(() => setStatus('idle'), 3000);
}
};
return (
<div className="rounded-xl bg-[#161b28] p-6 text-center border border-white/5 shadow-xl relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-violet-500/5 to-transparent pointer-events-none" />
<div className="relative z-10">
<h4 className="text-base font-bold text-white mb-2">Subscribe to Banatie</h4>
<p className="text-sm text-gray-400 mb-4 leading-relaxed">
Get the latest articles and updates delivered to your inbox.
</p>
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={status === 'loading' || status === 'success'}
className="block w-full rounded-lg border border-white/10 bg-[#0B0F19] py-2 px-3 text-white text-sm placeholder:text-gray-600 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={status === 'loading' || status === 'success'}
className="w-full rounded-lg bg-violet-500 px-3 py-2 text-sm font-semibold text-white shadow-md hover:bg-violet-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500 transition-all transform hover:-translate-y-0.5 disabled:opacity-50 disabled:hover:translate-y-0"
>
{status === 'loading' && 'Subscribing...'}
{status === 'success' && 'Subscribed!'}
{status === 'error' && 'Try again'}
{status === 'idle' && 'Subscribe'}
</button>
</form>
</div>
</div>
);
};

View File

@ -0,0 +1,50 @@
'use client';
import { useState } from 'react';
type TabType = 'latest' | 'popular';
interface BlogPageHeaderProps {
title?: string;
onTabChange?: (tab: TabType) => void;
}
export const BlogPageHeader = ({
title = 'Latest Articles',
onTabChange,
}: BlogPageHeaderProps) => {
const [activeTab, setActiveTab] = useState<TabType>('latest');
const handleTabClick = (tab: TabType) => {
setActiveTab(tab);
onTabChange?.(tab);
};
return (
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/5">
<h1 className="text-3xl font-bold text-white tracking-tight">{title}</h1>
<div className="flex items-center gap-2 text-sm text-gray-400 bg-white/5 p-1 rounded-lg border border-white/5">
<button
onClick={() => handleTabClick('latest')}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
activeTab === 'latest'
? 'bg-[#111827] text-white shadow-sm'
: 'hover:text-white'
}`}
>
Latest
</button>
<button
onClick={() => handleTabClick('popular')}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
activeTab === 'popular'
? 'bg-[#111827] text-white shadow-sm'
: 'hover:text-white'
}`}
>
Popular
</button>
</div>
</div>
);
};

View File

@ -78,6 +78,7 @@ export const BlogPostHeader = ({ post }: BlogPostHeaderProps) => {
height={600}
className="w-full h-auto object-cover aspect-[4/3]"
priority
fetchPriority="high"
/>
</div>
</div>

View File

@ -0,0 +1,16 @@
import { Search } from 'lucide-react';
export const BlogSearchInput = () => {
return (
<div className="relative group">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search className="w-4 h-4 text-gray-500 group-focus-within:text-violet-500 transition-colors" />
</div>
<input
type="text"
placeholder="Search articles..."
className="block w-full rounded-xl border border-white/10 bg-[#161b28] py-3 pl-10 pr-4 text-white placeholder:text-gray-500 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 sm:text-sm shadow-sm transition-all focus:outline-none"
/>
</div>
);
};

View File

@ -0,0 +1,30 @@
import Link from 'next/link';
interface BlogTagsProps {
tags: string[];
}
export const BlogTags = ({ tags }: BlogTagsProps) => {
if (tags.length === 0) {
return null;
}
return (
<div className="pt-4">
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
Popular Tags
</h4>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Link
key={tag}
href={`/blog?tag=${tag}`}
className="px-3 py-1 rounded-full bg-[#111827] border border-white/10 text-xs text-gray-400 hover:text-white hover:border-violet-500/50 transition-colors"
>
#{tag}
</Link>
))}
</div>
</div>
);
};

View File

@ -13,3 +13,11 @@ export { BlogInfoBox } from './BlogInfoBox';
export { BlogLeadParagraph } from './BlogLeadParagraph';
export { BlogServiceLink } from './BlogServiceLink';
export { BlogPricing } from './BlogPricing';
export { BlogBackground } from './BlogBackground';
export { BlogArticleCard } from './BlogArticleCard';
export { BlogPageHeader } from './BlogPageHeader';
export { BlogSearchInput } from './BlogSearchInput';
export { BlogCategories } from './BlogCategories';
export { BlogNewsletter } from './BlogNewsletter';
export { BlogTags } from './BlogTags';

View File

@ -1,38 +1,101 @@
import type { Metadata } from 'next';
import { getAllPosts } from './utils';
import { BlogCard, BlogBreadcrumbs } from './_components';
import {
BlogBackground,
BlogArticleCard,
BlogPageHeader,
// BlogSearchInput,
// BlogCategories,
BlogNewsletter,
// BlogTags,
} from './_components';
export const metadata: Metadata = {
title: 'Blog | Banatie',
description:
'Articles, guides, and updates about AI-powered placeholder images.',
'Articles, guides, and updates about AI-powered image generation.',
robots: 'index, follow',
alternates: {
canonical: '/blog/',
languages: {
en: '/blog/',
'x-default': '/blog/',
},
},
openGraph: {
type: 'website',
url: '/blog/',
title: 'Blog | Banatie',
description:
'Articles, guides, and updates about AI-powered image generation.',
siteName: 'Banatie',
locale: 'en_US',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
alt: 'Banatie Blog - AI Image Generation Articles',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Blog | Banatie',
description:
'Articles, guides, and updates about AI-powered image generation.',
images: ['/og-image.png'],
},
};
// const defaultTags = ['ai', 'image-generation', 'api', 'midjourney', 'flux'];
export default function BlogPage() {
const posts = getAllPosts();
// const categories = getCategories();
return (
<main className="py-12 px-6">
<div className="max-w-6xl mx-auto">
<BlogBreadcrumbs items={[{ label: 'Blog' }]} />
<>
<BlogBackground />
<main className="flex-grow bg-transparent relative z-10 pt-10 pb-12 lg:pt-16 lg:pb-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12">
<div className="lg:col-span-8 xl:col-span-9">
<BlogPageHeader />
<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 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{posts.map((post) => (
<BlogArticleCard key={post.slug} post={post} />
))}
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<BlogCard key={post.slug} post={post} />
))}
{posts.length === 0 && (
<p className="text-gray-500 text-center py-12">
No articles yet. Check back soon!
</p>
)}
{posts.length > 6 && (
<div className="mt-16 flex justify-center">
<button className="px-6 py-3 rounded-lg border border-white/10 bg-[#111827] text-sm font-medium text-white hover:bg-white/5 transition-colors">
Load More Articles
</button>
</div>
)}
</div>
<aside className="lg:col-span-4 xl:col-span-3 space-y-8">
{/* <BlogSearchInput /> */}
{/* <BlogCategories categories={categories} /> */}
<BlogNewsletter />
{/* <BlogTags tags={defaultTags} /> */}
</aside>
</div>
</div>
{posts.length === 0 && (
<p className="text-gray-500 text-center py-12">
No articles yet. Check back soon!
</p>
)}
</div>
</main>
</main>
</>
);
}

View File

@ -13,15 +13,42 @@ export const getPostsBySlugs = (slugs: string[]): BlogPost[] =>
.filter((post): post is BlogPost => post !== undefined);
export const generatePostMetadata = (post: BlogPost): Metadata => ({
title: post.title,
title: `${post.title} | Banatie Blog`,
description: post.description,
robots: 'index, follow',
alternates: {
canonical: `/blog/${post.slug}/`,
languages: {
en: `/blog/${post.slug}/`,
'x-default': `/blog/${post.slug}/`,
},
},
openGraph: {
type: 'article',
url: `/blog/${post.slug}/`,
title: post.title,
description: post.description,
siteName: 'Banatie',
locale: 'en_US',
images: [
{
url: post.heroImage,
width: 1200,
height: 630,
alt: post.title,
},
],
publishedTime: post.date,
authors: [post.author.name],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
images: [post.heroImage],
type: 'article',
publishedTime: post.date,
authors: [post.author.name],
},
});
@ -33,3 +60,13 @@ export const formatDate = (dateString: string): string => {
day: 'numeric',
});
};
export const getCategories = (): Record<string, number> => {
return blogPosts.reduce(
(acc, post) => {
acc[post.category] = (acc[post.category] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
};

View File

@ -1,4 +1,5 @@
import { MetadataRoute } from 'next';
import { blogPosts } from './(landings)/blog/blog-posts';
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = 'https://banatie.app';
@ -10,6 +11,19 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'weekly',
priority: 1,
},
// Blog
{
url: `${baseUrl}/blog/`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
...blogPosts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}/`,
lastModified: new Date(post.date),
changeFrequency: 'monthly' as const,
priority: 0.8,
})),
// Documentation - Guides
{
url: `${baseUrl}/docs/`,