feat: apply design for block article
This commit is contained in:
parent
fbe85fe6c9
commit
38b377496f
|
|
@ -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">
|
||||
<Content />
|
||||
</article>
|
||||
<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>
|
||||
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<div className="sticky top-24 space-y-8">
|
||||
{/* 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>
|
||||
|
||||
{/* 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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
<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>
|
||||
{displayName && (
|
||||
<span className="ml-4 text-xs font-mono text-gray-400">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-grow" />
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-xs text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,38 +10,77 @@ 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>
|
||||
|
||||
<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)} · {post.readTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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="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 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 ring-2 ring-[#0B0F19] object-cover"
|
||||
/>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* 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}
|
||||
width={800}
|
||||
height={600}
|
||||
className="w-full h-auto object-cover aspect-[4/3]"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -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">— {author}</footer>
|
||||
<footer className="mt-4 text-sm text-gray-500 font-normal not-italic">
|
||||
— {author}
|
||||
</footer>
|
||||
)}
|
||||
</blockquote>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
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>
|
||||
<Link
|
||||
key={doc.href}
|
||||
href={doc.href}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-violet-500 transition-colors group"
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] text-gray-400 group-hover:text-violet-500" />
|
||||
<span>{doc.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
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">
|
||||
<Link
|
||||
key={article.slug}
|
||||
href={`/blog/${article.slug}`}
|
||||
className="group block rounded-xl border border-gray-200 overflow-hidden bg-white hover:border-violet-500/50 transition-colors shadow-sm"
|
||||
>
|
||||
<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>
|
||||
<span className="text-sm text-gray-600 group-hover:text-purple-600 transition-colors line-clamp-2">
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</Link>
|
||||
</li>
|
||||
</h5>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatDate(article.date)} · {article.readTime}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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>
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => scrollToSection(item.id)}
|
||||
className={`
|
||||
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'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue