🖼️ blog listing styles updates
This commit is contained in:
15
app/blog/layout.tsx
Normal file
15
app/blog/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Metadata } from 'next'
|
||||
import { getAllPosts } from '@/lib/markdown'
|
||||
import BlogPageClient from './page'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog',
|
||||
description: 'Toate articolele din blog',
|
||||
}
|
||||
|
||||
export default async function BlogLayout() {
|
||||
const posts = await getAllPosts()
|
||||
const allTags = Array.from(new Set(posts.flatMap((post) => post.frontmatter.tags))).sort()
|
||||
|
||||
return <BlogPageClient posts={posts} allTags={allTags} />
|
||||
}
|
||||
@@ -1,94 +1,179 @@
|
||||
import { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getAllPosts } from '@/lib/markdown'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
'use client'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog',
|
||||
description: 'Toate articolele din blog',
|
||||
import { useMemo, useState } from 'react'
|
||||
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[]
|
||||
}
|
||||
|
||||
function PostCard({ post }: { post: any }) {
|
||||
return (
|
||||
<article className="border-b border-gray-200 dark:border-gray-700 pb-8 mb-8 last:border-0">
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{post.frontmatter.image && (
|
||||
<div className="lg:w-1/3">
|
||||
<img src={post.frontmatter.image} alt={post.frontmatter.title} className="w-full h-48 lg:h-full object-cover rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
<div className={post.frontmatter.image ? 'lg:w-2/3' : 'w-full'}>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 mb-2">
|
||||
<time dateTime={post.frontmatter.date}>{formatDate(post.frontmatter.date)}</time>
|
||||
<span>•</span>
|
||||
<span>{post.readingTime} min citire</span>
|
||||
<span>•</span>
|
||||
<span>{post.frontmatter.author}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3">
|
||||
<Link href={`/blog/${post.slug}`} className="hover:text-primary-600 transition">
|
||||
{post.frontmatter.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">{post.frontmatter.description}</p>
|
||||
{post.frontmatter.tags && post.frontmatter.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{post.frontmatter.tags.map((tag: string) => (
|
||||
<span key={tag} className="px-3 py-1 bg-gray-100 dark:bg-gray-800 text-sm rounded-full">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/blog/${post.slug}`} className="inline-flex items-center text-primary-600 hover:text-primary-700 transition">
|
||||
Citește articolul complet
|
||||
<svg className="ml-2 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
type SortOption = 'newest' | 'oldest' | 'title'
|
||||
|
||||
export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) {
|
||||
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(() => {
|
||||
let 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
|
||||
)
|
||||
}
|
||||
|
||||
function BlogFilters({ totalPosts }: { totalPosts: number }) {
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-6 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Articole Blog</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{totalPosts} {totalPosts === 1 ? 'articol' : 'articole'} publicate
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function BlogPage() {
|
||||
const posts = await getAllPosts()
|
||||
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h1 className="text-3xl font-bold mb-4">Blog</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">Nu există articole publicate încă.</p>
|
||||
<Link href="/" className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition">
|
||||
Înapoi la pagina principală
|
||||
</Link>
|
||||
</div>
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||
)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<BlogFilters totalPosts={posts.length} />
|
||||
<div>
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.slug} post={post} />
|
||||
))}
|
||||
<div className="min-h-screen bg-zinc-900">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="border-l-4 border-cyan-400 pl-6 mb-12">
|
||||
<p className="font-mono text-xs text-zinc-500 uppercase tracking-widest mb-2">
|
||||
DATABASE QUERY // SEARCH RESULTS
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-6xl font-mono font-bold text-zinc-100 uppercase tracking-tight">
|
||||
> BLOG ARCHIVE_
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="border-4 border-slate-700 bg-slate-900 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-zinc-500 uppercase">
|
||||
FOUND {filteredAndSortedPosts.length} {filteredAndSortedPosts.length === 1 ? 'POST' : 'POSTS'}
|
||||
</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-4 border-slate-700 bg-slate-900 p-12 text-center">
|
||||
<p className="font-mono text-lg text-zinc-400 uppercase">
|
||||
NO POSTS FOUND // TRY DIFFERENT SEARCH TERMS
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="border-4 border-slate-700 bg-slate-900 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-2 border-slate-700 text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed hover:border-cyan-400 hover:text-cyan-400 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-2 transition-colors cursor-pointer ${
|
||||
currentPage === page
|
||||
? 'bg-cyan-400 border-cyan-400 text-slate-900'
|
||||
: 'border-slate-700 text-zinc-400 hover:border-cyan-400 hover:text-cyan-400'
|
||||
}`}
|
||||
>
|
||||
{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-2 border-slate-700 text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed hover:border-cyan-400 hover:text-cyan-400 transition-colors cursor-pointer"
|
||||
>
|
||||
NEXT >
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user