feat: apply design for block article

This commit is contained in:
Oleg Proskurin 2026-01-18 00:43:15 +07:00
parent fbe85fe6c9
commit 38b377496f
13 changed files with 390 additions and 134 deletions

View File

@ -8,9 +8,9 @@ import {
} from '../utils';
import {
BlogPostHeader,
BlogBreadcrumbs,
BlogTOC,
BlogSidebar,
BlogShareButtons,
} from '../_components';
interface PageProps {
@ -42,29 +42,26 @@ export default async function BlogPostPage({ params }: PageProps) {
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">
<div className="bg-white border-t-0 -mt-1 pt-12 lg:pt-16 pb-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="lg:grid lg:grid-cols-12 lg:gap-12">
{/* Share buttons column - hidden on mobile */}
<aside className="hidden lg:block lg:col-span-1">
<BlogShareButtons url={`/blog/${post.slug}`} title={post.title} />
</aside>
{/* Article content */}
<div className="lg:col-span-8">
<article className="max-w-none text-gray-700 leading-relaxed [&>p]:mb-4 [&>ul]:mb-4 [&>ul]:list-disc [&>ul]:pl-6 [&>ul_li]:mb-2 [&>ul]:marker:text-violet-500 [&>ol]:mb-4 [&>ol]:list-decimal [&>ol]:pl-6 [&>a]:text-violet-500 [&>a]:hover:underline [&_strong]:text-gray-900 [&_strong]:font-semibold">
<Content />
</article>
</div>
<aside className="hidden lg:block w-64 flex-shrink-0">
<div className="sticky top-24 space-y-8">
{/* Sidebar - hidden on mobile */}
<aside className="hidden lg:block lg:col-span-3">
<div className="sticky top-28 space-y-8">
<BlogTOC items={tocItems} />
<BlogSidebar
relatedArticles={relatedArticles}

View File

@ -1,26 +1,58 @@
'use client';
import { useState } from 'react';
interface BlogCodeBlockProps {
children: string;
language?: string;
filename?: string;
}
export const BlogCodeBlock = ({
children,
language = 'text',
filename,
}: BlogCodeBlockProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const displayName = filename || language;
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 className="my-8 overflow-hidden rounded-xl border border-gray-200 bg-[#1e1e1e] shadow-xl">
<div className="flex items-center justify-between border-b border-white/5 bg-[#252526] px-4 py-2">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-[#ff5f56]" />
<div className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
<div className="h-3 w-3 rounded-full bg-[#27c93f]" />
</div>
{displayName && (
<span className="ml-4 text-xs font-mono text-gray-400">
{displayName}
</span>
)}
<pre
className={`bg-gray-50 border border-gray-200 p-4 overflow-x-auto text-sm ${
language ? 'rounded-tl-none rounded-lg' : 'rounded-lg'
}`}
<div className="flex-grow" />
<button
onClick={handleCopy}
className="text-xs text-gray-400 hover:text-white transition-colors"
>
<code className="text-gray-800">{children}</code>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<div className="p-6 overflow-x-auto blog-scrollbar">
<pre className="font-mono text-sm leading-relaxed text-[#d4d4d4] m-0 p-0 bg-transparent">
<code>{children}</code>
</pre>
</div>
</div>
);
};

View File

@ -7,7 +7,7 @@ interface BlogHeadingProps {
export const BlogHeading = ({ id, level, children }: BlogHeadingProps) => {
const Tag = `h${level}` as const;
const baseStyles = 'scroll-mt-24 text-gray-900 font-semibold';
const baseStyles = 'scroll-mt-28 text-gray-900 font-bold tracking-tight';
const levelStyles = {
2: 'text-2xl mt-12 mb-4 first:mt-0',
3: 'text-xl mt-8 mb-3',

View File

@ -1,4 +1,5 @@
import Image from 'next/image';
import { ImageIcon } from 'lucide-react';
interface BlogImageProps {
src: string;
@ -14,13 +15,21 @@ export const BlogImage = ({
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" />
<figure className={`my-10 group ${fullWidth ? '-mx-6' : ''}`}>
<div className="overflow-hidden rounded-xl border border-gray-200 shadow-lg transition-all duration-300 hover:shadow-xl">
<div className="relative aspect-video bg-gray-100">
<Image
src={src}
alt={alt}
fill
className="object-cover transform transition-transform duration-500 group-hover:scale-[1.02]"
/>
</div>
</div>
{caption && (
<figcaption className="mt-2 text-sm text-gray-500 text-center">
{caption}
<figcaption className="mt-4 flex items-center justify-center gap-2 text-sm text-gray-500">
<ImageIcon className="w-4 h-4" />
<span>{caption}</span>
</figcaption>
)}
</figure>

View File

@ -0,0 +1,55 @@
import { Info, Lightbulb, AlertTriangle } from 'lucide-react';
type InfoBoxType = 'info' | 'tip' | 'warning';
interface BlogInfoBoxProps {
type?: InfoBoxType;
title: string;
children: React.ReactNode;
}
const typeConfig = {
info: {
icon: Info,
borderColor: 'border-blue-500',
bgColor: 'bg-blue-50',
iconColor: 'text-blue-600',
},
tip: {
icon: Lightbulb,
borderColor: 'border-amber-500',
bgColor: 'bg-amber-50',
iconColor: 'text-amber-600',
},
warning: {
icon: AlertTriangle,
borderColor: 'border-red-500',
bgColor: 'bg-red-50',
iconColor: 'text-red-600',
},
};
export const BlogInfoBox = ({
type = 'info',
title,
children,
}: BlogInfoBoxProps) => {
const config = typeConfig[type];
const Icon = config.icon;
return (
<div
className={`my-8 rounded-lg border-l-4 ${config.borderColor} ${config.bgColor} p-6 shadow-sm`}
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 mt-0.5">
<Icon className={`w-5 h-5 ${config.iconColor}`} />
</div>
<div>
<h5 className="font-bold text-gray-900 mt-0 mb-2">{title}</h5>
<div className="text-sm text-gray-700 leading-relaxed">{children}</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,11 @@
interface BlogLeadParagraphProps {
children: React.ReactNode;
}
export const BlogLeadParagraph = ({ children }: BlogLeadParagraphProps) => {
return (
<p className="lead text-xl text-gray-600 mb-8 font-light leading-relaxed">
{children}
</p>
);
};

View File

@ -1,6 +1,8 @@
import Image from 'next/image';
import { Clock } from 'lucide-react';
import type { BlogPost } from '../types';
import { formatDate } from '../utils';
import { BlogBreadcrumbs } from './BlogBreadcrumbs';
interface BlogPostHeaderProps {
post: BlogPost;
@ -8,40 +10,79 @@ interface BlogPostHeaderProps {
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>
<header className="relative overflow-hidden bg-[#0B0F19] text-white pt-12 pb-16 lg:pt-20 lg:pb-24">
{/* Blur blob decorations */}
<div className="absolute inset-0 z-0">
<div className="absolute -top-24 -left-24 w-96 h-96 bg-violet-500/20 rounded-full blur-3xl" />
<div className="absolute top-1/2 right-0 w-[500px] h-[500px] bg-pink-600/10 rounded-full blur-3xl transform translate-x-1/3 -translate-y-1/2" />
</div>
<div className="flex items-center gap-4">
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="lg:grid lg:grid-cols-12 lg:gap-16 items-center">
{/* Left column - Content */}
<div className="lg:col-span-7 mb-12 lg:mb-0">
<div className="mb-8">
<BlogBreadcrumbs
items={[
{ label: 'Blog', href: '/blog' },
{ label: post.category },
{ label: post.title },
]}
/>
</div>
<div className="flex items-center gap-2 mb-8">
<span className="inline-flex items-center rounded-full bg-violet-500/20 px-3 py-1 text-xs font-medium text-violet-400 ring-1 ring-inset ring-violet-500/30">
{post.category}
</span>
<span className="text-gray-400 text-sm flex items-center gap-1 ml-2">
<Clock className="w-4 h-4" />
{post.readTime}
</span>
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold tracking-tight mb-8 leading-tight text-white">
{post.title}
</h1>
<p className="text-lg sm:text-xl text-gray-300 mb-10 max-w-2xl leading-relaxed">
{post.description}
</p>
<div className="flex items-center gap-4 border-t border-white/10 pt-8">
<Image
src={post.author.avatar}
alt={post.author.name}
width={48}
height={48}
className="rounded-full"
className="rounded-full ring-2 ring-[#0B0F19] object-cover"
/>
<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 className="font-medium text-white text-base">
{post.author.name}
</div>
<div className="text-sm text-gray-400">
{formatDate(post.date)}
</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">
{/* Right column - Hero image */}
<div className="lg:col-span-5 relative">
<div className="relative rounded-xl overflow-hidden shadow-2xl ring-1 ring-white/10 bg-black/40 backdrop-blur-sm">
<Image
src={post.heroImage}
alt={post.title}
fill
className="object-cover"
width={800}
height={600}
className="w-full h-auto object-cover aspect-[4/3]"
priority
/>
</div>
</div>
</div>
</div>
</header>
);
};

View File

@ -5,10 +5,12 @@ interface BlogQuoteProps {
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>
<blockquote className="my-10 border-l-4 border-violet-500 bg-gray-50 p-6 text-xl italic font-medium leading-relaxed text-gray-800 shadow-sm rounded-r-lg">
<p className="m-0">{children}</p>
{author && (
<footer className="mt-2 text-sm text-gray-500">&mdash; {author}</footer>
<footer className="mt-4 text-sm text-gray-500 font-normal not-italic">
&mdash; {author}
</footer>
)}
</blockquote>
);

View File

@ -0,0 +1,60 @@
'use client';
import { Link as LinkIcon } from 'lucide-react';
interface BlogShareButtonsProps {
url?: string;
title?: string;
}
export const BlogShareButtons = ({ url, title }: BlogShareButtonsProps) => {
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
const shareTitle = title || '';
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
} catch (err) {
console.error('Failed to copy link:', err);
}
};
const twitterUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(shareTitle)}`;
const linkedinUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
return (
<div className="sticky top-28 flex flex-col gap-4 items-center">
<a
href={twitterUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Share on Twitter"
className="p-2 rounded-full bg-white text-gray-500 hover:text-violet-500 transition-colors border border-gray-200 shadow-sm"
>
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
</svg>
</a>
<a
href={linkedinUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Share on LinkedIn"
className="p-2 rounded-full bg-white text-gray-500 hover:text-blue-600 transition-colors border border-gray-200 shadow-sm"
>
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
</svg>
</a>
<button
onClick={handleCopyLink}
aria-label="Copy Link"
className="p-2 rounded-full bg-white text-gray-500 hover:text-gray-900 transition-colors border border-gray-200 shadow-sm"
>
<LinkIcon className="w-5 h-5" />
</button>
</div>
);
};

View File

@ -1,7 +1,8 @@
import Link from 'next/link';
import Image from 'next/image';
import { BookOpen, Code, FileText, ExternalLink } from 'lucide-react';
import { BookOpen, Code, FileText, Terminal, Webhook } from 'lucide-react';
import type { BlogPost, RelatedDoc } from '../types';
import { formatDate } from '../utils';
interface BlogSidebarProps {
relatedArticles: BlogPost[];
@ -12,72 +13,100 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
book: BookOpen,
code: Code,
file: FileText,
terminal: Terminal,
webhook: Webhook,
};
export const BlogSidebar = ({
relatedArticles,
relatedDocs,
}: BlogSidebarProps) => {
if (relatedArticles.length === 0 && relatedDocs.length === 0) {
return null;
}
return (
<aside className="space-y-8">
<div className="space-y-8">
{relatedDocs.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-4">
Related Documentation
</h3>
<ul className="space-y-2">
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
Related Docs
</h4>
<div className="space-y-3 text-sm">
{relatedDocs.map((doc) => {
const Icon = iconMap[doc.icon] || FileText;
return (
<li key={doc.href}>
<Link
key={doc.href}
href={doc.href}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-purple-600 transition-colors"
className="flex items-center gap-2 text-gray-600 hover:text-violet-500 transition-colors group"
>
<Icon className="w-4 h-4" />
<Icon className="w-[18px] h-[18px] text-gray-400 group-hover:text-violet-500" />
<span>{doc.title}</span>
<ExternalLink className="w-3 h-3 ml-auto" />
</Link>
</li>
);
})}
</ul>
</div>
</div>
)}
<div className="rounded-xl border border-gray-700 bg-slate-800 p-6 shadow-xl relative overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
<div className="relative z-10">
<h4 className="font-bold text-white text-lg mb-2">
Build faster with Banatie
</h4>
<p className="text-sm text-gray-400 mb-6 leading-relaxed">
Integrate AI image generation into your app in minutes. Start for
free.
</p>
<Link
href="/#get-access"
className="block w-full rounded-lg bg-violet-500 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-lg hover:bg-violet-600 transition-all transform hover:-translate-y-0.5"
>
Get API Key
</Link>
</div>
</div>
{relatedArticles.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-4">
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
Related Articles
</h3>
<ul className="space-y-3">
</h4>
<div className="space-y-6">
{relatedArticles.map((article) => (
<li key={article.slug}>
<Link
key={article.slug}
href={`/blog/${article.slug}`}
className="flex items-start gap-3 group"
className="group block rounded-xl border border-gray-200 overflow-hidden bg-white hover:border-violet-500/50 transition-colors shadow-sm"
>
<div className="w-16 h-12 relative rounded overflow-hidden flex-shrink-0 bg-gray-100">
<div className="aspect-video w-full bg-gray-100 relative overflow-hidden">
{article.heroImage ? (
<Image
src={article.heroImage}
alt={article.title}
fill
className="object-cover"
className="object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : (
<>
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/10 to-pink-500/10 group-hover:scale-105 transition-transform duration-500" />
<div className="absolute inset-0 flex items-center justify-center text-violet-500/40">
<FileText className="w-10 h-10" />
</div>
<span className="text-sm text-gray-600 group-hover:text-purple-600 transition-colors line-clamp-2">
</>
)}
</div>
<div className="p-4">
<h5 className="text-base font-semibold text-gray-900 group-hover:text-violet-500 transition-colors leading-tight mb-2">
{article.title}
</span>
</h5>
<p className="text-xs text-gray-500">
{formatDate(article.date)} &middot; {article.readTime}
</p>
</div>
</Link>
</li>
))}
</ul>
</div>
</div>
)}
</aside>
</div>
);
};

View File

@ -42,37 +42,34 @@ export const BlogTOC = ({ items }: BlogTOCProps) => {
}
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">
<div className="rounded-xl bg-gray-50 border border-gray-200 p-5 shadow-sm">
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4 border-b border-gray-200 pb-2">
On This Page
</h3>
<ul className="space-y-2.5 text-sm">
</h4>
<nav className="flex flex-col space-y-3 text-sm" aria-label="Table of contents">
{items.map((item) => {
const isActive = activeId === item.id;
const isH3 = item.level === 3;
return (
<li key={item.id} className={isH3 ? 'ml-4' : ''}>
<button
key={item.id}
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'}
text-left pl-2 border-l-2 transition-colors
${isH3 ? 'ml-3' : ''}
${
isActive
? 'text-gray-900 font-medium border-violet-500'
: 'text-gray-500 border-transparent hover:text-gray-900'
}
`}
>
<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>
{item.text}
</button>
</li>
);
})}
</ul>
</nav>
</div>
);
};

View File

@ -8,3 +8,6 @@ export { BlogImage } from './BlogImage';
export { BlogQuote } from './BlogQuote';
export { BlogCTA } from './BlogCTA';
export { BlogCodeBlock } from './BlogCodeBlock';
export { BlogShareButtons } from './BlogShareButtons';
export { BlogInfoBox } from './BlogInfoBox';
export { BlogLeadParagraph } from './BlogLeadParagraph';

View File

@ -148,3 +148,23 @@ pre::-webkit-scrollbar-thumb {
pre::-webkit-scrollbar-thumb:hover {
background: rgb(148, 163, 184); /* slate-400 */
}
/* Blog code block scrollbar - light track for dark code blocks */
.blog-scrollbar::-webkit-scrollbar {
height: 8px;
width: 8px;
}
.blog-scrollbar::-webkit-scrollbar-track {
background: #1f2937;
border-radius: 4px;
}
.blog-scrollbar::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
.blog-scrollbar::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}