🖼️ added images support

- Should investigate how to resize the image from .md specs
This commit is contained in:
RJ
2025-11-21 16:15:15 +02:00
committed by Rares J
parent 6155a541f9
commit a039528fb3
26 changed files with 871 additions and 274 deletions

6
.gitignore vendored
View File

@@ -16,3 +16,9 @@ yarn-error.log*
.vercel
*.tsbuildinfo
next-env.d.ts
# Build artifacts (copied images)
public/blog/**/*.jpg
public/blog/**/*.png
public/blog/**/*.webp
public/blog/**/*.gif

View File

@@ -5,7 +5,7 @@
# ============================================
# Stage 1: Dependencies Installation
# ============================================
FROM node:20-alpine AS deps
FROM node:22-alpine AS deps
# Install libc6-compat for better compatibility
RUN apk add --no-cache libc6-compat
@@ -24,7 +24,7 @@ RUN npm ci
# ============================================
# Stage 2: Build Next.js Application
# ============================================
FROM node:20-alpine AS builder
FROM node:22-alpine AS builder
WORKDIR /app
@@ -57,7 +57,7 @@ RUN npm run build
# ============================================
# Stage 3: Production Runtime
# ============================================
FROM node:20-alpine AS runner
FROM node:22-alpine AS runner
# Install curl for health checks
RUN apk add --no-cache curl

View File

@@ -24,7 +24,7 @@ export default async function BlogPostBreadcrumb({
}) {
const { slug } = await params
const slugPath = slug.join('/')
const post = getPostBySlug(slugPath)
const post = await getPostBySlug(slugPath)
const items: BreadcrumbItem[] = [
{

View File

@@ -20,7 +20,7 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { slug } = await params
const slugPath = slug.join('/')
const post = getPostBySlug(slugPath)
const post = await getPostBySlug(slugPath)
if (!post) {
return { title: 'Articol negăsit' }
@@ -68,7 +68,7 @@ function extractHeadings(content: string) {
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params
const slugPath = slug.join('/')
const post = getPostBySlug(slugPath)
const post = await getPostBySlug(slugPath)
if (!post) {
notFound()

View File

@@ -1,4 +1,4 @@
import Link from 'next/link';
import Link from 'next/link'
export default function TagNotFound() {
return (
@@ -36,5 +36,5 @@ export default function TagNotFound() {
</div>
</div>
</div>
);
)
}

View File

@@ -1,30 +1,25 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import {
getAllTags,
getPostsByTag,
getTagInfo,
getRelatedTags
} from '@/lib/tags';
import { TagList } from '@/components/blog/tag-list';
import { formatDate } from '@/lib/utils';
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getAllTags, getPostsByTag, getTagInfo, getRelatedTags } from '@/lib/tags'
import { TagList } from '@/components/blog/tag-list'
import { formatDate } from '@/lib/utils'
export async function generateStaticParams() {
const tags = await getAllTags();
return tags.map(tag => ({ tag: tag.slug }));
const tags = await getAllTags()
return tags.map(tag => ({ tag: tag.slug }))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ tag: string }>;
params: Promise<{ tag: string }>
}): Promise<Metadata> {
const { tag } = await params;
const tagInfo = await getTagInfo(tag);
const { tag } = await params
const tagInfo = await getTagInfo(tag)
if (!tagInfo) {
return { title: 'Tag negăsit' };
return { title: 'Tag negăsit' }
}
return {
@@ -34,7 +29,7 @@ export async function generateMetadata({
title: `Tag: ${tagInfo.name}`,
description: `Explorează ${tagInfo.count} articole despre ${tagInfo.name}`,
},
};
}
}
function PostCard({ post }: { post: any }) {
@@ -49,47 +44,34 @@ function PostCard({ post }: { post: any }) {
)}
<div className="flex items-center gap-2 font-mono text-xs text-zinc-500 mb-3 uppercase">
<time dateTime={post.frontmatter.date}>
{formatDate(post.frontmatter.date)}
</time>
<time dateTime={post.frontmatter.date}>{formatDate(post.frontmatter.date)}</time>
<span>&gt;</span>
<span>{post.readingTime} min</span>
</div>
<h2 className="font-mono text-lg font-bold mb-3 uppercase">
<Link
href={`/blog/${post.slug}`}
className="text-cyan-400 hover:text-cyan-300 transition"
>
<Link href={`/blog/${post.slug}`} className="text-cyan-400 hover:text-cyan-300 transition">
{post.frontmatter.title}
</Link>
</h2>
<p className="text-zinc-400 mb-4 line-clamp-3">
{post.frontmatter.description}
</p>
<p className="text-zinc-400 mb-4 line-clamp-3">{post.frontmatter.description}</p>
{post.frontmatter.tags && (
<TagList tags={post.frontmatter.tags} variant="minimal" />
)}
{post.frontmatter.tags && <TagList tags={post.frontmatter.tags} variant="minimal" />}
</article>
);
)
}
export default async function TagPage({
params,
}: {
params: Promise<{ tag: string }>;
}) {
const { tag } = await params;
const tagInfo = await getTagInfo(tag);
export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
const { tag } = await params
const tagInfo = await getTagInfo(tag)
if (!tagInfo) {
notFound();
notFound()
}
const posts = await getPostsByTag(tag);
const relatedTags = await getRelatedTags(tag);
const posts = await getPostsByTag(tag)
const relatedTags = await getRelatedTags(tag)
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
@@ -122,9 +104,7 @@ export default async function TagPage({
<div className="lg:col-span-3">
{posts.length === 0 ? (
<div className="border-4 border-slate-800 bg-slate-900 p-12 text-center">
<p className="font-mono text-zinc-400 mb-6 uppercase">
&gt; NO DOCUMENTS FOUND
</p>
<p className="font-mono text-zinc-400 mb-6 uppercase">&gt; NO DOCUMENTS FOUND</p>
<Link
href="/blog"
className="inline-block px-6 py-3 font-mono text-sm uppercase border-2 border-cyan-400 bg-cyan-900 text-cyan-100 hover:bg-cyan-800 transition"
@@ -156,12 +136,8 @@ export default async function TagPage({
href={`/tags/${tag.slug}`}
className="flex items-center justify-between p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition"
>
<span className="font-mono text-xs uppercase text-zinc-300">
#{tag.name}
</span>
<span className="font-mono text-xs text-zinc-500">
[{tag.count}]
</span>
<span className="font-mono text-xs uppercase text-zinc-300">#{tag.name}</span>
<span className="font-mono text-xs text-zinc-500">[{tag.count}]</span>
</Link>
))}
</div>
@@ -170,9 +146,7 @@ export default async function TagPage({
<div className="border-2 border-slate-700 bg-slate-900 p-6">
<div className="border-b-2 border-slate-700 pb-3 mb-4">
<h2 className="font-mono text-sm font-bold uppercase text-cyan-400">
QUICK NAV
</h2>
<h2 className="font-mono text-sm font-bold uppercase text-cyan-400">QUICK NAV</h2>
</div>
<div className="space-y-2">
<Link
@@ -199,5 +173,5 @@ export default async function TagPage({
</div>
</div>
</div>
);
)
}

View File

@@ -1,17 +1,17 @@
import { Metadata } from 'next';
import Link from 'next/link';
import { getAllTags, getTagCloud } from '@/lib/tags';
import { TagCloud } from '@/components/blog/tag-cloud';
import { TagBadge } from '@/components/blog/tag-badge';
import { Metadata } from 'next'
import Link from 'next/link'
import { getAllTags, getTagCloud } from '@/lib/tags'
import { TagCloud } from '@/components/blog/tag-cloud'
import { TagBadge } from '@/components/blog/tag-badge'
export const metadata: Metadata = {
title: 'Tag-uri',
description: 'Explorează articolele după tag-uri',
};
}
export default async function TagsPage() {
const allTags = await getAllTags();
const tagCloud = await getTagCloud();
const allTags = await getAllTags()
const tagCloud = await getTagCloud()
if (allTags.length === 0) {
return (
@@ -21,9 +21,7 @@ export default async function TagsPage() {
<h1 className="font-mono text-3xl font-bold uppercase mb-4 text-cyan-400">
TAG DATABASE
</h1>
<p className="font-mono text-zinc-400 mb-8">
&gt; NO TAGS AVAILABLE
</p>
<p className="font-mono text-zinc-400 mb-8">&gt; NO TAGS AVAILABLE</p>
<Link
href="/blog"
className="inline-block px-6 py-3 font-mono text-sm uppercase border-2 border-cyan-400 bg-cyan-900 text-cyan-100 hover:bg-cyan-800 transition"
@@ -33,19 +31,22 @@ export default async function TagsPage() {
</div>
</div>
</div>
);
)
}
const groupedTags = allTags.reduce((acc, tag) => {
const firstLetter = tag.name[0].toUpperCase();
if (!acc[firstLetter]) {
acc[firstLetter] = [];
}
acc[firstLetter].push(tag);
return acc;
}, {} as Record<string, typeof allTags>);
const groupedTags = allTags.reduce(
(acc, tag) => {
const firstLetter = tag.name[0].toUpperCase()
if (!acc[firstLetter]) {
acc[firstLetter] = []
}
acc[firstLetter].push(tag)
return acc
},
{} as Record<string, typeof allTags>
)
const sortedLetters = Object.keys(groupedTags).sort();
const sortedLetters = Object.keys(groupedTags).sort()
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
@@ -57,9 +58,7 @@ export default async function TagsPage() {
<h1 className="font-mono text-4xl font-bold uppercase text-cyan-400 mb-4">
TAG REGISTRY
</h1>
<p className="font-mono text-lg text-zinc-400">
&gt; TOTAL TAGS: {allTags.length}
</p>
<p className="font-mono text-lg text-zinc-400">&gt; TOTAL TAGS: {allTags.length}</p>
</div>
<section className="mb-12">
@@ -109,38 +108,28 @@ export default async function TagsPage() {
<p className="font-mono text-xs text-cyan-600 uppercase tracking-widest mb-2">
DOCUMENT STATISTICS
</p>
<h2 className="font-mono text-2xl font-bold uppercase text-cyan-400">
TAG METRICS
</h2>
<h2 className="font-mono text-2xl font-bold uppercase text-cyan-400">TAG METRICS</h2>
</div>
<div className="grid gap-6 sm:grid-cols-3">
<div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
<div className="font-mono text-3xl font-bold text-cyan-400">
{allTags.length}
</div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">
TOTAL TAGS
</div>
<div className="font-mono text-3xl font-bold text-cyan-400">{allTags.length}</div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">TOTAL TAGS</div>
</div>
<div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
<div className="font-mono text-3xl font-bold text-cyan-400">
{Math.max(...allTags.map(t => t.count))}
</div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">
MAX POSTS/TAG
</div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">MAX POSTS/TAG</div>
</div>
<div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
<div className="font-mono text-3xl font-bold text-cyan-400">
{Math.round(allTags.reduce((sum, t) => sum + t.count, 0) / allTags.length)}
</div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">
AVG POSTS/TAG
</div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">AVG POSTS/TAG</div>
</div>
</div>
</section>
</div>
</div>
);
)
}

View File

@@ -0,0 +1,79 @@
'use client'
import { useState } from 'react'
import { OptimizedImage } from './OptimizedImage'
interface ImageItem {
src: string
alt: string
caption?: string
}
interface ImageGalleryProps {
images: ImageItem[]
columns?: 2 | 3 | 4
className?: string
}
export function ImageGallery({ images, columns = 3, className = '' }: ImageGalleryProps) {
const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null)
const gridCols = {
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
}
return (
<>
<div className={`grid ${gridCols[columns]} gap-4 ${className}`}>
{images.map((image, index) => (
<button
key={index}
onClick={() => setSelectedImage(image)}
className="group relative overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/50 transition-all hover:border-emerald-500 hover:shadow-lg hover:shadow-emerald-500/20"
>
<div className="aspect-video relative">
<img
src={image.src}
alt={image.alt}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</div>
{image.caption && <div className="p-2 text-sm text-zinc-400">{image.caption}</div>}
</button>
))}
</div>
{selectedImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
onClick={() => setSelectedImage(null)}
>
<button
className="absolute top-4 right-4 text-zinc-400 hover:text-zinc-100 transition-colors"
onClick={() => setSelectedImage(null)}
>
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<div className="max-w-5xl w-full" onClick={e => e.stopPropagation()}>
<OptimizedImage
src={selectedImage.src}
alt={selectedImage.alt}
caption={selectedImage.caption}
width={1200}
height={800}
/>
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import Image from 'next/image'
import { useState } from 'react'
interface OptimizedImageProps {
src: string
alt: string
caption?: string
width?: number
height?: number
priority?: boolean
className?: string
}
export function OptimizedImage({
src,
alt,
caption,
width = 800,
height = 600,
priority = false,
className = '',
}: OptimizedImageProps) {
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
if (hasError) {
return (
<div className="my-8 rounded-lg border border-zinc-800 bg-zinc-900/50 p-8 text-center">
<p className="text-zinc-400">Failed to load image</p>
{caption && <p className="mt-2 text-sm text-zinc-500">{caption}</p>}
</div>
)
}
return (
<figure className={`my-8 ${className}`}>
<div className="relative overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/50">
<Image
src={src}
alt={alt}
width={width}
height={height}
priority={priority}
className={`w-full h-auto transition-opacity duration-300 ${
isLoading ? 'opacity-0' : 'opacity-100'
}`}
onLoad={() => setIsLoading(false)}
onError={() => setHasError(true)}
placeholder="blur"
blurDataURL="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Crect width='400' height='300' fill='%2318181b'/%3E%3C/svg%3E"
/>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
)}
</div>
{caption && (
<figcaption className="mt-3 text-center text-sm text-zinc-400">{caption}</figcaption>
)}
</figure>
)
}

View File

@@ -2,87 +2,200 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import Image from 'next/image'
import Link from 'next/link'
import rehypeRaw from 'rehype-raw'
import { OptimizedImage } from './OptimizedImage'
import { CodeBlock } from './code-block'
import Link from 'next/link'
interface MarkdownRendererProps {
content: string
className?: string
}
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h1 id={id}>{children}</h1>
},
h2: ({ children }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h2 id={id}>{children}</h2>
},
h3: ({ children }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h3 id={id}>{children}</h3>
},
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '')
if (!inline && match) {
return <CodeBlock code={String(children).replace(/\n$/, '')} language={match[1]} />
}
return <code {...props}>{children}</code>
},
img: ({ src, alt }) => {
if (!src || typeof src !== 'string') return null
const isExternal = src.startsWith('http://') || src.startsWith('https://')
<div className={`prose prose-invert prose-zinc max-w-none ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
img: ({ node, src, alt, title, ...props }) => {
if (!src || typeof src !== 'string') return null
if (isExternal) {
return <img src={src} alt={alt || ''} className="w-full h-auto" />
}
const isExternal = src.startsWith('http://') || src.startsWith('https://')
return (
<div className="relative w-full h-auto">
<Image
src={src}
alt={alt || ''}
width={800}
height={600}
style={{ width: '100%', height: 'auto' }}
/>
</div>
)
},
a: ({ href, children }) => {
if (!href) return <>{children}</>
const isExternal = href.startsWith('http://') || href.startsWith('https://')
if (isExternal) {
return (
<img
src={src}
alt={alt || ''}
title={title}
className="rounded-lg border border-zinc-800"
{...props}
/>
)
}
// Ensure absolute path for Next Image
const absoluteSrc = src.startsWith('/') ? src : `/${src}`
const titleStr = typeof title === 'string' ? title : ''
const [altText, caption] = titleStr?.includes('|')
? titleStr.split('|').map(s => s.trim())
: [alt, undefined]
const url = new URL(absoluteSrc, 'http://localhost')
const width = url.searchParams.get('w') ? parseInt(url.searchParams.get('w')!) : 800
const height = url.searchParams.get('h') ? parseInt(url.searchParams.get('h')!) : 600
const cleanSrc = absoluteSrc.split('?')[0]
if (isExternal) {
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
<OptimizedImage
src={cleanSrc}
alt={altText || alt || ''}
caption={caption}
width={width}
height={height}
/>
)
}
},
code: ({ node, className, children, ...props }) => {
const inline = !className && typeof children === 'string' && !children.includes('\n')
const match = /language-(\w+)/.exec(className || '')
const language = match ? match[1] : ''
return <Link href={href}>{children}</Link>
},
}}
>
{content}
</ReactMarkdown>
if (inline) {
return (
<code
className="rounded bg-zinc-900 px-1.5 py-0.5 text-sm text-emerald-400"
{...props}
>
{children}
</code>
)
}
return <CodeBlock code={String(children).replace(/\n$/, '')} language={language} />
},
a: ({ node, href, children, ...props }) => {
if (!href) return <a {...props}>{children}</a>
const isExternal = href.startsWith('http://') || href.startsWith('https://')
const isAnchor = href.startsWith('#')
if (isExternal) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-emerald-400 hover:text-emerald-300 inline-flex items-center gap-1"
{...props}
>
{children}
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
)
}
if (isAnchor) {
return (
<a href={href} className="text-emerald-400 hover:text-emerald-300" {...props}>
{children}
</a>
)
}
return (
<Link href={href} className="text-emerald-400 hover:text-emerald-300" {...props}>
{children}
</Link>
)
},
h1: ({ node, children, ...props }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return (
<h1 id={id} className="text-3xl font-bold text-zinc-100 mt-8 mb-4" {...props}>
{children}
</h1>
)
},
h2: ({ node, children, ...props }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return (
<h2 id={id} className="text-2xl font-bold text-zinc-100 mt-6 mb-3" {...props}>
{children}
</h2>
)
},
h3: ({ node, children, ...props }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return (
<h3 id={id} className="text-xl font-bold text-zinc-100 mt-4 mb-2" {...props}>
{children}
</h3>
)
},
ul: ({ node, children, ...props }) => (
<ul className="list-disc list-inside space-y-2 text-zinc-300" {...props}>
{children}
</ul>
),
ol: ({ node, children, ...props }) => (
<ol className="list-decimal list-inside space-y-2 text-zinc-300" {...props}>
{children}
</ol>
),
blockquote: ({ node, children, ...props }) => (
<blockquote
className="border-l-4 border-emerald-500 pl-4 italic text-zinc-400"
{...props}
>
{children}
</blockquote>
),
table: ({ node, children, ...props }) => (
<div className="overflow-x-auto my-6">
<table className="min-w-full border border-zinc-800" {...props}>
{children}
</table>
</div>
),
th: ({ node, children, ...props }) => (
<th
className="bg-zinc-900 px-4 py-2 text-left font-bold text-zinc-100 border border-zinc-800"
{...props}
>
{children}
</th>
),
td: ({ node, children, ...props }) => (
<td className="px-4 py-2 text-zinc-300 border border-zinc-800" {...props}>
{children}
</td>
),
}}
>
{content}
</ReactMarkdown>
</div>
)
}

View File

@@ -1,18 +1,16 @@
import Link from 'next/link';
import { getPopularTags } from '@/lib/tags';
import { TagBadge } from './tag-badge';
import Link from 'next/link'
import { getPopularTags } from '@/lib/tags'
import { TagBadge } from './tag-badge'
export async function PopularTags({ limit = 5 }: { limit?: number }) {
const tags = await getPopularTags(limit);
const tags = await getPopularTags(limit)
if (tags.length === 0) return null;
if (tags.length === 0) return null
return (
<div className="border-2 border-slate-700 bg-slate-900 p-6">
<div className="border-b-2 border-slate-700 pb-3 mb-4">
<h3 className="font-mono text-sm font-bold uppercase text-cyan-400">
POPULAR TAGS
</h3>
<h3 className="font-mono text-sm font-bold uppercase text-cyan-400">POPULAR TAGS</h3>
</div>
<div className="space-y-3">
{tags.map((tag, index) => (
@@ -22,9 +20,7 @@ export async function PopularTags({ limit = 5 }: { limit?: number }) {
className="flex items-center justify-between group p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition"
>
<div className="flex items-center space-x-3">
<span className="font-mono text-xs text-zinc-500">
[{index + 1}]
</span>
<span className="font-mono text-xs text-zinc-500">[{index + 1}]</span>
<span className="font-mono text-xs uppercase group-hover:text-cyan-400 transition">
#{tag.name}
</span>
@@ -40,5 +36,5 @@ export async function PopularTags({ limit = 5 }: { limit?: number }) {
&gt; VIEW ALL TAGS
</Link>
</div>
);
)
}

View File

@@ -1,6 +1,6 @@
interface TagBadgeProps {
count: number;
className?: string;
count: number
className?: string
}
export function TagBadge({ count, className = '' }: TagBadgeProps) {
@@ -16,5 +16,5 @@ export function TagBadge({ count, className = '' }: TagBadgeProps) {
>
{count}
</span>
);
)
}

View File

@@ -1,8 +1,8 @@
import Link from 'next/link';
import { TagInfo } from '@/lib/tags';
import Link from 'next/link'
import { TagInfo } from '@/lib/tags'
interface TagCloudProps {
tags: Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>;
tags: Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>
}
export function TagCloud({ tags }: TagCloudProps) {
@@ -11,7 +11,7 @@ export function TagCloud({ tags }: TagCloudProps) {
md: 'text-sm',
lg: 'text-base font-bold',
xl: 'text-lg font-bold',
};
}
return (
<div className="flex flex-wrap gap-4 items-baseline">
@@ -32,5 +32,5 @@ export function TagCloud({ tags }: TagCloudProps) {
</Link>
))}
</div>
);
)
}

View File

@@ -1,24 +1,27 @@
import Link from 'next/link';
import { slugifyTag } from '@/lib/tags';
import Link from 'next/link'
import { slugifyTag } from '@/lib/tags'
interface TagListProps {
tags: (string | undefined)[];
variant?: 'default' | 'minimal' | 'colored';
className?: string;
tags: (string | undefined)[]
variant?: 'default' | 'minimal' | 'colored'
className?: string
}
export function TagList({ tags, variant = 'default', className = '' }: TagListProps) {
const validTags = tags.filter(Boolean) as string[];
const validTags = tags.filter(Boolean) as string[]
if (validTags.length === 0) return null;
if (validTags.length === 0) return null
const baseClasses = 'inline-flex items-center font-mono text-xs uppercase border transition-colors';
const baseClasses =
'inline-flex items-center font-mono text-xs uppercase border transition-colors'
const variants = {
default: 'px-3 py-1 bg-zinc-900 border-slate-700 text-zinc-400 hover:border-cyan-400 hover:text-cyan-400',
default:
'px-3 py-1 bg-zinc-900 border-slate-700 text-zinc-400 hover:border-cyan-400 hover:text-cyan-400',
minimal: 'px-2 py-0.5 border-transparent text-zinc-500 hover:text-cyan-400',
colored: 'px-3 py-1 bg-cyan-900 border-cyan-700 text-cyan-300 hover:bg-cyan-800 hover:border-cyan-600',
};
colored:
'px-3 py-1 bg-cyan-900 border-cyan-700 text-cyan-300 hover:bg-cyan-800 hover:border-cyan-600',
}
return (
<div className={`flex flex-wrap gap-2 ${className}`}>
@@ -33,5 +36,5 @@ export function TagList({ tags, variant = 'default', className = '' }: TagListPr
</Link>
))}
</div>
);
)
}

View File

@@ -0,0 +1,38 @@
import Image from 'next/image'
interface IconWrapperProps {
name: string
alt?: string
size?: number
className?: string
}
export function IconWrapper({ name, alt, size = 32, className = '' }: IconWrapperProps) {
const iconPath = `/icons/${name}.png`
return <Image src={iconPath} alt={alt || name} width={size} height={size} className={className} />
}
export function EmailIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Email" size={size} className={className} />
}
export function TerminalIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Terminal" size={size} className={className} />
}
export function FolderIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Folder" size={size} className={className} />
}
export function DocumentIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Document" size={size} className={className} />
}
export function SettingsIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Settings" size={size} className={className} />
}
export function NetworkIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Network" size={size} className={className} />
}

View File

@@ -0,0 +1,74 @@
export {
IconWrapper,
EmailIcon,
TerminalIcon,
FolderIcon,
DocumentIcon,
SettingsIcon,
NetworkIcon,
} from './IconWrapper'
export function HomeIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
)
}
export function SearchIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
)
}
export function TagIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
)
}
export function CalendarIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
)
}
export function ClockIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)
}

View File

@@ -36,6 +36,10 @@ async function fetchUser(id: number): Promise<User> {
}
```
### Use of coolers
- ![Use of coolers](./cooler.jpg?w=400&h=300)
## Concluzie
Subdirectoarele funcționează perfect pentru organizarea conținutului!

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

11
fix.js Normal file
View File

@@ -0,0 +1,11 @@
const fs = require('fs')
let content = fs.readFileSync('lib/remark-copy-images.ts', 'utf8')
const lines = content.split('\n')
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('replace')) {
console.log(`Line ${i + 1}:`, JSON.stringify(lines[i]))
lines[i] = lines[i].replace(/replace\(\/\\/g / g, 'replace(/\\/g')
console.log(`Fixed:`, JSON.stringify(lines[i]))
}
}
fs.writeFileSync('lib/remark-copy-images.ts', lines.join('\n'))

85
lib/image-utils.ts Normal file
View File

@@ -0,0 +1,85 @@
import { promises as fs } from 'fs'
import path from 'path'
export async function imageExists(imagePath: string): Promise<boolean> {
try {
const fullPath = path.join(process.cwd(), 'public', imagePath)
await fs.access(fullPath)
return true
} catch {
return false
}
}
export async function getImageDimensions(
imagePath: string
): Promise<{ width: number; height: number } | null> {
try {
const fullPath = path.join(process.cwd(), 'public', imagePath)
const buffer = await fs.readFile(fullPath)
if (imagePath.endsWith('.png')) {
const width = buffer.readUInt32BE(16)
const height = buffer.readUInt32BE(20)
return { width, height }
}
if (imagePath.endsWith('.jpg') || imagePath.endsWith('.jpeg')) {
let offset = 2
while (offset < buffer.length) {
if (buffer[offset] !== 0xff) break
const marker = buffer[offset + 1]
if (marker === 0xc0 || marker === 0xc2) {
const height = buffer.readUInt16BE(offset + 5)
const width = buffer.readUInt16BE(offset + 7)
return { width, height }
}
offset += 2 + buffer.readUInt16BE(offset + 2)
}
}
return null
} catch {
return null
}
}
export function getOptimizedImageUrl(
src: string,
width?: number,
height?: number,
quality: number = 75
): string {
const params = new URLSearchParams()
if (width) params.set('w', width.toString())
if (height) params.set('h', height.toString())
params.set('q', quality.toString())
const queryString = params.toString()
return queryString ? `${src}?${queryString}` : src
}
export async function getImageWithPlaceholder(
imagePath: string
): Promise<{ src: string; width: number; height: number; placeholder?: string }> {
const dimensions = await getImageDimensions(imagePath)
if (!dimensions) {
return {
src: imagePath,
width: 800,
height: 600,
}
}
const placeholder = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${dimensions.width}' height='${dimensions.height}'%3E%3Crect width='${dimensions.width}' height='${dimensions.height}' fill='%2318181b'/%3E%3C/svg%3E`
return {
src: imagePath,
...dimensions,
placeholder,
}
}

View File

@@ -1,8 +1,11 @@
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { remark } from 'remark'
import remarkGfm from 'remark-gfm'
import { FrontMatter, Post } from './types/frontmatter'
import { generateExcerpt } from './utils'
import { remarkCopyImages } from './remark-copy-images'
const POSTS_PATH = path.join(process.cwd(), 'content', 'blog')
@@ -52,7 +55,7 @@ export function validateFrontmatter(data: any): FrontMatter {
}
}
export function getPostBySlug(slug: string | string[]): Post | null {
export async function getPostBySlug(slug: string | string[]): Promise<Post | null> {
const slugArray = Array.isArray(slug) ? slug : slug.split('/')
const sanitized = slugArray.map(s => sanitizePath(s))
const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md'
@@ -65,19 +68,30 @@ export function getPostBySlug(slug: string | string[]): Post | null {
const { data, content } = matter(fileContents)
const frontmatter = validateFrontmatter(data)
const processed = await remark()
.use(remarkGfm)
.use(remarkCopyImages, {
contentDir: 'content/blog',
publicDir: 'public/blog',
currentSlug: sanitized.join('/'),
})
.process(content)
const processedContent = processed.toString()
return {
slug: sanitized.join('/'),
frontmatter,
content,
readingTime: calculateReadingTime(content),
excerpt: generateExcerpt(content),
content: processedContent,
readingTime: calculateReadingTime(processedContent),
excerpt: generateExcerpt(processedContent),
}
}
export function getAllPosts(includeContent = false): Post[] {
export async function getAllPosts(includeContent = false): Promise<Post[]> {
const posts: Post[] = []
function walkDir(dir: string, prefix = ''): void {
async function walkDir(dir: string, prefix = ''): Promise<void> {
const files = fs.readdirSync(dir)
for (const file of files) {
@@ -85,11 +99,11 @@ export function getAllPosts(includeContent = false): Post[] {
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
walkDir(filePath, prefix ? `${prefix}/${file}` : file)
await walkDir(filePath, prefix ? `${prefix}/${file}` : file)
} else if (file.endsWith('.md')) {
const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, '')
try {
const post = getPostBySlug(slug.split('/'))
const post = await getPostBySlug(slug.split('/'))
if (post && !post.frontmatter.draft) {
posts.push(includeContent ? post : { ...post, content: '' })
}
@@ -101,7 +115,7 @@ export function getAllPosts(includeContent = false): Post[] {
}
if (fs.existsSync(POSTS_PATH)) {
walkDir(POSTS_PATH)
await walkDir(POSTS_PATH)
}
return posts.sort(
@@ -110,10 +124,10 @@ export function getAllPosts(includeContent = false): Post[] {
}
export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<Post[]> {
const currentPost = getPostBySlug(currentSlug)
const currentPost = await getPostBySlug(currentSlug)
if (!currentPost) return []
const allPosts = getAllPosts(false)
const allPosts = await getAllPosts(false)
const { category, tags } = currentPost.frontmatter
const scored = allPosts

146
lib/remark-copy-images.ts Normal file
View File

@@ -0,0 +1,146 @@
import { visit } from 'unist-util-visit'
import fs from 'fs/promises'
import path from 'path'
import { Node } from 'unist'
interface ImageNode extends Node {
type: 'image'
url: string
alt?: string
title?: string
}
interface Options {
contentDir: string
publicDir: string
currentSlug: string
}
function isRelativePath(url: string): boolean {
// Matches: ./, ../, or bare filenames without protocol/absolute path
return (
url.startsWith('./') || url.startsWith('../') || (!url.startsWith('/') && !url.includes('://'))
)
}
function stripQueryParams(url: string): string {
return url.split('?')[0]
}
// In-memory cache to prevent duplicate copies across parallel compilations
const copiedFiles = new Set<string>()
async function copyAndRewritePath(node: ImageNode, options: Options): Promise<void> {
const { contentDir, publicDir, currentSlug } = options
const urlWithoutParams = stripQueryParams(node.url)
const slugParts = currentSlug.split('/')
const contentPostDir = path.join(process.cwd(), contentDir, ...slugParts.slice(0, -1))
const sourcePath = path.resolve(contentPostDir, urlWithoutParams)
if (sourcePath.includes('..') && !sourcePath.startsWith(path.join(process.cwd(), contentDir))) {
throw new Error(`Invalid image path: ${node.url} (path traversal detected)`)
}
const relativeToContent = path.relative(path.join(process.cwd(), contentDir), sourcePath)
const destPath = path.join(process.cwd(), publicDir, relativeToContent)
try {
await fs.access(sourcePath)
} catch {
throw new Error(
`Image not found: ${sourcePath}\nReferenced in: ${currentSlug}\nURL: ${node.url}`
)
}
const destDir = path.dirname(destPath)
await fs.mkdir(destDir, { recursive: true })
// Deduplication: check cache first
const cacheKey = `${sourcePath}:${destPath}`
if (copiedFiles.has(cacheKey)) {
// Already copied, just rewrite URL
const publicUrl =
'/' + path.relative(path.join(process.cwd(), 'public'), destPath).replace(/\\/g, '/')
const queryParams = node.url.includes('?') ? '?' + node.url.split('?')[1] : ''
node.url = publicUrl + queryParams
return
}
// Check if destination exists with matching size
try {
const [sourceStat, destStat] = await Promise.all([
fs.stat(sourcePath),
fs.stat(destPath).catch(() => null),
])
if (destStat && sourceStat.size === destStat.size) {
// File already exists and matches, skip copy
copiedFiles.add(cacheKey)
const publicUrl =
'/' + path.relative(path.join(process.cwd(), 'public'), destPath).replace(/\\/g, '/')
const queryParams = node.url.includes('?') ? '?' + node.url.split('?')[1] : ''
node.url = publicUrl + queryParams
return
}
} catch (error) {
// Stat failed, proceed with copy
}
// Attempt copy with EBUSY retry logic
try {
await fs.copyFile(sourcePath, destPath)
copiedFiles.add(cacheKey)
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException
if (err.code === 'EBUSY') {
// Race condition: another process is copying this file
// Wait briefly and check if file now exists
await new Promise(resolve => setTimeout(resolve, 100))
try {
await fs.access(destPath)
// File exists now, verify integrity
const [sourceStat, destStat] = await Promise.all([fs.stat(sourcePath), fs.stat(destPath)])
if (sourceStat.size === destStat.size) {
// Successfully copied by another process
copiedFiles.add(cacheKey)
} else {
// File corrupted, retry once
await fs.copyFile(sourcePath, destPath)
copiedFiles.add(cacheKey)
}
} catch {
// File still doesn't exist, retry copy
await fs.copyFile(sourcePath, destPath)
copiedFiles.add(cacheKey)
}
} else {
// Unknown error, rethrow
throw error
}
}
const publicUrl =
'/' + path.relative(path.join(process.cwd(), 'public'), destPath).replace(/\\/g, '/')
const queryParams = node.url.includes('?') ? '?' + node.url.split('?')[1] : ''
node.url = publicUrl + queryParams
}
export function remarkCopyImages(options: Options) {
return async (tree: Node) => {
const promises: Promise<void>[] = []
visit(tree, 'image', (node: Node) => {
const imageNode = node as ImageNode
if (isRelativePath(imageNode.url)) {
promises.push(copyAndRewritePath(imageNode, options))
}
})
await Promise.all(promises)
}
}

View File

@@ -1,15 +1,15 @@
import { getAllPosts } from './markdown';
import type { Post } from './types/frontmatter';
import { getAllPosts } from './markdown'
import type { Post } from './types/frontmatter'
export interface TagInfo {
name: string;
slug: string;
count: number;
name: string
slug: string
count: number
}
export interface TagWithPosts {
tag: TagInfo;
posts: Post[];
tag: TagInfo
posts: Post[]
}
export function slugifyTag(tag: string): string {
@@ -22,110 +22,108 @@ export function slugifyTag(tag: string): string {
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
.replace(/^-|-$/g, '')
}
export async function getAllTags(): Promise<TagInfo[]> {
const posts = getAllPosts();
const tagMap = new Map<string, number>();
const posts = await getAllPosts()
const tagMap = new Map<string, number>()
posts.forEach(post => {
const tags = post.frontmatter.tags?.filter(Boolean) || [];
const tags = post.frontmatter.tags?.filter(Boolean) || []
tags.forEach(tag => {
const count = tagMap.get(tag) || 0;
tagMap.set(tag, count + 1);
});
});
const count = tagMap.get(tag) || 0
tagMap.set(tag, count + 1)
})
})
return Array.from(tagMap.entries())
.map(([name, count]) => ({
name,
slug: slugifyTag(name),
count
count,
}))
.sort((a, b) => b.count - a.count);
.sort((a, b) => b.count - a.count)
}
export async function getPostsByTag(tagSlug: string): Promise<Post[]> {
const posts = getAllPosts();
const posts = await getAllPosts()
return posts.filter(post => {
const tags = post.frontmatter.tags?.filter(Boolean) || [];
return tags.some(tag => slugifyTag(tag) === tagSlug);
});
const tags = post.frontmatter.tags?.filter(Boolean) || []
return tags.some(tag => slugifyTag(tag) === tagSlug)
})
}
export async function getTagInfo(tagSlug: string): Promise<TagInfo | null> {
const allTags = await getAllTags();
return allTags.find(tag => tag.slug === tagSlug) || null;
const allTags = await getAllTags()
return allTags.find(tag => tag.slug === tagSlug) || null
}
export async function getPopularTags(limit = 10): Promise<TagInfo[]> {
const allTags = await getAllTags();
return allTags.slice(0, limit);
const allTags = await getAllTags()
return allTags.slice(0, limit)
}
export async function getRelatedTags(tagSlug: string, limit = 5): Promise<TagInfo[]> {
const posts = await getPostsByTag(tagSlug);
const relatedTagMap = new Map<string, number>();
const posts = await getPostsByTag(tagSlug)
const relatedTagMap = new Map<string, number>()
posts.forEach(post => {
const tags = post.frontmatter.tags?.filter(Boolean) || [];
const tags = post.frontmatter.tags?.filter(Boolean) || []
tags.forEach(tag => {
const slug = slugifyTag(tag);
const slug = slugifyTag(tag)
if (slug !== tagSlug) {
const count = relatedTagMap.get(tag) || 0;
relatedTagMap.set(tag, count + 1);
const count = relatedTagMap.get(tag) || 0
relatedTagMap.set(tag, count + 1)
}
});
});
})
})
return Array.from(relatedTagMap.entries())
.map(([name, count]) => ({
name,
slug: slugifyTag(name),
count
count,
}))
.sort((a, b) => b.count - a.count)
.slice(0, limit);
.slice(0, limit)
}
export function validateTags(tags: any): string[] {
if (!tags) return [];
if (!tags) return []
if (!Array.isArray(tags)) {
console.warn('Tags should be an array');
return [];
console.warn('Tags should be an array')
return []
}
const validTags = tags
.filter(tag => tag && typeof tag === 'string')
.slice(0, 3);
const validTags = tags.filter(tag => tag && typeof tag === 'string').slice(0, 3)
if (tags.length > 3) {
console.warn(`Too many tags provided (${tags.length}). Limited to first 3.`);
console.warn(`Too many tags provided (${tags.length}). Limited to first 3.`)
}
return validTags;
return validTags
}
export async function getTagCloud(): Promise<Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>> {
const tags = await getAllTags();
if (tags.length === 0) return [];
const tags = await getAllTags()
if (tags.length === 0) return []
const maxCount = Math.max(...tags.map(t => t.count));
const minCount = Math.min(...tags.map(t => t.count));
const range = maxCount - minCount || 1;
const maxCount = Math.max(...tags.map(t => t.count))
const minCount = Math.min(...tags.map(t => t.count))
const range = maxCount - minCount || 1
return tags.map(tag => {
const normalized = (tag.count - minCount) / range;
let size: 'sm' | 'md' | 'lg' | 'xl';
const normalized = (tag.count - minCount) / range
let size: 'sm' | 'md' | 'lg' | 'xl'
if (normalized < 0.25) size = 'sm';
else if (normalized < 0.5) size = 'md';
else if (normalized < 0.75) size = 'lg';
else size = 'xl';
if (normalized < 0.25) size = 'sm'
else if (normalized < 0.5) size = 'md'
else if (normalized < 0.75) size = 'lg'
else size = 'xl'
return { ...tag, size };
});
return { ...tag, size }
})
}

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

3
package-lock.json generated
View File

@@ -28,7 +28,8 @@
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",

View File

@@ -40,7 +40,8 @@
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",