📄 Huge intl feature
This commit is contained in:
32
app/[locale]/blog/[...slug]/not-found.tsx
Normal file
32
app/[locale]/blog/[...slug]/not-found.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations('NotFound')
|
||||
|
||||
return (
|
||||
<div className="min-h-[60vh] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-gray-300 dark:text-gray-700 mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold mb-4">{t('title')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
{t('description')}
|
||||
</p>
|
||||
<div className="space-x-4">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
{t('goHome')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
{t('goHome')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
225
app/[locale]/blog/[...slug]/page.tsx
Normal file
225
app/[locale]/blog/[...slug]/page.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { Link } from '@/src/i18n/navigation'
|
||||
import { getAllPosts, getPostBySlug, getRelatedPosts } from '@/lib/markdown'
|
||||
import { formatDate, formatRelativeDate } from '@/lib/utils'
|
||||
import { TableOfContents } from '@/components/blog/table-of-contents'
|
||||
import { ReadingProgress } from '@/components/blog/reading-progress'
|
||||
import { StickyFooter } from '@/components/blog/sticky-footer'
|
||||
import MarkdownRenderer from '@/components/blog/markdown-renderer'
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { routing } from '@/src/i18n/routing'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const locales = ['en', 'ro']
|
||||
const allParams: Array<{ locale: string; slug: string[] }> = []
|
||||
|
||||
for (const locale of locales) {
|
||||
const posts = await getAllPosts(locale)
|
||||
posts.forEach(post => {
|
||||
allParams.push({ locale, slug: post.slug.split('/') })
|
||||
})
|
||||
}
|
||||
return allParams
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string[] }>
|
||||
}): Promise<Metadata> {
|
||||
const { slug, locale } = await params
|
||||
const slugPath = slug.join('/')
|
||||
const post = await getPostBySlug(slugPath, locale)
|
||||
|
||||
if (!post) {
|
||||
return { title: 'Articol negăsit' }
|
||||
}
|
||||
|
||||
return {
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
authors: [{ name: post.frontmatter.author }],
|
||||
openGraph: {
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
type: 'article',
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: [post.frontmatter.author],
|
||||
images: post.frontmatter.image ? [post.frontmatter.image] : [],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
images: post.frontmatter.image ? [post.frontmatter.image] : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function extractHeadings(content: string) {
|
||||
const headingRegex = /^(#{2,3})\s+(.+)$/gm
|
||||
const headings: { id: string; text: string; level: number }[] = []
|
||||
let match
|
||||
|
||||
while ((match = headingRegex.exec(content)) !== null) {
|
||||
const level = match[1].length
|
||||
const text = match[2]
|
||||
const id = text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
headings.push({ id, text, level })
|
||||
}
|
||||
|
||||
return headings
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string[] }>
|
||||
}) {
|
||||
const { slug, locale } = await params
|
||||
const slugPath = slug.join('/')
|
||||
const post = await getPostBySlug(slugPath, locale)
|
||||
|
||||
if (!post) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const relatedPosts = await getRelatedPosts(slugPath)
|
||||
const headings = extractHeadings(post.content)
|
||||
const fullUrl = `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3030'}/blog/${slugPath}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReadingProgress />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-16">
|
||||
<div className="flex gap-12">
|
||||
<TableOfContents headings={headings} />
|
||||
|
||||
<article className="flex-1 min-w-0">
|
||||
<header className="mb-16 border border-[var(--neon-cyan)] bg-[rgb(var(--bg-primary))] p-8 relative">
|
||||
<div className="border-b border-[var(--neon-pink)] pb-4 mb-6 relative">
|
||||
<div className="flex items-center gap-3 mb-3 justify-end">
|
||||
<p className="font-mono text-xs text-[var(--neon-cyan)] uppercase tracking-widest">
|
||||
>> _DOC://PUBLIC_ACCESS
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-4 h-4 border border-[rgb(var(--border-primary))] hover:bg-red-500/10 cursor-pointer" />
|
||||
<div className="w-4 h-4 border border-[rgb(var(--border-primary))] hover:bg-yellow-500/10 cursor-pointer" />
|
||||
<div className="w-4 h-4 border border-[rgb(var(--border-primary))] hover:bg-green-500/10 cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{post.frontmatter.tags.map((tag: string) => (
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/tags/${tag}`}
|
||||
className="px-3 py-1 bg-cyan-500/5 border border-[var(--neon-cyan)] text-cyan-400 text-xs font-mono uppercase shadow-[0_0_8px_rgba(90,139,149,0.3)] hover:shadow-[0_0_12px_rgba(90,139,149,0.5)] transition-all"
|
||||
>
|
||||
#{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-l border-[var(--neon-magenta)] pl-6 relative">
|
||||
<h1 className="text-4xl md:text-5xl font-mono font-bold text-[var(--neon-cyan)] uppercase tracking-tight leading-tight mb-6">
|
||||
{post.frontmatter.title}
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-[rgb(var(--text-secondary))] leading-relaxed mb-6 font-mono">
|
||||
<span className="text-[var(--neon-pink)]">>></span>{' '}
|
||||
{post.frontmatter.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-6 border-t border-[var(--neon-purple)] relative">
|
||||
<div className="w-12 h-12 border border-[var(--neon-cyan)] bg-[rgb(var(--bg-secondary))] flex items-center justify-center">
|
||||
<span className="font-mono text-[var(--neon-cyan)] text-xs">
|
||||
{post.frontmatter.author.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono font-bold text-[var(--neon-cyan)] uppercase text-sm">
|
||||
{post.frontmatter.author}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-[rgb(var(--text-muted))] font-mono">
|
||||
<time className="text-[var(--neon-magenta)]">
|
||||
{formatDate(post.frontmatter.date)}
|
||||
</time>
|
||||
<span className="text-[var(--neon-pink)]">//</span>
|
||||
<span className="text-[var(--neon-cyan)]">{post.readingTime}min READ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{post.frontmatter.image && (
|
||||
<div className="relative aspect-video mb-16 border border-[var(--neon-pink)] overflow-hidden">
|
||||
<img
|
||||
src={post.frontmatter.image}
|
||||
alt={post.frontmatter.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prose prose-invert prose-lg max-w-none cyberpunk-prose">
|
||||
<MarkdownRenderer content={post.content} />
|
||||
</div>
|
||||
|
||||
{relatedPosts.length > 0 && (
|
||||
<section className="mt-12 pt-8 border-t border-zinc-800">
|
||||
<h2 className="text-2xl font-mono font-bold uppercase text-[var(--neon-cyan)] mb-6">
|
||||
// Articole similare
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{relatedPosts.map(relatedPost => (
|
||||
<Link
|
||||
key={relatedPost.slug}
|
||||
href={`/blog/${relatedPost.slug}`}
|
||||
className="block p-4 border border-zinc-800 bg-zinc-950 hover:border-[var(--neon-cyan)] transition-all hover:shadow-[0_0_8px_rgba(90,139,149,0.2)]"
|
||||
>
|
||||
<h3 className="font-mono font-semibold text-cyan-400 mb-2 line-clamp-2">
|
||||
{relatedPost.frontmatter.title}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400 line-clamp-2">
|
||||
{relatedPost.frontmatter.description}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-600 mt-2 font-mono">
|
||||
{formatDate(relatedPost.frontmatter.date)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<nav className="flex justify-between items-center mt-12 pt-8 border-t border-zinc-800">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="flex items-center text-[var(--neon-pink)] hover:text-[var(--neon-magenta)] transition-all font-mono text-sm uppercase border border-[var(--neon-pink)] px-4 py-2 hover:shadow-[0_0_6px_rgba(155,90,110,0.3)]"
|
||||
>
|
||||
<svg className="mr-2 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
[BACK TO BLOG]
|
||||
</Link>
|
||||
</nav>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StickyFooter url={fullUrl} title={post.frontmatter.title} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
175
app/[locale]/blog/blog-client.tsx
Normal file
175
app/[locale]/blog/blog-client.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Post } from '@/lib/types/frontmatter'
|
||||
import { BlogCard } from '@/components/blog/blog-card'
|
||||
import { SearchBar } from '@/components/blog/search-bar'
|
||||
import { SortDropdown } from '@/components/blog/sort-dropdown'
|
||||
import { TagFilter } from '@/components/blog/tag-filter'
|
||||
import { Navbar } from '@/components/blog/navbar'
|
||||
|
||||
interface BlogPageClientProps {
|
||||
posts: Post[]
|
||||
allTags: string[]
|
||||
}
|
||||
|
||||
type SortOption = 'newest' | 'oldest' | 'title'
|
||||
|
||||
export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) {
|
||||
const t = useTranslations('BlogListing')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([])
|
||||
const [sortBy, setSortBy] = useState<SortOption>('newest')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const postsPerPage = 9
|
||||
|
||||
const filteredAndSortedPosts = useMemo(() => {
|
||||
const result = posts.filter(post => {
|
||||
const matchesSearch =
|
||||
searchQuery === '' ||
|
||||
post.frontmatter.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
post.frontmatter.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesTags =
|
||||
selectedTags.length === 0 || selectedTags.every(tag => post.frontmatter.tags.includes(tag))
|
||||
|
||||
return matchesSearch && matchesTags
|
||||
})
|
||||
|
||||
result.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'oldest':
|
||||
return new Date(a.frontmatter.date).getTime() - new Date(b.frontmatter.date).getTime()
|
||||
case 'title':
|
||||
return a.frontmatter.title.localeCompare(b.frontmatter.title)
|
||||
case 'newest':
|
||||
default:
|
||||
return new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}, [posts, searchQuery, selectedTags, sortBy])
|
||||
|
||||
const totalPages = Math.ceil(filteredAndSortedPosts.length / postsPerPage)
|
||||
const paginatedPosts = filteredAndSortedPosts.slice(
|
||||
(currentPage - 1) * postsPerPage,
|
||||
currentPage * postsPerPage
|
||||
)
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags(prev => (prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]))
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[rgb(var(--bg-primary))]">
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="border-l border-[var(--neon-cyan)] pl-6 mb-12">
|
||||
<p className="font-mono text-xs text-[rgb(var(--text-muted))] uppercase tracking-widest mb-2">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-6xl font-mono font-bold text-[rgb(var(--text-primary))] uppercase tracking-tight">
|
||||
> {t("title")}_
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="border border-[rgb(var(--border-primary))] bg-[rgb(var(--bg-secondary))] p-6 mb-8">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<SearchBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={value => {
|
||||
setSearchQuery(value)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
<SortDropdown sortBy={sortBy} onSortChange={setSortBy} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag Filters */}
|
||||
<TagFilter
|
||||
allTags={allTags}
|
||||
selectedTags={selectedTags}
|
||||
onToggleTag={toggleTag}
|
||||
onClearTags={() => {
|
||||
setSelectedTags([])
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="mb-6">
|
||||
<p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase">
|
||||
{t("foundPosts", {count: filteredAndSortedPosts.length})}{' '}
|
||||
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Blog Grid */}
|
||||
{paginatedPosts.length > 0 ? (
|
||||
<div className="grid gap-8 lg:grid-cols-3 md:grid-cols-2 grid-cols-1 mb-12">
|
||||
{paginatedPosts.map((post, index) => {
|
||||
const hasImage = !!post.frontmatter.image
|
||||
let variant: 'image-top' | 'image-side' | 'text-only'
|
||||
|
||||
if (!hasImage) {
|
||||
variant = 'text-only'
|
||||
} else {
|
||||
variant = index % 3 === 1 ? 'image-side' : 'image-top'
|
||||
}
|
||||
|
||||
return <BlogCard key={post.slug} post={post} variant={variant} />
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-[rgb(var(--border-primary))] bg-[rgb(var(--bg-secondary))] p-12 text-center">
|
||||
<p className="font-mono text-lg text-[rgb(var(--text-muted))] uppercase">
|
||||
{t("noPosts")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="border border-[rgb(var(--border-primary))] bg-[rgb(var(--bg-secondary))] p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-6 py-3 font-mono text-sm uppercase border border-[rgb(var(--border-primary))] text-[rgb(var(--text-primary))] disabled:opacity-30 disabled:cursor-not-allowed hover:border-[var(--neon-cyan)] hover:text-[var(--neon-cyan)] transition-colors cursor-pointer"
|
||||
>
|
||||
< PREV
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`w-12 h-12 font-mono text-sm border transition-colors cursor-pointer ${
|
||||
currentPage === page
|
||||
? 'bg-[var(--neon-cyan)] border-[var(--neon-cyan)] text-white'
|
||||
: 'border-[rgb(var(--border-primary))] text-[rgb(var(--text-muted))] hover:border-[var(--neon-cyan)] hover:text-[var(--neon-cyan)]'
|
||||
}`}
|
||||
>
|
||||
{String(page).padStart(2, '0')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-6 py-3 font-mono text-sm uppercase border border-[rgb(var(--border-primary))] text-[rgb(var(--text-primary))] disabled:opacity-30 disabled:cursor-not-allowed hover:border-[var(--neon-cyan)] hover:text-[var(--neon-cyan)] transition-colors cursor-pointer"
|
||||
>
|
||||
NEXT >
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
app/[locale]/blog/layout.tsx
Normal file
16
app/[locale]/blog/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Metadata } from 'next'
|
||||
import { Navbar } from '@/components/blog/navbar'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog',
|
||||
description: 'Toate articolele din blog',
|
||||
}
|
||||
|
||||
export default function BlogLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
10
app/[locale]/blog/page.tsx
Normal file
10
app/[locale]/blog/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getAllPosts } from '@/lib/markdown'
|
||||
import BlogPageClient from './blog-client'
|
||||
import {setRequestLocale} from 'next-intl/server'
|
||||
|
||||
export default async function BlogPage() {
|
||||
const posts = await getAllPosts()
|
||||
const allTags = Array.from(new Set(posts.flatMap(post => post.frontmatter.tags))).sort()
|
||||
|
||||
return <BlogPageClient posts={posts} allTags={allTags} />
|
||||
}
|
||||
Reference in New Issue
Block a user