🪛 03 routing

This commit is contained in:
RJ
2025-11-07 16:55:58 +02:00
parent 651beb2de6
commit 05390016b2
8 changed files with 441 additions and 14 deletions

47
app/about/page.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Despre',
description: 'Află mai multe despre mine și acest blog',
}
export default function AboutPage() {
return (
<div className="max-w-3xl mx-auto">
<h1 className="text-4xl font-bold mb-8">Despre Mine</h1>
<div className="prose dark:prose-invert max-w-none">
<p className="text-lg leading-relaxed mb-6">
Bun venit pe blogul meu! Sunt un dezvoltator pasionat de tehnologie,
specializat în dezvoltarea web modernă cu Next.js, React și TypeScript.
</p>
<h2 className="text-2xl font-semibold mt-8 mb-4">Ce vei găsi aici</h2>
<ul className="space-y-2">
<li>Tutoriale despre dezvoltare web</li>
<li>Ghiduri practice pentru Next.js și React</li>
<li>Sfaturi despre design și UX</li>
<li>Experiențe din proiecte reale</li>
</ul>
<h2 className="text-2xl font-semibold mt-8 mb-4">Tehnologii folosite</h2>
<p>Acest blog este construit cu:</p>
<ul className="space-y-2">
<li><strong>Next.js 15</strong> - Framework React pentru producție</li>
<li><strong>TypeScript</strong> - Pentru type safety</li>
<li><strong>Tailwind CSS</strong> - Pentru stilizare rapidă</li>
<li><strong>Markdown</strong> - Pentru conținut</li>
</ul>
<h2 className="text-2xl font-semibold mt-8 mb-4">Contact</h2>
<p>
poți contacta pe{' '}
<a href="mailto:email@example.com" className="text-primary-600 hover:text-primary-700">
email@example.com
</a>{' '}
sau poți găsi pe rețelele sociale.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import Link from 'next/link'
export default function 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">Articolul nu a fost găsit</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
Ne pare rău, dar articolul pe care îl cauți nu există sau a fost mutat.
</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">
Vezi toate articolele
</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">
Pagina principală
</Link>
</div>
</div>
</div>
)
}

129
app/blog/[...slug]/page.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getAllPosts, getPostBySlug, getRelatedPosts } from '@/lib/markdown'
import { formatDate, formatRelativeDate } from '@/lib/utils'
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug.split('/') }))
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string[] }> }): Promise<Metadata> {
const { slug } = await params
const slugPath = slug.join('/')
const post = getPostBySlug(slugPath)
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 AuthorInfo({ author, date }: { author: string; date: string }) {
return (
<div className="flex items-center space-x-4 py-6 border-y border-gray-200 dark:border-gray-700">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<span className="text-xl font-bold text-primary-600 dark:text-primary-400">
{author.charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="font-semibold">{author}</p>
<p className="text-sm text-gray-500">
Publicat {formatRelativeDate(date)} {formatDate(date)}
</p>
</div>
</div>
)
}
function RelatedPosts({ posts }: { posts: any[] }) {
if (posts.length === 0) return null
return (
<section className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<h2 className="text-2xl font-bold mb-6">Articole similare</h2>
<div className="grid gap-6 md:grid-cols-3">
{posts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`} className="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-lg transition">
<h3 className="font-semibold mb-2 line-clamp-2">{post.frontmatter.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{post.frontmatter.description}</p>
<p className="text-xs text-gray-500 mt-2">{formatDate(post.frontmatter.date)}</p>
</Link>
))}
</div>
</section>
)
}
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params
const slugPath = slug.join('/')
const post = getPostBySlug(slugPath)
if (!post) {
notFound()
}
const relatedPosts = await getRelatedPosts(slugPath)
return (
<article className="max-w-4xl mx-auto">
<header className="mb-8">
{post.frontmatter.image && (
<img src={post.frontmatter.image} alt={post.frontmatter.title} className="w-full h-64 md:h-96 object-cover rounded-lg mb-8" />
)}
<h1 className="text-4xl md:text-5xl font-bold mb-4">{post.frontmatter.title}</h1>
<p className="text-xl text-gray-600 dark:text-gray-400 mb-6">{post.frontmatter.description}</p>
{post.frontmatter.tags && post.frontmatter.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{post.frontmatter.tags.map((tag: string) => (
<Link key={tag} href={`/tags/${tag.toLowerCase().replace(/\s+/g, '-')}`} className="px-3 py-1 bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full text-sm hover:bg-primary-200 dark:hover:bg-primary-800 transition">
#{tag}
</Link>
))}
</div>
)}
<AuthorInfo author={post.frontmatter.author} date={post.frontmatter.date} />
</header>
<div className="prose dark:prose-invert max-w-none">
<div className="flex items-center justify-between text-sm text-gray-500 mb-6">
<span>Timp estimat de citire: {post.readingTime} minute</span>
</div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
<nav className="flex justify-between items-center mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<Link href="/blog" className="flex items-center text-primary-600 hover:text-primary-700 transition">
<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>
Înapoi la blog
</Link>
</nav>
<RelatedPosts posts={relatedPosts} />
</article>
)
}

95
app/blog/page.tsx Normal file
View File

@@ -0,0 +1,95 @@
import { Metadata } from 'next'
import Link from 'next/link'
import { getAllPosts } from '@/lib/markdown'
import { formatDate } from '@/lib/utils'
export const metadata: Metadata = {
title: 'Blog',
description: 'Toate articolele din blog',
}
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>
)
}
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>
)
}
return (
<div className="max-w-4xl mx-auto">
<BlogFilters totalPosts={posts.length} />
<div>
{posts.map((post) => (
<PostCard key={post.slug} post={post} />
))}
</div>
</div>
)
}

View File

@@ -1,9 +1,28 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Link from 'next/link'
import './globals.css' import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'My Blog', title: {
description: 'Personal blog built with Next.js 15', template: '%s | Blog & Portofoliu',
default: 'Blog & Portofoliu',
},
description: 'Blog personal despre dezvoltare web și design',
metadataBase: new URL('http://localhost:3000'),
authors: [{ name: 'Nume Autor' }],
keywords: ['blog', 'dezvoltare web', 'nextjs', 'react', 'typescript'],
openGraph: {
type: 'website',
locale: 'ro_RO',
siteName: 'Blog & Portofoliu',
},
robots: {
index: true,
follow: true,
},
} }
export default function RootLayout({ export default function RootLayout({
@@ -12,8 +31,33 @@ export default function RootLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<html lang="en"> <html lang="ro">
<body>{children}</body> <body className={inter.className}>
<div className="min-h-screen bg-white dark:bg-gray-900">
<header className="border-b border-gray-200 dark:border-gray-700">
<nav className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<Link href="/" className="text-2xl font-bold text-primary-600">
Blog
</Link>
<div className="flex space-x-6">
<Link href="/" className="hover:text-primary-600">Acasă</Link>
<Link href="/blog" className="hover:text-primary-600">Blog</Link>
<Link href="/about" className="hover:text-primary-600">Despre</Link>
</div>
</div>
</nav>
</header>
<main className="container mx-auto px-4 py-8">
{children}
</main>
<footer className="border-t border-gray-200 dark:border-gray-700 mt-12">
<div className="container mx-auto px-4 py-6 text-center text-gray-600 dark:text-gray-400">
© 2025 Blog & Portofoliu. Toate drepturile rezervate.
</div>
</footer>
</div>
</body>
</html> </html>
) )
} }

View File

@@ -1,8 +1,59 @@
export default function Home() { import Link from 'next/link'
import { getAllPosts } from '@/lib/markdown'
import { formatDate } from '@/lib/utils'
export default async function HomePage() {
const allPosts = await getAllPosts()
const featuredPosts = allPosts.slice(0, 3)
return ( return (
<main className="min-h-screen p-8"> <div className="space-y-12">
<h1 className="text-4xl font-bold text-primary-600">Welcome to My Blog</h1> <section className="text-center py-12">
<p className="mt-4 text-gray-600">Built with Next.js 15, TypeScript, and Tailwind CSS</p> <h1 className="text-5xl font-bold mb-4">Bun venit pe Blog</h1>
</main> <p className="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
Explorează articole despre dezvoltare web, design și tehnologie.
Învață din experiențe practice și tutoriale detaliate.
</p>
<div className="mt-8 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">
Vezi toate articolele
</Link>
<Link href="/about" 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">
Despre mine
</Link>
</div>
</section>
<section>
<h2 className="text-3xl font-bold mb-8">Articole Recente</h2>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{featuredPosts.map((post) => (
<article key={post.slug} className="border border-gray-200 dark:border-gray-700 rounded-lg p-6 hover:shadow-lg transition">
{post.frontmatter.image && (
<img src={post.frontmatter.image} alt={post.frontmatter.title} className="w-full h-48 object-cover rounded-lg mb-4" />
)}
<h3 className="text-xl font-semibold mb-2">
<Link href={`/blog/${post.slug}`} className="hover:text-primary-600 transition">
{post.frontmatter.title}
</Link>
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">{post.frontmatter.description}</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{formatDate(post.frontmatter.date)}</span>
<span>{post.readingTime} min citire</span>
</div>
</article>
))}
</div>
</section>
<section className="text-center py-12 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h2 className="text-3xl font-bold mb-4">Vrei afli mai multe?</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-6">Explorează arhiva completă de articole</p>
<Link href="/blog" className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition">
Vezi toate articolele
</Link>
</section>
</div>
) )
} }

View File

@@ -52,12 +52,13 @@ export function validateFrontmatter(data: any): FrontMatter {
}; };
} }
export function getPostBySlug(slug: string[]): Post { export function getPostBySlug(slug: string | string[]): Post | null {
const sanitized = slug.map(s => sanitizePath(s)); const slugArray = Array.isArray(slug) ? slug : [slug];
const sanitized = slugArray.map(s => sanitizePath(s));
const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md'; const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md';
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
throw new Error('Post not found'); return null;
} }
const fileContents = fs.readFileSync(fullPath, 'utf8'); const fileContents = fs.readFileSync(fullPath, 'utf8');
@@ -89,7 +90,7 @@ export function getAllPosts(includeContent = false): Post[] {
const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, ''); const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, '');
try { try {
const post = getPostBySlug(slug.split('/')); const post = getPostBySlug(slug.split('/'));
if (!post.frontmatter.draft) { if (post && !post.frontmatter.draft) {
posts.push(includeContent ? post : { ...post, content: '' }); posts.push(includeContent ? post : { ...post, content: '' });
} }
} catch (error) { } catch (error) {
@@ -106,8 +107,12 @@ export function getAllPosts(includeContent = false): Post[] {
return posts.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()); return posts.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime());
} }
export function getRelatedPosts(currentSlug: string, category: string, tags: string[], limit = 3): Post[] { export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<Post[]> {
const currentPost = getPostBySlug(currentSlug);
if (!currentPost) return [];
const allPosts = getAllPosts(false); const allPosts = getAllPosts(false);
const { category, tags } = currentPost.frontmatter;
const scored = allPosts const scored = allPosts
.filter(post => post.slug !== currentSlug) .filter(post => post.slug !== currentSlug)

33
lib/seo.ts Normal file
View File

@@ -0,0 +1,33 @@
export interface SEOData {
title: string
description: string
url: string
image?: string
type?: 'website' | 'article'
author?: string
publishedTime?: string
}
export function generateStructuredData(data: SEOData) {
const structuredData: any = {
'@context': 'https://schema.org',
'@type': data.type === 'article' ? 'BlogPosting' : 'WebSite',
headline: data.title,
description: data.description,
url: data.url,
}
if (data.image) {
structuredData.image = data.image
}
if (data.type === 'article') {
structuredData.author = {
'@type': 'Person',
name: data.author || 'Unknown',
}
structuredData.datePublished = data.publishedTime
}
return JSON.stringify(structuredData)
}