feat: redesign main blog

This commit is contained in:
Oleg Proskurin 2026-01-20 17:58:25 +07:00
parent 070ab0f689
commit cff9197a6b
10 changed files with 317 additions and 21 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,61 @@
'use client';
import { useState } from 'react';
import { Mail } from 'lucide-react';
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');
// TODO: Integrate with email service
// For now, just simulate success
await new Promise((resolve) => setTimeout(resolve, 500));
console.log('Newsletter subscription:', email);
setStatus('success');
setEmail('');
// Reset status after 3 seconds
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">
<div className="w-12 h-12 bg-violet-500/10 rounded-xl flex items-center justify-center mx-auto mb-4 border border-violet-500/20">
<Mail className="w-5 h-5 text-violet-400" />
</div>
<h4 className="text-base font-bold text-white mb-2">Subscribe to Banatie</h4>
<p className="text-sm text-gray-400 mb-6 leading-relaxed">
Get the latest technical articles, tutorials, and updates delivered right 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'}
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 === 'idle' || status === 'error') && 'Subscribe'}
</button>
</form>
</div>
</div>
);
};

View File

@ -0,0 +1,19 @@
interface BlogPageHeaderProps {
title?: string;
}
export const BlogPageHeader = ({ title = 'Latest Articles' }: BlogPageHeaderProps) => {
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 className="px-3 py-1.5 rounded-md bg-[#111827] text-white shadow-sm font-medium text-xs transition-all">
Latest
</button>
<button className="px-3 py-1.5 rounded-md hover:text-white text-xs font-medium transition-colors">
Popular
</button>
</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 { BlogLeadParagraph } from './BlogLeadParagraph';
export { BlogServiceLink } from './BlogServiceLink'; export { BlogServiceLink } from './BlogServiceLink';
export { BlogPricing } from './BlogPricing'; 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,6 +1,14 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getAllPosts } from './utils'; import { getAllPosts, getCategories } from './utils';
import { BlogCard, BlogBreadcrumbs } from './_components'; import {
BlogBackground,
BlogArticleCard,
BlogPageHeader,
BlogSearchInput,
BlogCategories,
BlogNewsletter,
BlogTags,
} from './_components';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Blog | Banatie', title: 'Blog | Banatie',
@ -8,31 +16,51 @@ export const metadata: Metadata = {
'Articles, guides, and updates about AI-powered placeholder images.', 'Articles, guides, and updates about AI-powered placeholder images.',
}; };
const defaultTags = ['ai', 'image-generation', 'api', 'midjourney', 'flux'];
export default function BlogPage() { export default function BlogPage() {
const posts = getAllPosts(); const posts = getAllPosts();
const categories = getCategories();
return ( return (
<main className="py-12 px-6"> <>
<div className="max-w-6xl mx-auto"> <BlogBackground />
<BlogBreadcrumbs items={[{ label: 'Blog' }]} /> <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> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<p className="text-xl text-gray-400 mb-12"> {posts.map((post) => (
Articles, guides, and updates about AI-powered images. <BlogArticleCard key={post.slug} post={post} />
</p> ))}
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> {posts.length === 0 && (
{posts.map((post) => ( <p className="text-gray-500 text-center py-12">
<BlogCard key={post.slug} post={post} /> 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> </div>
</main>
{posts.length === 0 && ( </>
<p className="text-gray-500 text-center py-12">
No articles yet. Check back soon!
</p>
)}
</div>
</main>
); );
} }

View File

@ -33,3 +33,13 @@ export const formatDate = (dateString: string): string => {
day: 'numeric', 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>
);
};