feat/intl-multi-lang #10
6
.gitignore
vendored
6
.gitignore
vendored
@@ -16,3 +16,9 @@ yarn-error.log*
|
|||||||
.vercel
|
.vercel
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Build artifacts (copied images)
|
||||||
|
public/blog/**/*.jpg
|
||||||
|
public/blog/**/*.png
|
||||||
|
public/blog/**/*.webp
|
||||||
|
public/blog/**/*.gif
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Stage 1: Dependencies Installation
|
# Stage 1: Dependencies Installation
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM node:20-alpine AS deps
|
FROM node:22-alpine AS deps
|
||||||
|
|
||||||
# Install libc6-compat for better compatibility
|
# Install libc6-compat for better compatibility
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
@@ -24,7 +24,7 @@ RUN npm ci
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Stage 2: Build Next.js Application
|
# Stage 2: Build Next.js Application
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM node:20-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ RUN npm run build
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Stage 3: Production Runtime
|
# Stage 3: Production Runtime
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM node:20-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
# Install curl for health checks
|
# Install curl for health checks
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default async function BlogPostBreadcrumb({
|
|||||||
}) {
|
}) {
|
||||||
const { slug } = await params
|
const { slug } = await params
|
||||||
const slugPath = slug.join('/')
|
const slugPath = slug.join('/')
|
||||||
const post = getPostBySlug(slugPath)
|
const post = await getPostBySlug(slugPath)
|
||||||
|
|
||||||
const items: BreadcrumbItem[] = [
|
const items: BreadcrumbItem[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function generateMetadata({
|
|||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { slug } = await params
|
const { slug } = await params
|
||||||
const slugPath = slug.join('/')
|
const slugPath = slug.join('/')
|
||||||
const post = getPostBySlug(slugPath)
|
const post = await getPostBySlug(slugPath)
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return { title: 'Articol negăsit' }
|
return { title: 'Articol negăsit' }
|
||||||
@@ -68,7 +68,7 @@ function extractHeadings(content: string) {
|
|||||||
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) {
|
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) {
|
||||||
const { slug } = await params
|
const { slug } = await params
|
||||||
const slugPath = slug.join('/')
|
const slugPath = slug.join('/')
|
||||||
const post = getPostBySlug(slugPath)
|
const post = await getPostBySlug(slugPath)
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
notFound()
|
notFound()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function TagNotFound() {
|
export default function TagNotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -36,5 +36,5 @@ export default function TagNotFound() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,25 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next'
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link'
|
||||||
import {
|
import { getAllTags, getPostsByTag, getTagInfo, getRelatedTags } from '@/lib/tags'
|
||||||
getAllTags,
|
import { TagList } from '@/components/blog/tag-list'
|
||||||
getPostsByTag,
|
import { formatDate } from '@/lib/utils'
|
||||||
getTagInfo,
|
|
||||||
getRelatedTags
|
|
||||||
} from '@/lib/tags';
|
|
||||||
import { TagList } from '@/components/blog/tag-list';
|
|
||||||
import { formatDate } from '@/lib/utils';
|
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const tags = await getAllTags();
|
const tags = await getAllTags()
|
||||||
return tags.map(tag => ({ tag: tag.slug }));
|
return tags.map(tag => ({ tag: tag.slug }))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ tag: string }>;
|
params: Promise<{ tag: string }>
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { tag } = await params;
|
const { tag } = await params
|
||||||
const tagInfo = await getTagInfo(tag);
|
const tagInfo = await getTagInfo(tag)
|
||||||
|
|
||||||
if (!tagInfo) {
|
if (!tagInfo) {
|
||||||
return { title: 'Tag negăsit' };
|
return { title: 'Tag negăsit' }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -34,7 +29,7 @@ export async function generateMetadata({
|
|||||||
title: `Tag: ${tagInfo.name}`,
|
title: `Tag: ${tagInfo.name}`,
|
||||||
description: `Explorează ${tagInfo.count} articole despre ${tagInfo.name}`,
|
description: `Explorează ${tagInfo.count} articole despre ${tagInfo.name}`,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function PostCard({ post }: { post: any }) {
|
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">
|
<div className="flex items-center gap-2 font-mono text-xs text-zinc-500 mb-3 uppercase">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<time dateTime={post.frontmatter.date}>{formatDate(post.frontmatter.date)}</time>
|
||||||
{formatDate(post.frontmatter.date)}
|
|
||||||
</time>
|
|
||||||
<span>></span>
|
<span>></span>
|
||||||
<span>{post.readingTime} min</span>
|
<span>{post.readingTime} min</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="font-mono text-lg font-bold mb-3 uppercase">
|
<h2 className="font-mono text-lg font-bold mb-3 uppercase">
|
||||||
<Link
|
<Link href={`/blog/${post.slug}`} className="text-cyan-400 hover:text-cyan-300 transition">
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
className="text-cyan-400 hover:text-cyan-300 transition"
|
|
||||||
>
|
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Link>
|
</Link>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-zinc-400 mb-4 line-clamp-3">
|
<p className="text-zinc-400 mb-4 line-clamp-3">{post.frontmatter.description}</p>
|
||||||
{post.frontmatter.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{post.frontmatter.tags && (
|
{post.frontmatter.tags && <TagList tags={post.frontmatter.tags} variant="minimal" />}
|
||||||
<TagList tags={post.frontmatter.tags} variant="minimal" />
|
|
||||||
)}
|
|
||||||
</article>
|
</article>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function TagPage({
|
export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
|
||||||
params,
|
const { tag } = await params
|
||||||
}: {
|
const tagInfo = await getTagInfo(tag)
|
||||||
params: Promise<{ tag: string }>;
|
|
||||||
}) {
|
|
||||||
const { tag } = await params;
|
|
||||||
const tagInfo = await getTagInfo(tag);
|
|
||||||
|
|
||||||
if (!tagInfo) {
|
if (!tagInfo) {
|
||||||
notFound();
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const posts = await getPostsByTag(tag);
|
const posts = await getPostsByTag(tag)
|
||||||
const relatedTags = await getRelatedTags(tag);
|
const relatedTags = await getRelatedTags(tag)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
<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">
|
<div className="lg:col-span-3">
|
||||||
{posts.length === 0 ? (
|
{posts.length === 0 ? (
|
||||||
<div className="border-4 border-slate-800 bg-slate-900 p-12 text-center">
|
<div className="border-4 border-slate-800 bg-slate-900 p-12 text-center">
|
||||||
<p className="font-mono text-zinc-400 mb-6 uppercase">
|
<p className="font-mono text-zinc-400 mb-6 uppercase">> NO DOCUMENTS FOUND</p>
|
||||||
> NO DOCUMENTS FOUND
|
|
||||||
</p>
|
|
||||||
<Link
|
<Link
|
||||||
href="/blog"
|
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"
|
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}`}
|
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"
|
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">
|
<span className="font-mono text-xs uppercase text-zinc-300">#{tag.name}</span>
|
||||||
#{tag.name}
|
<span className="font-mono text-xs text-zinc-500">[{tag.count}]</span>
|
||||||
</span>
|
|
||||||
<span className="font-mono text-xs text-zinc-500">
|
|
||||||
[{tag.count}]
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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-2 border-slate-700 bg-slate-900 p-6">
|
||||||
<div className="border-b-2 border-slate-700 pb-3 mb-4">
|
<div className="border-b-2 border-slate-700 pb-3 mb-4">
|
||||||
<h2 className="font-mono text-sm font-bold uppercase text-cyan-400">
|
<h2 className="font-mono text-sm font-bold uppercase text-cyan-400">QUICK NAV</h2>
|
||||||
QUICK NAV
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Link
|
<Link
|
||||||
@@ -199,5 +173,5 @@ export default async function TagPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link'
|
||||||
import { getAllTags, getTagCloud } from '@/lib/tags';
|
import { getAllTags, getTagCloud } from '@/lib/tags'
|
||||||
import { TagCloud } from '@/components/blog/tag-cloud';
|
import { TagCloud } from '@/components/blog/tag-cloud'
|
||||||
import { TagBadge } from '@/components/blog/tag-badge';
|
import { TagBadge } from '@/components/blog/tag-badge'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Tag-uri',
|
title: 'Tag-uri',
|
||||||
description: 'Explorează articolele după tag-uri',
|
description: 'Explorează articolele după tag-uri',
|
||||||
};
|
}
|
||||||
|
|
||||||
export default async function TagsPage() {
|
export default async function TagsPage() {
|
||||||
const allTags = await getAllTags();
|
const allTags = await getAllTags()
|
||||||
const tagCloud = await getTagCloud();
|
const tagCloud = await getTagCloud()
|
||||||
|
|
||||||
if (allTags.length === 0) {
|
if (allTags.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -21,9 +21,7 @@ export default async function TagsPage() {
|
|||||||
<h1 className="font-mono text-3xl font-bold uppercase mb-4 text-cyan-400">
|
<h1 className="font-mono text-3xl font-bold uppercase mb-4 text-cyan-400">
|
||||||
TAG DATABASE
|
TAG DATABASE
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-mono text-zinc-400 mb-8">
|
<p className="font-mono text-zinc-400 mb-8">> NO TAGS AVAILABLE</p>
|
||||||
> NO TAGS AVAILABLE
|
|
||||||
</p>
|
|
||||||
<Link
|
<Link
|
||||||
href="/blog"
|
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"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupedTags = allTags.reduce((acc, tag) => {
|
const groupedTags = allTags.reduce(
|
||||||
const firstLetter = tag.name[0].toUpperCase();
|
(acc, tag) => {
|
||||||
|
const firstLetter = tag.name[0].toUpperCase()
|
||||||
if (!acc[firstLetter]) {
|
if (!acc[firstLetter]) {
|
||||||
acc[firstLetter] = [];
|
acc[firstLetter] = []
|
||||||
}
|
}
|
||||||
acc[firstLetter].push(tag);
|
acc[firstLetter].push(tag)
|
||||||
return acc;
|
return acc
|
||||||
}, {} as Record<string, typeof allTags>);
|
},
|
||||||
|
{} as Record<string, typeof allTags>
|
||||||
|
)
|
||||||
|
|
||||||
const sortedLetters = Object.keys(groupedTags).sort();
|
const sortedLetters = Object.keys(groupedTags).sort()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
<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">
|
<h1 className="font-mono text-4xl font-bold uppercase text-cyan-400 mb-4">
|
||||||
TAG REGISTRY
|
TAG REGISTRY
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-mono text-lg text-zinc-400">
|
<p className="font-mono text-lg text-zinc-400">> TOTAL TAGS: {allTags.length}</p>
|
||||||
> TOTAL TAGS: {allTags.length}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="mb-12">
|
<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">
|
<p className="font-mono text-xs text-cyan-600 uppercase tracking-widest mb-2">
|
||||||
DOCUMENT STATISTICS
|
DOCUMENT STATISTICS
|
||||||
</p>
|
</p>
|
||||||
<h2 className="font-mono text-2xl font-bold uppercase text-cyan-400">
|
<h2 className="font-mono text-2xl font-bold uppercase text-cyan-400">TAG METRICS</h2>
|
||||||
TAG METRICS
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-6 sm:grid-cols-3">
|
<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="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
|
||||||
<div className="font-mono text-3xl font-bold text-cyan-400">
|
<div className="font-mono text-3xl font-bold text-cyan-400">{allTags.length}</div>
|
||||||
{allTags.length}
|
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">TOTAL TAGS</div>
|
||||||
</div>
|
|
||||||
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">
|
|
||||||
TOTAL TAGS
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
|
<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">
|
<div className="font-mono text-3xl font-bold text-cyan-400">
|
||||||
{Math.max(...allTags.map(t => t.count))}
|
{Math.max(...allTags.map(t => t.count))}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">
|
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">MAX POSTS/TAG</div>
|
||||||
MAX POSTS/TAG
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
|
<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">
|
<div className="font-mono text-3xl font-bold text-cyan-400">
|
||||||
{Math.round(allTags.reduce((sum, t) => sum + t.count, 0) / allTags.length)}
|
{Math.round(allTags.reduce((sum, t) => sum + t.count, 0) / allTags.length)}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">
|
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">AVG POSTS/TAG</div>
|
||||||
AVG POSTS/TAG
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
79
components/blog/ImageGallery.tsx
Normal file
79
components/blog/ImageGallery.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
components/blog/OptimizedImage.tsx
Normal file
65
components/blog/OptimizedImage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,87 +2,200 @@
|
|||||||
|
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import Image from 'next/image'
|
import rehypeRaw from 'rehype-raw'
|
||||||
import Link from 'next/link'
|
import { OptimizedImage } from './OptimizedImage'
|
||||||
import { CodeBlock } from './code-block'
|
import { CodeBlock } from './code-block'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface MarkdownRendererProps {
|
interface MarkdownRendererProps {
|
||||||
content: string
|
content: string
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
||||||
return (
|
return (
|
||||||
|
<div className={`prose prose-invert prose-zinc max-w-none ${className}`}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw]}
|
||||||
components={{
|
components={{
|
||||||
h1: ({ children }) => {
|
img: ({ node, src, alt, title, ...props }) => {
|
||||||
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
|
if (!src || typeof src !== 'string') return null
|
||||||
|
|
||||||
const isExternal = src.startsWith('http://') || src.startsWith('https://')
|
const isExternal = src.startsWith('http://') || src.startsWith('https://')
|
||||||
|
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
return <img src={src} alt={alt || ''} className="w-full h-auto" />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-auto">
|
<img
|
||||||
<Image
|
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt || ''}
|
alt={alt || ''}
|
||||||
width={800}
|
title={title}
|
||||||
height={600}
|
className="rounded-lg border border-zinc-800"
|
||||||
style={{ width: '100%', height: 'auto' }}
|
{...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]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptimizedImage
|
||||||
|
src={cleanSrc}
|
||||||
|
alt={altText || alt || ''}
|
||||||
|
caption={caption}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
a: ({ href, children }) => {
|
code: ({ node, className, children, ...props }) => {
|
||||||
if (!href) return <>{children}</>
|
const inline = !className && typeof children === 'string' && !children.includes('\n')
|
||||||
|
const match = /language-(\w+)/.exec(className || '')
|
||||||
|
const language = match ? match[1] : ''
|
||||||
|
|
||||||
|
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 isExternal = href.startsWith('http://') || href.startsWith('https://')
|
||||||
|
const isAnchor = href.startsWith('#')
|
||||||
|
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
return (
|
return (
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
<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}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Link href={href}>{children}</Link>
|
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}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link'
|
||||||
import { getPopularTags } from '@/lib/tags';
|
import { getPopularTags } from '@/lib/tags'
|
||||||
import { TagBadge } from './tag-badge';
|
import { TagBadge } from './tag-badge'
|
||||||
|
|
||||||
export async function PopularTags({ limit = 5 }: { limit?: number }) {
|
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 (
|
return (
|
||||||
<div className="border-2 border-slate-700 bg-slate-900 p-6">
|
<div className="border-2 border-slate-700 bg-slate-900 p-6">
|
||||||
<div className="border-b-2 border-slate-700 pb-3 mb-4">
|
<div className="border-b-2 border-slate-700 pb-3 mb-4">
|
||||||
<h3 className="font-mono text-sm font-bold uppercase text-cyan-400">
|
<h3 className="font-mono text-sm font-bold uppercase text-cyan-400">POPULAR TAGS</h3>
|
||||||
POPULAR TAGS
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{tags.map((tag, index) => (
|
{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"
|
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">
|
<div className="flex items-center space-x-3">
|
||||||
<span className="font-mono text-xs text-zinc-500">
|
<span className="font-mono text-xs text-zinc-500">[{index + 1}]</span>
|
||||||
[{index + 1}]
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-xs uppercase group-hover:text-cyan-400 transition">
|
<span className="font-mono text-xs uppercase group-hover:text-cyan-400 transition">
|
||||||
#{tag.name}
|
#{tag.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -40,5 +36,5 @@ export async function PopularTags({ limit = 5 }: { limit?: number }) {
|
|||||||
> VIEW ALL TAGS
|
> VIEW ALL TAGS
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
interface TagBadgeProps {
|
interface TagBadgeProps {
|
||||||
count: number;
|
count: number
|
||||||
className?: string;
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagBadge({ count, className = '' }: TagBadgeProps) {
|
export function TagBadge({ count, className = '' }: TagBadgeProps) {
|
||||||
@@ -16,5 +16,5 @@ export function TagBadge({ count, className = '' }: TagBadgeProps) {
|
|||||||
>
|
>
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link'
|
||||||
import { TagInfo } from '@/lib/tags';
|
import { TagInfo } from '@/lib/tags'
|
||||||
|
|
||||||
interface TagCloudProps {
|
interface TagCloudProps {
|
||||||
tags: Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>;
|
tags: Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagCloud({ tags }: TagCloudProps) {
|
export function TagCloud({ tags }: TagCloudProps) {
|
||||||
@@ -11,7 +11,7 @@ export function TagCloud({ tags }: TagCloudProps) {
|
|||||||
md: 'text-sm',
|
md: 'text-sm',
|
||||||
lg: 'text-base font-bold',
|
lg: 'text-base font-bold',
|
||||||
xl: 'text-lg font-bold',
|
xl: 'text-lg font-bold',
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-4 items-baseline">
|
<div className="flex flex-wrap gap-4 items-baseline">
|
||||||
@@ -32,5 +32,5 @@ export function TagCloud({ tags }: TagCloudProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link'
|
||||||
import { slugifyTag } from '@/lib/tags';
|
import { slugifyTag } from '@/lib/tags'
|
||||||
|
|
||||||
interface TagListProps {
|
interface TagListProps {
|
||||||
tags: (string | undefined)[];
|
tags: (string | undefined)[]
|
||||||
variant?: 'default' | 'minimal' | 'colored';
|
variant?: 'default' | 'minimal' | 'colored'
|
||||||
className?: string;
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagList({ tags, variant = 'default', className = '' }: TagListProps) {
|
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 = {
|
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',
|
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 (
|
return (
|
||||||
<div className={`flex flex-wrap gap-2 ${className}`}>
|
<div className={`flex flex-wrap gap-2 ${className}`}>
|
||||||
@@ -33,5 +36,5 @@ export function TagList({ tags, variant = 'default', className = '' }: TagListPr
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
38
components/icons/IconWrapper.tsx
Normal file
38
components/icons/IconWrapper.tsx
Normal 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} />
|
||||||
|
}
|
||||||
74
components/icons/index.tsx
Normal file
74
components/icons/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -36,6 +36,10 @@ async function fetchUser(id: number): Promise<User> {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Use of coolers
|
||||||
|
|
||||||
|
- 
|
||||||
|
|
||||||
## Concluzie
|
## Concluzie
|
||||||
|
|
||||||
Subdirectoarele funcționează perfect pentru organizarea conținutului!
|
Subdirectoarele funcționează perfect pentru organizarea conținutului!
|
||||||
|
|||||||
BIN
content/blog/tech/cooler.jpg
Normal file
BIN
content/blog/tech/cooler.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
11
fix.js
Normal file
11
fix.js
Normal 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
85
lib/image-utils.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import matter from 'gray-matter'
|
import matter from 'gray-matter'
|
||||||
|
import { remark } from 'remark'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
import { FrontMatter, Post } from './types/frontmatter'
|
import { FrontMatter, Post } from './types/frontmatter'
|
||||||
import { generateExcerpt } from './utils'
|
import { generateExcerpt } from './utils'
|
||||||
|
import { remarkCopyImages } from './remark-copy-images'
|
||||||
|
|
||||||
const POSTS_PATH = path.join(process.cwd(), 'content', 'blog')
|
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 slugArray = Array.isArray(slug) ? slug : slug.split('/')
|
||||||
const sanitized = slugArray.map(s => sanitizePath(s))
|
const sanitized = slugArray.map(s => sanitizePath(s))
|
||||||
const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md'
|
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 { data, content } = matter(fileContents)
|
||||||
const frontmatter = validateFrontmatter(data)
|
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 {
|
return {
|
||||||
slug: sanitized.join('/'),
|
slug: sanitized.join('/'),
|
||||||
frontmatter,
|
frontmatter,
|
||||||
content,
|
content: processedContent,
|
||||||
readingTime: calculateReadingTime(content),
|
readingTime: calculateReadingTime(processedContent),
|
||||||
excerpt: generateExcerpt(content),
|
excerpt: generateExcerpt(processedContent),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllPosts(includeContent = false): Post[] {
|
export async function getAllPosts(includeContent = false): Promise<Post[]> {
|
||||||
const posts: Post[] = []
|
const posts: Post[] = []
|
||||||
|
|
||||||
function walkDir(dir: string, prefix = ''): void {
|
async function walkDir(dir: string, prefix = ''): Promise<void> {
|
||||||
const files = fs.readdirSync(dir)
|
const files = fs.readdirSync(dir)
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -85,11 +99,11 @@ export function getAllPosts(includeContent = false): Post[] {
|
|||||||
const stat = fs.statSync(filePath)
|
const stat = fs.statSync(filePath)
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
walkDir(filePath, prefix ? `${prefix}/${file}` : file)
|
await walkDir(filePath, prefix ? `${prefix}/${file}` : file)
|
||||||
} else if (file.endsWith('.md')) {
|
} else if (file.endsWith('.md')) {
|
||||||
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 = await getPostBySlug(slug.split('/'))
|
||||||
if (post && !post.frontmatter.draft) {
|
if (post && !post.frontmatter.draft) {
|
||||||
posts.push(includeContent ? post : { ...post, content: '' })
|
posts.push(includeContent ? post : { ...post, content: '' })
|
||||||
}
|
}
|
||||||
@@ -101,7 +115,7 @@ export function getAllPosts(includeContent = false): Post[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fs.existsSync(POSTS_PATH)) {
|
if (fs.existsSync(POSTS_PATH)) {
|
||||||
walkDir(POSTS_PATH)
|
await walkDir(POSTS_PATH)
|
||||||
}
|
}
|
||||||
|
|
||||||
return posts.sort(
|
return posts.sort(
|
||||||
@@ -110,10 +124,10 @@ export function getAllPosts(includeContent = false): Post[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<Post[]> {
|
export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<Post[]> {
|
||||||
const currentPost = getPostBySlug(currentSlug)
|
const currentPost = await getPostBySlug(currentSlug)
|
||||||
if (!currentPost) return []
|
if (!currentPost) return []
|
||||||
|
|
||||||
const allPosts = getAllPosts(false)
|
const allPosts = await getAllPosts(false)
|
||||||
const { category, tags } = currentPost.frontmatter
|
const { category, tags } = currentPost.frontmatter
|
||||||
|
|
||||||
const scored = allPosts
|
const scored = allPosts
|
||||||
|
|||||||
146
lib/remark-copy-images.ts
Normal file
146
lib/remark-copy-images.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
lib/tags.ts
110
lib/tags.ts
@@ -1,15 +1,15 @@
|
|||||||
import { getAllPosts } from './markdown';
|
import { getAllPosts } from './markdown'
|
||||||
import type { Post } from './types/frontmatter';
|
import type { Post } from './types/frontmatter'
|
||||||
|
|
||||||
export interface TagInfo {
|
export interface TagInfo {
|
||||||
name: string;
|
name: string
|
||||||
slug: string;
|
slug: string
|
||||||
count: number;
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TagWithPosts {
|
export interface TagWithPosts {
|
||||||
tag: TagInfo;
|
tag: TagInfo
|
||||||
posts: Post[];
|
posts: Post[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function slugifyTag(tag: string): string {
|
export function slugifyTag(tag: string): string {
|
||||||
@@ -22,110 +22,108 @@ export function slugifyTag(tag: string): string {
|
|||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/[^a-z0-9-]/g, '')
|
.replace(/[^a-z0-9-]/g, '')
|
||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-')
|
||||||
.replace(/^-|-$/g, '');
|
.replace(/^-|-$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllTags(): Promise<TagInfo[]> {
|
export async function getAllTags(): Promise<TagInfo[]> {
|
||||||
const posts = getAllPosts();
|
const posts = await getAllPosts()
|
||||||
const tagMap = new Map<string, number>();
|
const tagMap = new Map<string, number>()
|
||||||
|
|
||||||
posts.forEach(post => {
|
posts.forEach(post => {
|
||||||
const tags = post.frontmatter.tags?.filter(Boolean) || [];
|
const tags = post.frontmatter.tags?.filter(Boolean) || []
|
||||||
tags.forEach(tag => {
|
tags.forEach(tag => {
|
||||||
const count = tagMap.get(tag) || 0;
|
const count = tagMap.get(tag) || 0
|
||||||
tagMap.set(tag, count + 1);
|
tagMap.set(tag, count + 1)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
return Array.from(tagMap.entries())
|
return Array.from(tagMap.entries())
|
||||||
.map(([name, count]) => ({
|
.map(([name, count]) => ({
|
||||||
name,
|
name,
|
||||||
slug: slugifyTag(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[]> {
|
export async function getPostsByTag(tagSlug: string): Promise<Post[]> {
|
||||||
const posts = getAllPosts();
|
const posts = await getAllPosts()
|
||||||
|
|
||||||
return posts.filter(post => {
|
return posts.filter(post => {
|
||||||
const tags = post.frontmatter.tags?.filter(Boolean) || [];
|
const tags = post.frontmatter.tags?.filter(Boolean) || []
|
||||||
return tags.some(tag => slugifyTag(tag) === tagSlug);
|
return tags.some(tag => slugifyTag(tag) === tagSlug)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTagInfo(tagSlug: string): Promise<TagInfo | null> {
|
export async function getTagInfo(tagSlug: string): Promise<TagInfo | null> {
|
||||||
const allTags = await getAllTags();
|
const allTags = await getAllTags()
|
||||||
return allTags.find(tag => tag.slug === tagSlug) || null;
|
return allTags.find(tag => tag.slug === tagSlug) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPopularTags(limit = 10): Promise<TagInfo[]> {
|
export async function getPopularTags(limit = 10): Promise<TagInfo[]> {
|
||||||
const allTags = await getAllTags();
|
const allTags = await getAllTags()
|
||||||
return allTags.slice(0, limit);
|
return allTags.slice(0, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRelatedTags(tagSlug: string, limit = 5): Promise<TagInfo[]> {
|
export async function getRelatedTags(tagSlug: string, limit = 5): Promise<TagInfo[]> {
|
||||||
const posts = await getPostsByTag(tagSlug);
|
const posts = await getPostsByTag(tagSlug)
|
||||||
const relatedTagMap = new Map<string, number>();
|
const relatedTagMap = new Map<string, number>()
|
||||||
|
|
||||||
posts.forEach(post => {
|
posts.forEach(post => {
|
||||||
const tags = post.frontmatter.tags?.filter(Boolean) || [];
|
const tags = post.frontmatter.tags?.filter(Boolean) || []
|
||||||
tags.forEach(tag => {
|
tags.forEach(tag => {
|
||||||
const slug = slugifyTag(tag);
|
const slug = slugifyTag(tag)
|
||||||
if (slug !== tagSlug) {
|
if (slug !== tagSlug) {
|
||||||
const count = relatedTagMap.get(tag) || 0;
|
const count = relatedTagMap.get(tag) || 0
|
||||||
relatedTagMap.set(tag, count + 1);
|
relatedTagMap.set(tag, count + 1)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
return Array.from(relatedTagMap.entries())
|
return Array.from(relatedTagMap.entries())
|
||||||
.map(([name, count]) => ({
|
.map(([name, count]) => ({
|
||||||
name,
|
name,
|
||||||
slug: slugifyTag(name),
|
slug: slugifyTag(name),
|
||||||
count
|
count,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, limit);
|
.slice(0, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateTags(tags: any): string[] {
|
export function validateTags(tags: any): string[] {
|
||||||
if (!tags) return [];
|
if (!tags) return []
|
||||||
|
|
||||||
if (!Array.isArray(tags)) {
|
if (!Array.isArray(tags)) {
|
||||||
console.warn('Tags should be an array');
|
console.warn('Tags should be an array')
|
||||||
return [];
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTags = tags
|
const validTags = tags.filter(tag => tag && typeof tag === 'string').slice(0, 3)
|
||||||
.filter(tag => tag && typeof tag === 'string')
|
|
||||||
.slice(0, 3);
|
|
||||||
|
|
||||||
if (tags.length > 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' }>> {
|
export async function getTagCloud(): Promise<Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>> {
|
||||||
const tags = await getAllTags();
|
const tags = await getAllTags()
|
||||||
if (tags.length === 0) return [];
|
if (tags.length === 0) return []
|
||||||
|
|
||||||
const maxCount = Math.max(...tags.map(t => t.count));
|
const maxCount = Math.max(...tags.map(t => t.count))
|
||||||
const minCount = Math.min(...tags.map(t => t.count));
|
const minCount = Math.min(...tags.map(t => t.count))
|
||||||
const range = maxCount - minCount || 1;
|
const range = maxCount - minCount || 1
|
||||||
|
|
||||||
return tags.map(tag => {
|
return tags.map(tag => {
|
||||||
const normalized = (tag.count - minCount) / range;
|
const normalized = (tag.count - minCount) / range
|
||||||
let size: 'sm' | 'md' | 'lg' | 'xl';
|
let size: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
|
||||||
if (normalized < 0.25) size = 'sm';
|
if (normalized < 0.25) size = 'sm'
|
||||||
else if (normalized < 0.5) size = 'md';
|
else if (normalized < 0.5) size = 'md'
|
||||||
else if (normalized < 0.75) size = 'lg';
|
else if (normalized < 0.75) size = 'lg'
|
||||||
else size = 'xl';
|
else size = 'xl'
|
||||||
|
|
||||||
return { ...tag, size };
|
return { ...tag, size }
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -28,7 +28,8 @@
|
|||||||
"remark": "^15.0.1",
|
"remark": "^15.0.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"unist-util-visit": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
"remark": "^15.0.1",
|
"remark": "^15.0.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"unist-util-visit": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user