Compare commits
7 Commits
feat/intl-
...
d349c1a957
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d349c1a957 | ||
|
|
0e0c21449b | ||
|
|
73cbc8f731 | ||
|
|
77b4e95a93 | ||
|
|
fd50757c94 | ||
|
|
7e8b82f571 | ||
|
|
8b05aae5a8 |
34
.gitea/workflows/pr-checks.yml
Normal file
34
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
lint-and-build:
|
||||
runs-on: node-22
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 📥 Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: 🔍 Run ESLint
|
||||
run: npm run lint
|
||||
continue-on-error: true
|
||||
|
||||
- name: 💅 Check code formatting (Prettier)
|
||||
run: npm run format:check
|
||||
continue-on-error: true
|
||||
|
||||
- name: 🔤 TypeScript type checking
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: ✅ All quality checks passed
|
||||
run: |
|
||||
echo "✅ All code quality checks passed successfully!"
|
||||
echo " - ESLint: No linting errors"
|
||||
echo " - Prettier: Code is properly formatted"
|
||||
echo " - TypeScript: No type errors"
|
||||
384
.gitea/workflows/staging.yml
Normal file
384
.gitea/workflows/staging.yml
Normal file
@@ -0,0 +1,384 @@
|
||||
# Gitea Actions Workflow for Next.js Blog Application - Staging Environment
|
||||
# This workflow builds a Docker image and deploys it to staging
|
||||
#
|
||||
# Workflow triggers:
|
||||
# - Push to staging branch (automatic deployment)
|
||||
# - Manual trigger via workflow_dispatch
|
||||
#
|
||||
# Required Secrets (configure in Gitea repository settings):
|
||||
# - PRODUCTION_HOST: IP address or hostname of production server (same server hosts staging)
|
||||
# - PRODUCTION_USER: SSH username (e.g., 'deployer')
|
||||
# - SSH_PRIVATE_KEY: Private SSH key for authentication
|
||||
#
|
||||
# Environment Variables (configured below):
|
||||
# - REGISTRY: Docker registry URL
|
||||
# - IMAGE_NAME: Docker image name
|
||||
#
|
||||
# Docker Registry Configuration:
|
||||
# - Current registry (repository.workspace:5000) is INSECURE - no authentication required
|
||||
# - Registry login steps are SKIPPED to avoid 7+ minute timeout delays
|
||||
# - Docker push/pull operations work without credentials
|
||||
# - If switching to authenticated registry: uncomment login steps and configure secrets
|
||||
|
||||
name: Build and Deploy Next.js Blog to Staging
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- staging # Trigger on push to staging branch
|
||||
workflow_dispatch: # Allow manual trigger from Gitea UI
|
||||
|
||||
env:
|
||||
# Docker registry configuration
|
||||
# Update this to match your private registry URL
|
||||
REGISTRY: repository.workspace:5000
|
||||
IMAGE_NAME: mypage
|
||||
|
||||
jobs:
|
||||
# ============================================
|
||||
# Job 1: Code Quality Checks (Linting)
|
||||
# ============================================
|
||||
lint:
|
||||
name: 🔍 Code Quality Checks
|
||||
runs-on: node-22
|
||||
# env:
|
||||
# ACTIONS_RUNTIME_URL: http://192.168.1.53:3000 # Setează la nivel de job
|
||||
|
||||
steps:
|
||||
- name: 🔎 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
# with:
|
||||
# github-server-url: ${{ env.ACTIONS_RUNTIME_URL }}
|
||||
|
||||
# - name: 📦 Setup Node.js
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: "22"
|
||||
# cache: "npm"
|
||||
|
||||
- name: 📥 Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: 🔍 Run ESLint
|
||||
run: npm run lint
|
||||
continue-on-error: true
|
||||
|
||||
- name: 💅 Check code formatting (Prettier)
|
||||
run: npm run format:check
|
||||
continue-on-error: true
|
||||
|
||||
- name: 🔤 TypeScript type checking
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: ✅ All quality checks passed
|
||||
run: |
|
||||
echo "✅ All code quality checks passed successfully!"
|
||||
echo " - ESLint: No linting errors"
|
||||
echo " - Prettier: Code is properly formatted"
|
||||
echo " - TypeScript: No type errors"
|
||||
|
||||
# ============================================
|
||||
# Job 2: Build and Push Docker Image
|
||||
# ============================================
|
||||
build-and-push:
|
||||
name: 🏗️ Build and Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint] # Wait for lint job to complete successfully
|
||||
|
||||
steps:
|
||||
- name: 🔎 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 📝 Create .env file from Gitea secrets
|
||||
run: |
|
||||
echo "Creating .env file for Docker build..."
|
||||
cat > .env << EOF
|
||||
# Build-time environment variables
|
||||
NEXT_PUBLIC_SITE_URL=${{ vars.NEXT_PUBLIC_SITE_URL }}
|
||||
NODE_ENV=production
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Add other build-time variables here as needed
|
||||
# NEXT_PUBLIC_GA_ID=${{ vars.NEXT_PUBLIC_GA_ID }}
|
||||
EOF
|
||||
|
||||
echo "✅ .env file created successfully"
|
||||
echo "Preview (secrets masked):"
|
||||
cat .env | sed 's/=.*/=***MASKED***/g'
|
||||
|
||||
# Insecure registry configuration - no authentication required
|
||||
# The registry at repository.workspace:5000 does not require login
|
||||
# Docker push/pull operations work without credentials
|
||||
- name: ℹ️ Registry configuration (insecure - no login required)
|
||||
run: |
|
||||
echo "=== Docker Registry Configuration ==="
|
||||
echo "Registry: ${{ env.REGISTRY }}"
|
||||
echo "Type: Insecure (no authentication required)"
|
||||
echo ""
|
||||
echo "ℹ️ Skipping registry login - insecure registry allows push/pull without credentials"
|
||||
echo ""
|
||||
echo "If your registry requires authentication in the future:"
|
||||
echo " 1. Set REGISTRY_USERNAME and REGISTRY_PASSWORD secrets in Gitea"
|
||||
echo " 2. Uncomment the login step below this message"
|
||||
echo " 3. Change registry URL to authenticated registry"
|
||||
|
||||
# Uncomment this step if registry requires authentication in the future
|
||||
# - name: 🔐 Log in to Docker Registry
|
||||
# timeout-minutes: 1
|
||||
# run: |
|
||||
# if [ -n "${{ secrets.REGISTRY_USERNAME }}" ] && [ -n "${{ secrets.REGISTRY_PASSWORD }}" ]; then
|
||||
# echo "Attempting login to ${{ env.REGISTRY }}..."
|
||||
# timeout 30s bash -c 'echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin' || {
|
||||
# echo "⚠️ Login failed - continuing anyway"
|
||||
# }
|
||||
# fi
|
||||
|
||||
- name: 🏗️ Build Docker image
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1 # Enable BuildKit for faster builds and better caching
|
||||
run: |
|
||||
echo "Building Next.js Docker image with BuildKit (staging)..."
|
||||
echo "Build context size:"
|
||||
du -sh . 2>/dev/null || echo "Cannot measure context size"
|
||||
|
||||
# Build the Docker image for staging
|
||||
# - Uses Dockerfile.nextjs from project root
|
||||
# - Tags image with 'staging' tag
|
||||
# - Enables inline cache for faster subsequent builds
|
||||
docker build \
|
||||
--progress=plain \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging \
|
||||
-f Dockerfile.nextjs \
|
||||
.
|
||||
|
||||
echo "✅ Build successful"
|
||||
echo "Image size:"
|
||||
docker images ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging
|
||||
|
||||
- name: 🚀 Push Docker image to registry
|
||||
run: |
|
||||
echo "Pushing staging image to registry..."
|
||||
|
||||
# Push staging tag
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging
|
||||
|
||||
echo "✅ Image pushed successfully"
|
||||
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging"
|
||||
|
||||
# ============================================
|
||||
# Job 3: Deploy to Staging Server
|
||||
# ============================================
|
||||
deploy-staging:
|
||||
name: 🚀 Deploy to Staging
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-and-push] # Wait for build job to complete
|
||||
environment:
|
||||
name: staging
|
||||
url: http://192.168.1.54:3031
|
||||
|
||||
steps:
|
||||
- name: 🔎 Checkout code (for docker-compose file)
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Verify Docker is accessible on staging server
|
||||
# Registry authentication is not required for insecure registry
|
||||
- name: ℹ️ Verify staging server Docker access
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ vars.PRODUCTION_HOST }}
|
||||
username: ${{ vars.PRODUCTION_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
echo "=== Verifying Docker is accessible ==="
|
||||
docker info > /dev/null 2>&1 || {
|
||||
echo "❌ Docker is not running or user has no access"
|
||||
echo "Please ensure Docker is installed and user is in docker group"
|
||||
exit 1
|
||||
}
|
||||
echo "✅ Docker is accessible"
|
||||
|
||||
echo ""
|
||||
echo "=== Registry Configuration ==="
|
||||
echo "Registry: ${{ env.REGISTRY }}"
|
||||
echo "Type: Insecure (no authentication)"
|
||||
echo "ℹ️ Skipping registry login - push/pull will work without credentials"
|
||||
|
||||
- name: 📁 Ensure staging directory structure
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ vars.PRODUCTION_HOST }}
|
||||
username: ${{ vars.PRODUCTION_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
echo "=== Ensuring staging directory structure ==="
|
||||
|
||||
# Verify base directory exists and is writable
|
||||
# Update /opt/mypage-staging to match your deployment directory
|
||||
if [ ! -d /opt/mypage-staging ]; then
|
||||
echo "❌ /opt/mypage-staging does not exist!"
|
||||
echo "Please run manually on staging server:"
|
||||
echo " sudo mkdir -p /opt/mypage-staging"
|
||||
echo " sudo chown -R deployer:docker /opt/mypage-staging"
|
||||
echo " sudo chmod -R 775 /opt/mypage-staging"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -w /opt/mypage-staging ]; then
|
||||
echo "❌ /opt/mypage-staging is not writable by $USER user"
|
||||
echo "Please run manually on staging server:"
|
||||
echo " sudo chown -R deployer:docker /opt/mypage-staging"
|
||||
echo " sudo chmod -R 775 /opt/mypage-staging"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create data directories for logs
|
||||
mkdir -p /opt/mypage-staging/data/logs || { echo "❌ Failed to create logs directory"; exit 1; }
|
||||
|
||||
echo "✅ Directory structure ready"
|
||||
ls -la /opt/mypage-staging
|
||||
|
||||
- name: 📦 Copy docker-compose.staging.yml to staging server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ vars.PRODUCTION_HOST }}
|
||||
username: ${{ vars.PRODUCTION_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
source: "docker-compose.staging.yml"
|
||||
target: "/opt/mypage-staging/"
|
||||
overwrite: true
|
||||
|
||||
- name: 🐳 Deploy application via Docker Compose
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
env:
|
||||
# Optional: only needed if registry requires authentication
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD || '' }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME || '' }}
|
||||
REGISTRY_URL: ${{ env.REGISTRY }}
|
||||
IMAGE_FULL: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging
|
||||
with:
|
||||
host: ${{ vars.PRODUCTION_HOST }}
|
||||
username: ${{ vars.PRODUCTION_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
envs: REGISTRY_PASSWORD,REGISTRY_USERNAME,REGISTRY_URL,IMAGE_FULL
|
||||
script_stop: true # Stop execution on any error
|
||||
script: |
|
||||
echo "=== Starting deployment to staging server ==="
|
||||
cd /opt/mypage-staging
|
||||
|
||||
# Registry configuration - insecure registry does not require authentication
|
||||
echo "=== Registry Configuration ==="
|
||||
echo "Registry: $REGISTRY_URL"
|
||||
echo "Type: Insecure (no authentication required)"
|
||||
echo "ℹ️ Skipping registry login"
|
||||
echo ""
|
||||
|
||||
# Verify docker-compose.staging.yml exists (copied by previous step)
|
||||
if [ ! -f docker-compose.staging.yml ]; then
|
||||
echo "❌ docker-compose.staging.yml not found in /opt/mypage-staging"
|
||||
echo "File should have been copied by CI/CD workflow"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Using docker-compose.staging.yml"
|
||||
|
||||
# Pull latest staging image from registry
|
||||
echo "=== Pulling latest Docker image (staging) ==="
|
||||
docker pull "$IMAGE_FULL"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Failed to pull image, aborting deployment"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deploy new container
|
||||
# - Stops old container
|
||||
# - Removes old container
|
||||
# - Creates and starts new container with fresh image
|
||||
echo "=== Deploying new staging container ==="
|
||||
docker compose -f docker-compose.staging.yml up -d --force-recreate
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Failed to deploy new container"
|
||||
echo "Check logs above for errors"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check container status
|
||||
echo "=== Container Status ==="
|
||||
docker compose -f docker-compose.staging.yml ps
|
||||
|
||||
# Show recent logs for debugging
|
||||
echo "=== Recent application logs ==="
|
||||
docker compose -f docker-compose.staging.yml logs --tail=50
|
||||
|
||||
# Clean up old/unused images to save disk space
|
||||
echo "=== Cleaning up old Docker images ==="
|
||||
docker image prune -f
|
||||
|
||||
echo "✅ Staging deployment completed successfully ==="
|
||||
|
||||
- name: ❤️ Health check
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ vars.PRODUCTION_HOST }}
|
||||
username: ${{ vars.PRODUCTION_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
echo "=== Performing health check ==="
|
||||
cd /opt/mypage-staging
|
||||
max_attempts=15
|
||||
attempt=1
|
||||
|
||||
# Wait for container to be healthy (respect start_period from health check)
|
||||
echo "Waiting for application to start (40s start period)..."
|
||||
sleep 40
|
||||
|
||||
# Retry health check up to 15 times
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
# Check if application responds at port 3031
|
||||
if curl -f http://localhost:3031/ > /dev/null 2>&1; then
|
||||
echo "✅ Health check passed!"
|
||||
echo "Application is healthy and responding to requests"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $attempt/$max_attempts: Health check failed, retrying in 5s..."
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
# Health check failed - gather diagnostic information
|
||||
echo "❌ Health check failed after $max_attempts attempts"
|
||||
echo ""
|
||||
echo "=== Container Status ==="
|
||||
docker compose -f docker-compose.staging.yml ps
|
||||
echo ""
|
||||
echo "=== Container Health ==="
|
||||
docker inspect mypage-staging --format='{{.State.Health.Status}}' 2>/dev/null || echo "No health status"
|
||||
echo ""
|
||||
echo "=== Recent Application Logs ==="
|
||||
docker compose -f docker-compose.staging.yml logs --tail=100
|
||||
|
||||
exit 1
|
||||
|
||||
- name: 📊 Deployment summary
|
||||
if: always() # Run even if previous steps fail
|
||||
run: |
|
||||
echo "### 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Environment**: Staging" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Image**: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Workflow Run**: #${{ github.run_number }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Triggered By**: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Status**: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Next Steps:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "1. Verify application is accessible at http://192.168.1.54:3031" >> $GITHUB_STEP_SUMMARY
|
||||
echo "2. Check application logs for any errors" >> $GITHUB_STEP_SUMMARY
|
||||
echo "3. Test staging features before promoting to production" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,12 +1,19 @@
|
||||
import { Metadata } from 'next'
|
||||
import { Navbar } from '@/components/blog/navbar'
|
||||
import {setRequestLocale} from 'next-intl/server'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'About',
|
||||
description: 'Learn more about me and this blog',
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
type Props = {
|
||||
params: Promise<{locale: string}>
|
||||
}
|
||||
|
||||
export default async function AboutPage({params}: Props) {
|
||||
const {locale} = await params
|
||||
setRequestLocale(locale)
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
@@ -1,26 +1,29 @@
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations('NotFound')
|
||||
|
||||
return (
|
||||
<div className="min-h-[60vh] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-gray-300 dark:text-gray-700 mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold mb-4">Articolul nu a fost găsit</h2>
|
||||
<h2 className="text-2xl font-semibold mb-4">{t('title')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
Ne pare rău, dar articolul pe care îl cauți nu există sau a fost mutat.
|
||||
{t('description')}
|
||||
</p>
|
||||
<div className="space-x-4">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
Vezi toate articolele
|
||||
{t('goHome')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
Pagina principală
|
||||
{t('goHome')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,26 +1,36 @@
|
||||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Link } from '@/src/i18n/navigation'
|
||||
import { getAllPosts, getPostBySlug, getRelatedPosts } from '@/lib/markdown'
|
||||
import { formatDate, formatRelativeDate } from '@/lib/utils'
|
||||
import { TableOfContents } from '@/components/blog/table-of-contents'
|
||||
import { ReadingProgress } from '@/components/blog/reading-progress'
|
||||
import { StickyFooter } from '@/components/blog/sticky-footer'
|
||||
import MarkdownRenderer from '@/components/blog/markdown-renderer'
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { routing } from '@/src/i18n/routing'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = await getAllPosts()
|
||||
return posts.map(post => ({ slug: post.slug.split('/') }))
|
||||
const locales = ['en', 'ro']
|
||||
const allParams: Array<{ locale: string; slug: string[] }> = []
|
||||
|
||||
for (const locale of locales) {
|
||||
const posts = await getAllPosts(locale)
|
||||
posts.forEach(post => {
|
||||
allParams.push({ locale, slug: post.slug.split('/') })
|
||||
})
|
||||
}
|
||||
return allParams
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string[] }>
|
||||
params: Promise<{ locale: string; slug: string[] }>
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const { slug, locale } = await params
|
||||
const slugPath = slug.join('/')
|
||||
const post = await getPostBySlug(slugPath)
|
||||
const post = await getPostBySlug(slugPath, locale)
|
||||
|
||||
if (!post) {
|
||||
return { title: 'Articol negăsit' }
|
||||
@@ -65,10 +75,14 @@ function extractHeadings(content: string) {
|
||||
return headings
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) {
|
||||
const { slug } = await params
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string[] }>
|
||||
}) {
|
||||
const { slug, locale } = await params
|
||||
const slugPath = slug.join('/')
|
||||
const post = await getPostBySlug(slugPath)
|
||||
const post = await getPostBySlug(slugPath, locale)
|
||||
|
||||
if (!post) {
|
||||
notFound()
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Post } from '@/lib/types/frontmatter'
|
||||
import { BlogCard } from '@/components/blog/blog-card'
|
||||
import { SearchBar } from '@/components/blog/search-bar'
|
||||
@@ -16,6 +17,7 @@ interface BlogPageClientProps {
|
||||
type SortOption = 'newest' | 'oldest' | 'title'
|
||||
|
||||
export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) {
|
||||
const t = useTranslations('BlogListing')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([])
|
||||
const [sortBy, setSortBy] = useState<SortOption>('newest')
|
||||
@@ -67,10 +69,10 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
|
||||
{/* Header */}
|
||||
<div className="border-l border-[var(--neon-cyan)] pl-6 mb-12">
|
||||
<p className="font-mono text-xs text-[rgb(var(--text-muted))] uppercase tracking-widest mb-2">
|
||||
DATABASE QUERY // SEARCH RESULTS
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-6xl font-mono font-bold text-[rgb(var(--text-primary))] uppercase tracking-tight">
|
||||
> BLOG ARCHIVE_
|
||||
> {t("title")}_
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -102,8 +104,8 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
|
||||
{/* Results Count */}
|
||||
<div className="mb-6">
|
||||
<p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase">
|
||||
FOUND {filteredAndSortedPosts.length}{' '}
|
||||
{filteredAndSortedPosts.length === 1 ? 'POST' : 'POSTS'}
|
||||
{t("foundPosts", {count: filteredAndSortedPosts.length})}{' '}
|
||||
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +128,7 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
|
||||
) : (
|
||||
<div className="border border-[rgb(var(--border-primary))] bg-[rgb(var(--bg-secondary))] p-12 text-center">
|
||||
<p className="font-mono text-lg text-[rgb(var(--text-muted))] uppercase">
|
||||
NO POSTS FOUND // TRY DIFFERENT SEARCH TERMS
|
||||
{t("noPosts")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getAllPosts } from '@/lib/markdown'
|
||||
import BlogPageClient from './blog-client'
|
||||
import {setRequestLocale} from 'next-intl/server'
|
||||
|
||||
export default async function BlogPage() {
|
||||
const posts = await getAllPosts()
|
||||
35
app/[locale]/layout.tsx
Normal file
35
app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import {notFound} from 'next/navigation'
|
||||
import {setRequestLocale} from 'next-intl/server'
|
||||
import {routing} from '@/src/i18n/routing'
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
breadcrumbs: ReactNode
|
||||
params: Promise<{locale: string}>
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({locale}))
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
breadcrumbs,
|
||||
params
|
||||
}: Props) {
|
||||
const {locale} = await params
|
||||
|
||||
if (!routing.locales.includes(locale as any)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
setRequestLocale(locale)
|
||||
|
||||
return (
|
||||
<>
|
||||
{breadcrumbs}
|
||||
<main>{children}</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import Link from 'next/link'
|
||||
import {Link} from '@/src/i18n/navigation'
|
||||
import Image from 'next/image'
|
||||
import { getAllPosts } from '@/lib/markdown'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import {setRequestLocale} from 'next-intl/server'
|
||||
|
||||
export default async function HomePage() {
|
||||
const allPosts = await getAllPosts()
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import {Link} from '@/src/i18n/navigation'
|
||||
import { getAllTags, getPostsByTag, getTagInfo, getRelatedTags } from '@/lib/tags'
|
||||
import { TagList } from '@/components/blog/tag-list'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import {setRequestLocale} from 'next-intl/server'
|
||||
import {routing} from '@/src/i18n/routing'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const tags = await getAllTags()
|
||||
@@ -13,7 +15,7 @@ export async function generateStaticParams() {
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ tag: string }>
|
||||
params: Promise<{ locale: string; tag: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { tag } = await params
|
||||
const tagInfo = await getTagInfo(tag)
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {Link} from '@/src/i18n/navigation'
|
||||
import { getAllTags, getTagCloud } from '@/lib/tags'
|
||||
import { TagCloud } from '@/components/blog/tag-cloud'
|
||||
import { TagBadge } from '@/components/blog/tag-badge'
|
||||
import {setRequestLocale} from 'next-intl/server'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Tag-uri',
|
||||
description: 'Explorează articolele după tag-uri',
|
||||
}
|
||||
|
||||
export default async function TagsPage() {
|
||||
type Props = {
|
||||
params: Promise<{locale: string}>
|
||||
}
|
||||
|
||||
export default async function TagsPage({params}: Props) {
|
||||
const {locale} = await params
|
||||
setRequestLocale(locale)
|
||||
const allTags = await getAllTags()
|
||||
const tagCloud = await getTagCloud()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3030'
|
||||
const posts = await getAllPosts(false)
|
||||
const posts = await getAllPosts("en", false)
|
||||
|
||||
const rss = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
--text-muted: 113 113 122;
|
||||
--border-primary: 212 212 216;
|
||||
--border-subtle: 228 228 231;
|
||||
--text-color: #1f1f1f;
|
||||
|
||||
/* Desaturated cyberpunk for light mode - darker for readability */
|
||||
--neon-pink: #7a3d52;
|
||||
@@ -35,6 +36,7 @@
|
||||
--text-muted: 100 116 139;
|
||||
--border-primary: 71 85 105;
|
||||
--border-subtle: 30 41 59;
|
||||
--text-color: #d4d4d8;
|
||||
|
||||
/* Desaturated cyberpunk for dark mode */
|
||||
--neon-pink: #8a5568;
|
||||
@@ -313,7 +315,7 @@
|
||||
|
||||
/* Cyberpunk Prose Styling */
|
||||
.cyberpunk-prose {
|
||||
color: rgb(212 212 216);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.cyberpunk-prose h1,
|
||||
@@ -347,7 +349,6 @@
|
||||
}
|
||||
|
||||
.cyberpunk-prose p {
|
||||
color: rgb(212 212 216);
|
||||
line-height: 1.625;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
@@ -366,7 +367,6 @@
|
||||
|
||||
.cyberpunk-prose ul,
|
||||
.cyberpunk-prose ol {
|
||||
color: rgb(212 212 216);
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { Metadata } from 'next'
|
||||
import { JetBrains_Mono } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { ThemeProvider } from '@/providers/providers'
|
||||
import '@/lib/env-validation' // Validate environment variables
|
||||
import '@/lib/env-validation'
|
||||
import {NextIntlClientProvider} from 'next-intl'
|
||||
import {getMessages} from 'next-intl/server'
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' })
|
||||
|
||||
@@ -17,7 +19,6 @@ export const metadata: Metadata = {
|
||||
keywords: ['blog', 'dezvoltare web', 'nextjs', 'react', 'typescript', 'terminal'],
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'ro_RO',
|
||||
siteName: 'Terminal Blog',
|
||||
},
|
||||
robots: {
|
||||
@@ -29,9 +30,15 @@ export const metadata: Metadata = {
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const messages = await getMessages()
|
||||
|
||||
return (
|
||||
<html lang="ro" suppressHydrationWarning className={jetbrainsMono.variable}>
|
||||
<html suppressHydrationWarning className={jetbrainsMono.variable}>
|
||||
<body className="font-mono bg-zinc-50 text-slate-900 dark:bg-zinc-900 dark:text-slate-100 transition-colors duration-300">
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
@@ -40,10 +47,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
storageKey="blog-theme"
|
||||
disableTransitionOnChange={false}
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<div className="flex-1">{children}</div>
|
||||
|
||||
{/* Footer - from worktree-agent-1 */}
|
||||
<footer className="mt-auto border-t-4 border-slate-300 dark:border-slate-800 bg-zinc-100 dark:bg-slate-900 transition-colors duration-300">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="border-2 border-slate-300 dark:border-slate-800 p-6">
|
||||
@@ -56,6 +63,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,7 +5,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3030'
|
||||
|
||||
// Get all blog posts
|
||||
const posts = await getAllPosts(false)
|
||||
const posts = await getAllPosts("en", false)
|
||||
|
||||
// Generate sitemap entries for blog posts
|
||||
const blogPosts: MetadataRoute.Sitemap = posts.map(post => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import Image from 'next/image'
|
||||
import { Post } from '@/lib/types/frontmatter'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
@@ -9,6 +10,7 @@ interface BlogCardProps {
|
||||
}
|
||||
|
||||
export function BlogCard({ post, variant }: BlogCardProps) {
|
||||
const t = useTranslations('BlogPost')
|
||||
const hasImage = !!post.frontmatter.image
|
||||
|
||||
if (!hasImage || variant === 'text-only') {
|
||||
@@ -38,7 +40,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
|
||||
))}
|
||||
</div>
|
||||
<span className="inline-flex items-center font-mono text-xs uppercase text-cyan-400 hover:text-cyan-300 transition-colors">
|
||||
> READ [{post.readingTime}MIN]
|
||||
> {t('readingTime', {minutes: post.readingTime})}
|
||||
</span>
|
||||
</article>
|
||||
</Link>
|
||||
@@ -82,7 +84,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
|
||||
))}
|
||||
</div>
|
||||
<span className="inline-flex items-center font-mono text-xs uppercase text-cyan-400 hover:text-cyan-300 transition-colors">
|
||||
> READ [{post.readingTime}MIN]
|
||||
> {t('readingTime', {minutes: post.readingTime})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,7 +129,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
|
||||
))}
|
||||
</div>
|
||||
<span className="inline-flex items-center font-mono text-xs uppercase text-cyan-400 hover:text-cyan-300 transition-colors">
|
||||
> READ [{post.readingTime}MIN]
|
||||
> {t('readingTime', {minutes: post.readingTime})}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -6,7 +6,8 @@ import rehypeSanitize from 'rehype-sanitize'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import { OptimizedImage } from './OptimizedImage'
|
||||
import { CodeBlock } from './code-block'
|
||||
import Link from 'next/link'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string
|
||||
@@ -14,6 +15,7 @@ interface MarkdownRendererProps {
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
||||
const locale = useLocale()
|
||||
return (
|
||||
<div className={`prose prose-invert prose-zinc max-w-none ${className}`}>
|
||||
<ReactMarkdown
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import LanguageSwitcher from '@/components/layout/LanguageSwitcher'
|
||||
|
||||
export function Navbar() {
|
||||
const t = useTranslations('Navigation')
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const [lastScrollY, setLastScrollY] = useState(0)
|
||||
|
||||
@@ -39,10 +42,10 @@ export function Navbar() {
|
||||
className="font-mono text-sm uppercase tracking-wider transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--neon-cyan)' }}
|
||||
>
|
||||
< HOME
|
||||
< {t('home')}
|
||||
</Link>
|
||||
<span className="font-mono text-sm text-zinc-100 dark:text-zinc-300 uppercase tracking-wider">
|
||||
// <span style={{ color: 'var(--neon-pink)' }}>BLOG</span> ARCHIVE
|
||||
// <span style={{ color: 'var(--neon-pink)' }}>{t('blog')}</span> ARCHIVE
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
@@ -50,15 +53,16 @@ export function Navbar() {
|
||||
href="/about"
|
||||
className="font-mono text-sm text-zinc-400 dark:text-zinc-500 uppercase tracking-wider hover:text-cyan-400 dark:hover:text-cyan-300 transition-colors cursor-pointer"
|
||||
>
|
||||
[ABOUT]
|
||||
[{t('about')}]
|
||||
</Link>
|
||||
<Link
|
||||
href="/blog"
|
||||
className="font-mono text-sm text-zinc-400 dark:text-zinc-500 uppercase tracking-wider hover:text-cyan-400 dark:hover:text-cyan-300 transition-colors cursor-pointer"
|
||||
>
|
||||
[BLOG]
|
||||
[{t('blog')}]
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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("en", limit)
|
||||
|
||||
if (tags.length === 0) return null
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ export function ReadingProgress() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="fixed top-4 right-4 z-50 px-3 py-1.5 bg-[rgb(var(--bg-primary))] border-2 border-[var(--neon-cyan)] text-xs font-mono font-bold text-[var(--neon-cyan)] relative">
|
||||
<span className="relative z-10">[{Math.round(progress)}%]</span>
|
||||
</div>
|
||||
{/* <div className="fixed left-13 z-50 m-3 px-3 py-1.5 bg-[rgb(var(--bg-primary))] border-2 border-[var(--neon-cyan)] text-xs font-mono font-bold text-[var(--neon-cyan)]">
|
||||
<span className="left-4 z-10">[{Math.round(progress)}%]</span>
|
||||
</div> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,17 +43,12 @@ export function StickyFooter({ url, title }: StickyFooterProps) {
|
||||
className={`
|
||||
fixed bottom-0 left-0 right-0 z-40
|
||||
bg-black/98 backdrop-blur-sm
|
||||
border-t-4 border-[var(--neon-magenta)]
|
||||
border-t-1 border-[var(--neon-magenta)]
|
||||
transition-transform duration-200 ease-in-out
|
||||
${isVisible ? 'translate-y-0' : 'translate-y-full'}
|
||||
`}
|
||||
style={{
|
||||
boxShadow: isVisible
|
||||
? '0 -8px 30px rgba(155,90,142,0.5), inset 0 4px 20px rgba(155,90,142,0.1)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[var(--neon-magenta)] to-transparent opacity-70" />
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent to-transparent opacity-70" />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { TagInfo } from '@/lib/tags'
|
||||
|
||||
interface TagCloudProps {
|
||||
@@ -6,6 +7,7 @@ interface TagCloudProps {
|
||||
}
|
||||
|
||||
export function TagCloud({ tags }: TagCloudProps) {
|
||||
const t = useTranslations('Tags')
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs opacity-70',
|
||||
md: 'text-sm',
|
||||
@@ -26,7 +28,7 @@ export function TagCloud({ tags }: TagCloudProps) {
|
||||
hover:text-cyan-400
|
||||
transition-colors
|
||||
`}
|
||||
title={`${tag.count} ${tag.count === 1 ? 'articol' : 'articole'}`}
|
||||
title={t('postsWithTag', {count: tag.count, tag: tag.name})}
|
||||
>
|
||||
#{tag.name}
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import {Link} from '@/i18n/navigation'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Fragment } from 'react'
|
||||
import { BreadcrumbsSchema } from './breadcrumbs-schema'
|
||||
|
||||
@@ -38,11 +39,22 @@ function ChevronIcon({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatSegmentLabel(segment: string): string {
|
||||
export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
|
||||
const pathname = usePathname()
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('Breadcrumbs')
|
||||
|
||||
// Hide breadcrumbs on main page
|
||||
const isMainPage = pathname === `/${locale}` || pathname === '/'
|
||||
if (isMainPage) {
|
||||
return null
|
||||
}
|
||||
|
||||
const formatSegmentLabel = (segment: string): string => {
|
||||
const specialCases: { [key: string]: string } = {
|
||||
blog: 'Blog',
|
||||
tags: 'Tag-uri',
|
||||
about: 'Despre',
|
||||
blog: t('blog'),
|
||||
tags: t('tags'),
|
||||
about: t('about'),
|
||||
}
|
||||
|
||||
if (specialCases[segment]) {
|
||||
@@ -53,10 +65,7 @@ function formatSegmentLabel(segment: string): string {
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
|
||||
const pathname = usePathname()
|
||||
}
|
||||
|
||||
let breadcrumbs: BreadcrumbItem[] = items || []
|
||||
|
||||
@@ -71,12 +80,8 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === '/') {
|
||||
return null
|
||||
}
|
||||
|
||||
const schemaItems = [
|
||||
{ position: 1, name: 'Acasă', item: '/' },
|
||||
{ position: 1, name: t('home'), item: '/' },
|
||||
...breadcrumbs.map((item, index) => ({
|
||||
position: index + 2,
|
||||
name: item.label,
|
||||
@@ -96,7 +101,7 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center text-gray-500 hover:text-primary-600 transition"
|
||||
aria-label="Acasă"
|
||||
aria-label={t('home')}
|
||||
>
|
||||
<HomeIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
|
||||
59
components/layout/LanguageSwitcher.tsx
Normal file
59
components/layout/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import {useLocale} from 'next-intl';
|
||||
import {useRouter, usePathname} from '@/i18n/navigation';
|
||||
import {routing} from '@/i18n/routing';
|
||||
import {useState} from 'react';
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleLocaleChange = (newLocale: string) => {
|
||||
router.replace(pathname, {locale: newLocale});
|
||||
router.refresh();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-[100]">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="px-3 py-1 border-2 border-slate-700 font-mono uppercase text-xs hover:border-cyan-500 transition-colors"
|
||||
aria-label="Switch language"
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 bg-slate-900 border-2 border-slate-700 min-w-[120px] z-[100]">
|
||||
{routing.locales.map((loc: string) => (
|
||||
<button
|
||||
key={loc}
|
||||
onClick={() => handleLocaleChange(loc)}
|
||||
className={`
|
||||
w-full text-left px-4 py-2 font-mono uppercase text-xs
|
||||
border-b border-slate-700 last:border-b-0
|
||||
${locale === loc
|
||||
? 'bg-cyan-900 text-cyan-300'
|
||||
: 'text-slate-400 hover:bg-slate-800'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{loc === 'en' ? 'English' : 'Română'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
content/blog/ro/why-this-page.md
Normal file
40
content/blog/ro/why-this-page.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: 'Why I created this page'
|
||||
description: 'First post'
|
||||
date: '2025-12-02'
|
||||
author: 'Rares'
|
||||
category: 'Opinion'
|
||||
tags: ['opinion']
|
||||
image: ''
|
||||
draft: false
|
||||
---
|
||||
|
||||
# De ce aceasta pagina?
|
||||
|
||||
Daca te intrebi de ce aceata pagina? Pentru ca vreau sa jurnalizez lucrurile la care lucrez, sau gandurile pe care vreua sa le impartesesc.
|
||||
|
||||
## De ce blog?
|
||||
|
||||
Dacă te gândești de ce să mai creezi inca un blog cand sunt atea pe net, pai ideea este ca este si ca un jurnal, unde postez lucruri si ma ajuta sa revin la ceea ce am investigat.
|
||||
|
||||
1. **Este personal**: Nu este un lucru formal, chiar daca am lucrat in corporate, unde toti se asteapta sa fii prietenos si zambaret mereu, aici o sa fie mai sincere opiniile.
|
||||
2. **Mai mult decat tech**: O sa scriu despre tech dar, nu ăsta e focusul aici
|
||||
3. **Cum fac selfhost**: Fac selfhost, la cateva servicii utile: git, webpage-ul acesta. O sa incerc sa povestesc si cum fac mentenanta sau ce probleme am intampinat pe parcursul acestor deploymenturi.
|
||||
|
||||
## De ce selfhost?
|
||||
|
||||

|
||||
|
||||
Am inceput sa fac hosting acasa din cateva motive:
|
||||
|
||||
- **Detin controlul**: Nu depind de cloud providers sau alte 3rd parties (inafara de VPS).
|
||||
- **Nu exista scurgeri de informatii**: Sunt unele lucruri pe care nu as vrea sa le impartasesc cu marii provideri de servicii cloud.
|
||||
- **E destul de smecher**: Este destul de tare sa vezi cum datele ruleaza pe hardwareul de la tine din casa.
|
||||
|
||||
## Ce este aici de fapt
|
||||
|
||||
E un blog, o jurnalizare e ceea ce fac eu, ma ajuta sa tin evidenta cand explica lucruri.
|
||||
|
||||
- **Resurse tehnice**: Ghiduri pas cu pas despre diverse subiecte, de la configurarea propriului mediului de dezvoltare până la ajustarea serverului.
|
||||
- **Experiențe personale cu selfhosting**: Ce realizat, cum am solutionat, provocari ...
|
||||
- **Gânduri aleatorii**: Gânduri despre eficiența profesională, sănătatea mintală și alte interese personale care nu sunt direct legate de tehnologie.
|
||||
135
docker-compose.staging.yml
Normal file
135
docker-compose.staging.yml
Normal file
@@ -0,0 +1,135 @@
|
||||
# Docker Compose Configuration for Staging Deployment
|
||||
# This file is used by CI/CD to deploy the application on staging servers
|
||||
#
|
||||
# Key differences from production docker-compose.prod.yml:
|
||||
# - Container name: mypage-staging (vs mypage-prod)
|
||||
# - Port mapping: 3031:3030 (vs 3030:3030)
|
||||
# - Network name: mypage-staging-network (vs mypage-network)
|
||||
# - Image tag: staging (vs latest)
|
||||
#
|
||||
# Usage:
|
||||
# 1. This file is automatically copied to server by CI/CD workflow
|
||||
# 2. Server pulls image from registry: docker compose -f docker-compose.staging.yml pull
|
||||
# 3. Server starts container: docker compose -f docker-compose.staging.yml up -d
|
||||
#
|
||||
# Manual deployment (if CI/CD is not available):
|
||||
# ssh user@staging-server
|
||||
# cd /opt/mypage-staging
|
||||
# docker compose -f docker-compose.staging.yml pull
|
||||
# docker compose -f docker-compose.staging.yml up -d --force-recreate
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mypage:
|
||||
# Use pre-built image from private registry with staging tag
|
||||
# This image is built and pushed by the CI/CD workflow
|
||||
# Format: REGISTRY_URL/IMAGE_NAME:TAG
|
||||
image: repository.workspace:5000/mypage:staging
|
||||
|
||||
container_name: mypage-staging
|
||||
|
||||
# Restart policy: always restart on failure or server reboot
|
||||
# This ensures high availability in staging
|
||||
restart: always
|
||||
|
||||
# Port mapping: host:container
|
||||
# Staging runs on port 3031 to avoid conflicts with production (3030)
|
||||
# The application will be accessible at http://SERVER_IP:3031
|
||||
ports:
|
||||
- "3031:3030"
|
||||
|
||||
# Staging environment variables
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
- PORT=3030
|
||||
- HOSTNAME=0.0.0.0
|
||||
# Add any other staging-specific environment variables here
|
||||
# Example:
|
||||
# - DATABASE_URL=postgresql://user:pass@db:5432/mypage_staging
|
||||
# - REDIS_URL=redis://redis:6379
|
||||
|
||||
# Persistent volumes for logs (optional)
|
||||
# Uncomment if your application writes logs
|
||||
volumes:
|
||||
- ./data/logs:/app/logs
|
||||
|
||||
# Health check configuration
|
||||
# Docker monitors the application and marks it unhealthy if checks fail
|
||||
# If container is unhealthy, restart policy will trigger a restart
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3030/", "||", "exit", "1"]
|
||||
interval: 30s # Check every 30 seconds
|
||||
timeout: 10s # Wait up to 10 seconds for response
|
||||
retries: 3 # Mark unhealthy after 3 consecutive failures
|
||||
start_period: 40s # Grace period during container startup
|
||||
|
||||
# Resource limits for staging
|
||||
# Prevents container from consuming all server resources
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# cpus: '1.0' # Maximum 1 CPU core
|
||||
# memory: 512M # Maximum 512MB RAM
|
||||
# reservations:
|
||||
# cpus: '0.25' # Reserve at least 0.25 CPU cores
|
||||
# memory: 256M # Reserve at least 256MB RAM
|
||||
|
||||
# Network configuration
|
||||
networks:
|
||||
- mypage-staging-network
|
||||
|
||||
# Logging configuration
|
||||
# Prevents logs from consuming all disk space
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m" # Maximum 10MB per log file
|
||||
max-file: "3" # Keep only 3 log files (30MB total)
|
||||
|
||||
# Network definition
|
||||
networks:
|
||||
mypage-staging-network:
|
||||
driver: bridge
|
||||
|
||||
# ============================================
|
||||
# Staging Deployment Commands
|
||||
# ============================================
|
||||
#
|
||||
# Pull latest image from registry:
|
||||
# docker compose -f docker-compose.staging.yml pull
|
||||
#
|
||||
# Start/update containers:
|
||||
# docker compose -f docker-compose.staging.yml up -d --force-recreate
|
||||
#
|
||||
# View logs:
|
||||
# docker compose -f docker-compose.staging.yml logs -f mypage
|
||||
#
|
||||
# Check health status:
|
||||
# docker inspect mypage-staging | grep -A 10 Health
|
||||
#
|
||||
# Stop containers:
|
||||
# docker compose -f docker-compose.staging.yml down
|
||||
#
|
||||
# Restart containers:
|
||||
# docker compose -f docker-compose.staging.yml restart
|
||||
#
|
||||
# Remove old/unused images (cleanup):
|
||||
# docker image prune -f
|
||||
#
|
||||
# ============================================
|
||||
# Troubleshooting
|
||||
# ============================================
|
||||
#
|
||||
# If container keeps restarting:
|
||||
# 1. Check logs: docker compose -f docker-compose.staging.yml logs --tail=100
|
||||
# 2. Check health: docker inspect mypage-staging | grep -A 10 Health
|
||||
# 3. Verify port is not already in use: netstat -tulpn | grep 3031
|
||||
# 4. Check resource usage: docker stats mypage-staging
|
||||
#
|
||||
# If health check fails:
|
||||
# 1. Test manually: docker exec mypage-staging curl -f http://localhost:3030/
|
||||
# 2. Check if Next.js server is running: docker exec mypage-staging ps aux
|
||||
# 3. Verify environment variables: docker exec mypage-staging env
|
||||
#
|
||||
@@ -33,7 +33,7 @@ export function calculateReadingTime(content: string): number {
|
||||
return Math.ceil(words / wordsPerMinute)
|
||||
}
|
||||
|
||||
export function validateFrontmatter(data: any): FrontMatter {
|
||||
export function validateFrontmatter(data: any, locale?: string): FrontMatter {
|
||||
if (!data.title || typeof data.title !== 'string') {
|
||||
throw new Error('Invalid title')
|
||||
}
|
||||
@@ -60,15 +60,19 @@ export function validateFrontmatter(data: any): FrontMatter {
|
||||
author: data.author,
|
||||
category: data.category,
|
||||
tags: data.tags,
|
||||
locale: data.locale || locale || 'en',
|
||||
image: data.image,
|
||||
draft: data.draft || false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPostBySlug(slug: string | string[]): Promise<Post | null> {
|
||||
export async function getPostBySlug(
|
||||
slug: string | string[],
|
||||
locale: string = 'en'
|
||||
): 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'
|
||||
const fullPath = path.join(POSTS_PATH, locale, ...sanitized) + '.md'
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return null
|
||||
@@ -76,7 +80,7 @@ export async function getPostBySlug(slug: string | string[]): Promise<Post | nul
|
||||
|
||||
const fileContents = fs.readFileSync(fullPath, 'utf8')
|
||||
const { data, content } = matter(fileContents)
|
||||
const frontmatter = validateFrontmatter(data)
|
||||
const frontmatter = validateFrontmatter(data, locale)
|
||||
|
||||
const processed = await remark()
|
||||
.use(remarkGfm)
|
||||
@@ -85,13 +89,14 @@ export async function getPostBySlug(slug: string | string[]): Promise<Post | nul
|
||||
publicDir: 'public/blog',
|
||||
currentSlug: sanitized.join('/'),
|
||||
})
|
||||
.use(remarkInternalLinks)
|
||||
.use(remarkInternalLinks, { locale })
|
||||
.process(content)
|
||||
|
||||
const processedContent = processed.toString()
|
||||
|
||||
return {
|
||||
slug: sanitized.join('/'),
|
||||
locale,
|
||||
frontmatter,
|
||||
content: processedContent,
|
||||
readingTime: calculateReadingTime(processedContent),
|
||||
@@ -99,8 +104,14 @@ export async function getPostBySlug(slug: string | string[]): Promise<Post | nul
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllPosts(includeContent = false): Promise<Post[]> {
|
||||
export async function getAllPosts(locale: string = 'en', includeContent = false): Promise<Post[]> {
|
||||
const posts: Post[] = []
|
||||
const localeDir = path.join(POSTS_PATH, locale)
|
||||
|
||||
if (!fs.existsSync(localeDir)) {
|
||||
console.warn(`Locale directory not found: ${localeDir}`)
|
||||
return []
|
||||
}
|
||||
|
||||
async function walkDir(dir: string, prefix = ''): Promise<void> {
|
||||
const files = fs.readdirSync(dir)
|
||||
@@ -114,7 +125,7 @@ export async function getAllPosts(includeContent = false): Promise<Post[]> {
|
||||
} else if (file.endsWith('.md')) {
|
||||
const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, '')
|
||||
try {
|
||||
const post = await getPostBySlug(slug.split('/'))
|
||||
const post = await getPostBySlug(slug.split('/'), locale)
|
||||
if (post && !post.frontmatter.draft) {
|
||||
posts.push(includeContent ? post : { ...post, content: '' })
|
||||
}
|
||||
@@ -125,20 +136,22 @@ export async function getAllPosts(includeContent = false): Promise<Post[]> {
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(POSTS_PATH)) {
|
||||
await walkDir(POSTS_PATH)
|
||||
}
|
||||
await walkDir(localeDir)
|
||||
|
||||
return posts.sort(
|
||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
|
||||
)
|
||||
}
|
||||
|
||||
export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<Post[]> {
|
||||
const currentPost = await getPostBySlug(currentSlug)
|
||||
export async function getRelatedPosts(
|
||||
currentSlug: string,
|
||||
locale: string = 'en',
|
||||
limit = 3
|
||||
): Promise<Post[]> {
|
||||
const currentPost = await getPostBySlug(currentSlug, locale)
|
||||
if (!currentPost) return []
|
||||
|
||||
const allPosts = await getAllPosts(false)
|
||||
const allPosts = await getAllPosts(locale, false)
|
||||
const { category, tags } = currentPost.frontmatter
|
||||
|
||||
const scored = allPosts
|
||||
@@ -155,8 +168,13 @@ export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<P
|
||||
return scored.slice(0, limit).map(({ post }) => post)
|
||||
}
|
||||
|
||||
export function getAllPostSlugs(): string[][] {
|
||||
export function getAllPostSlugs(locale: string = 'en'): string[][] {
|
||||
const slugs: string[][] = []
|
||||
const localeDir = path.join(POSTS_PATH, locale)
|
||||
|
||||
if (!fs.existsSync(localeDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
function walkDir(dir: string, prefix: string[] = []): void {
|
||||
const files = fs.readdirSync(dir)
|
||||
@@ -173,9 +191,26 @@ export function getAllPostSlugs(): string[][] {
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(POSTS_PATH)) {
|
||||
walkDir(POSTS_PATH)
|
||||
}
|
||||
walkDir(localeDir)
|
||||
|
||||
return slugs
|
||||
}
|
||||
|
||||
export async function getAvailableLocales(slug: string): Promise<string[]> {
|
||||
const locales = ['en', 'ro']
|
||||
const available: string[] = []
|
||||
|
||||
for (const locale of locales) {
|
||||
const post = await getPostBySlug(slug, locale)
|
||||
if (post) {
|
||||
available.push(locale)
|
||||
}
|
||||
}
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
export async function getPostCount(locale: string): Promise<number> {
|
||||
const posts = await getAllPosts(locale, false)
|
||||
return posts.length
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ interface LinkNode extends Node {
|
||||
children: Node[]
|
||||
}
|
||||
|
||||
interface Options {
|
||||
locale?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects internal blog post links:
|
||||
* - Relative paths (no http/https)
|
||||
@@ -24,11 +28,11 @@ function isInternalBlogLink(url: string): boolean {
|
||||
|
||||
/**
|
||||
* Transforms internal .md links to blog routes:
|
||||
* - tech/article.md → /blog/tech/article
|
||||
* - article.md#section → /blog/article#section
|
||||
* - nested/path/post.md?ref=foo → /blog/nested/path/post?ref=foo
|
||||
* - tech/article.md → /[locale]/blog/tech/article
|
||||
* - article.md#section → /[locale]/blog/article#section
|
||||
* - nested/path/post.md?ref=foo → /[locale]/blog/nested/path/post?ref=foo
|
||||
*/
|
||||
function transformToBlogPath(url: string): string {
|
||||
function transformToBlogPath(url: string, locale: string = 'en'): string {
|
||||
// Split into path, hash, and query
|
||||
const hashIndex = url.indexOf('#')
|
||||
const queryIndex = url.indexOf('?')
|
||||
@@ -50,17 +54,19 @@ function transformToBlogPath(url: string): string {
|
||||
// Remove .md extension
|
||||
const cleanPath = path.replace(/\.md$/, '')
|
||||
|
||||
// Build final URL
|
||||
return `/blog/${cleanPath}${query}${hash}`
|
||||
// Build final URL with locale prefix
|
||||
return `/${locale}/blog/${cleanPath}${query}${hash}`
|
||||
}
|
||||
|
||||
export function remarkInternalLinks() {
|
||||
export function remarkInternalLinks(options: Options = {}) {
|
||||
const locale = options.locale || 'en'
|
||||
|
||||
return (tree: Node) => {
|
||||
visit(tree, 'link', (node: Node) => {
|
||||
const linkNode = node as LinkNode
|
||||
|
||||
if (isInternalBlogLink(linkNode.url)) {
|
||||
linkNode.url = transformToBlogPath(linkNode.url)
|
||||
linkNode.url = transformToBlogPath(linkNode.url, locale)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
24
lib/tags.ts
24
lib/tags.ts
@@ -25,8 +25,8 @@ export function slugifyTag(tag: string): string {
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export async function getAllTags(): Promise<TagInfo[]> {
|
||||
const posts = await getAllPosts()
|
||||
export async function getAllTags(locale: string = 'en'): Promise<TagInfo[]> {
|
||||
const posts = await getAllPosts(locale)
|
||||
const tagMap = new Map<string, number>()
|
||||
|
||||
posts.forEach(post => {
|
||||
@@ -46,8 +46,8 @@ export async function getAllTags(): Promise<TagInfo[]> {
|
||||
.sort((a, b) => b.count - a.count)
|
||||
}
|
||||
|
||||
export async function getPostsByTag(tagSlug: string): Promise<Post[]> {
|
||||
const posts = await getAllPosts()
|
||||
export async function getPostsByTag(tagSlug: string, locale: string = 'en'): Promise<Post[]> {
|
||||
const posts = await getAllPosts(locale)
|
||||
|
||||
return posts.filter(post => {
|
||||
const tags = post.frontmatter.tags?.filter(Boolean) || []
|
||||
@@ -55,18 +55,18 @@ export async function getPostsByTag(tagSlug: string): Promise<Post[]> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function getTagInfo(tagSlug: string): Promise<TagInfo | null> {
|
||||
const allTags = await getAllTags()
|
||||
export async function getTagInfo(tagSlug: string, locale: string = 'en'): Promise<TagInfo | null> {
|
||||
const allTags = await getAllTags(locale)
|
||||
return allTags.find(tag => tag.slug === tagSlug) || null
|
||||
}
|
||||
|
||||
export async function getPopularTags(limit = 10): Promise<TagInfo[]> {
|
||||
const allTags = await getAllTags()
|
||||
export async function getPopularTags(locale: string = 'en', limit = 10): Promise<TagInfo[]> {
|
||||
const allTags = await getAllTags(locale)
|
||||
return allTags.slice(0, limit)
|
||||
}
|
||||
|
||||
export async function getRelatedTags(tagSlug: string, limit = 5): Promise<TagInfo[]> {
|
||||
const posts = await getPostsByTag(tagSlug)
|
||||
export async function getRelatedTags(tagSlug: string, locale: string = 'en', limit = 5): Promise<TagInfo[]> {
|
||||
const posts = await getPostsByTag(tagSlug, locale)
|
||||
const relatedTagMap = new Map<string, number>()
|
||||
|
||||
posts.forEach(post => {
|
||||
@@ -107,8 +107,8 @@ export function validateTags(tags: any): string[] {
|
||||
return validTags
|
||||
}
|
||||
|
||||
export async function getTagCloud(): Promise<Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>> {
|
||||
const tags = await getAllTags()
|
||||
export async function getTagCloud(locale: string = 'en'): Promise<Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>> {
|
||||
const tags = await getAllTags(locale)
|
||||
if (tags.length === 0) return []
|
||||
|
||||
const maxCount = Math.max(...tags.map(t => t.count))
|
||||
|
||||
@@ -5,12 +5,14 @@ export interface FrontMatter {
|
||||
author: string
|
||||
category: string
|
||||
tags: string[]
|
||||
locale: string
|
||||
image?: string
|
||||
draft?: boolean
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
slug: string
|
||||
locale: string
|
||||
frontmatter: FrontMatter
|
||||
content: string
|
||||
readingTime: number
|
||||
|
||||
69
messages/en.json
Normal file
69
messages/en.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"Metadata": {
|
||||
"siteTitle": "Personal Blog",
|
||||
"siteDescription": "Thoughts on technology and development"
|
||||
},
|
||||
|
||||
"Navigation": {
|
||||
"home": "Home",
|
||||
"blog": "Blog",
|
||||
"tags": "Tags",
|
||||
"about": "About"
|
||||
},
|
||||
|
||||
"Breadcrumbs": {
|
||||
"home": "Home",
|
||||
"blog": "Blog",
|
||||
"tags": "Tags",
|
||||
"about": "About"
|
||||
},
|
||||
|
||||
"BlogListing": {
|
||||
"title": "Blog",
|
||||
"subtitle": "Latest articles and thoughts",
|
||||
"searchPlaceholder": "Search articles...",
|
||||
"sortBy": "Sort by",
|
||||
"sortNewest": "Newest",
|
||||
"sortOldest": "Oldest",
|
||||
"sortTitle": "Title",
|
||||
"filterByTag": "Filter by tag",
|
||||
"clearFilters": "Clear filters",
|
||||
"foundPosts": "Found {count} posts",
|
||||
"noPosts": "No posts found"
|
||||
},
|
||||
|
||||
"BlogPost": {
|
||||
"readMore": "Read more",
|
||||
"readingTime": "{minutes} min read",
|
||||
"publishedOn": "Published on {date}",
|
||||
"author": "By {author}",
|
||||
"tags": "Tags",
|
||||
"relatedPosts": "Related Posts",
|
||||
"sharePost": "Share this post"
|
||||
},
|
||||
|
||||
"Tags": {
|
||||
"title": "Tags",
|
||||
"subtitle": "Browse by topic",
|
||||
"allTags": "All Tags",
|
||||
"postsWithTag": "{count} posts tagged with {tag}",
|
||||
"relatedTags": "Related tags",
|
||||
"quickNav": "Quick navigation"
|
||||
},
|
||||
|
||||
"About": {
|
||||
"title": "About",
|
||||
"subtitle": "Learn more about me"
|
||||
},
|
||||
|
||||
"NotFound": {
|
||||
"title": "Page Not Found",
|
||||
"description": "The page you're looking for doesn't exist",
|
||||
"goHome": "Go to homepage"
|
||||
},
|
||||
|
||||
"LanguageSwitcher": {
|
||||
"switchLanguage": "Switch language",
|
||||
"currentLanguage": "Current language"
|
||||
}
|
||||
}
|
||||
69
messages/ro.json
Normal file
69
messages/ro.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"Metadata": {
|
||||
"siteTitle": "Blog Personal",
|
||||
"siteDescription": "Gânduri despre tehnologie și dezvoltare"
|
||||
},
|
||||
|
||||
"Navigation": {
|
||||
"home": "Acasă",
|
||||
"blog": "Blog",
|
||||
"tags": "Etichete",
|
||||
"about": "Despre"
|
||||
},
|
||||
|
||||
"Breadcrumbs": {
|
||||
"home": "Acasă",
|
||||
"blog": "Blog",
|
||||
"tags": "Etichete",
|
||||
"about": "Despre"
|
||||
},
|
||||
|
||||
"BlogListing": {
|
||||
"title": "Blog",
|
||||
"subtitle": "Ultimele articole și gânduri",
|
||||
"searchPlaceholder": "Caută articole...",
|
||||
"sortBy": "Sortează după",
|
||||
"sortNewest": "Cele mai noi",
|
||||
"sortOldest": "Cele mai vechi",
|
||||
"sortTitle": "Titlu",
|
||||
"filterByTag": "Filtrează după etichetă",
|
||||
"clearFilters": "Șterge filtrele",
|
||||
"foundPosts": "{count} articole găsite",
|
||||
"noPosts": "Niciun articol găsit"
|
||||
},
|
||||
|
||||
"BlogPost": {
|
||||
"readMore": "Citește mai mult",
|
||||
"readingTime": "{minutes} min citire",
|
||||
"publishedOn": "Publicat pe {date}",
|
||||
"author": "De {author}",
|
||||
"tags": "Etichete",
|
||||
"relatedPosts": "Articole similare",
|
||||
"sharePost": "Distribuie acest articol"
|
||||
},
|
||||
|
||||
"Tags": {
|
||||
"title": "Etichete",
|
||||
"subtitle": "Navighează după subiect",
|
||||
"allTags": "Toate etichetele",
|
||||
"postsWithTag": "{count} articole cu eticheta {tag}",
|
||||
"relatedTags": "Etichete similare",
|
||||
"quickNav": "Navigare rapidă"
|
||||
},
|
||||
|
||||
"About": {
|
||||
"title": "Despre",
|
||||
"subtitle": "Află mai multe despre mine"
|
||||
},
|
||||
|
||||
"NotFound": {
|
||||
"title": "Pagina nu a fost găsită",
|
||||
"description": "Pagina pe care o cauți nu există",
|
||||
"goHome": "Mergi la pagina principală"
|
||||
},
|
||||
|
||||
"LanguageSwitcher": {
|
||||
"switchLanguage": "Schimbă limba",
|
||||
"currentLanguage": "Limba curentă"
|
||||
}
|
||||
}
|
||||
20
middleware.ts
Normal file
20
middleware.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import {routing} from './src/i18n/routing';
|
||||
|
||||
export default createMiddleware({
|
||||
...routing,
|
||||
localeDetection: true,
|
||||
localeCookie: {
|
||||
name: 'NEXT_LOCALE',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: 'lax'
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/',
|
||||
'/(en|ro)/:path*',
|
||||
'/((?!api|_next|_vercel|.*\\..*).*)'
|
||||
]
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
const withNextIntl = require('next-intl/plugin')();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
// ============================================
|
||||
// Next.js 16 Configuration
|
||||
@@ -245,4 +247,4 @@ const nextConfig = {
|
||||
// },
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
module.exports = withNextIntl(nextConfig)
|
||||
|
||||
729
package-lock.json
generated
729
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -30,15 +30,16 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"gray-matter": "^4.0.3",
|
||||
"next": "^16.0.1",
|
||||
"next": "^16.0.7",
|
||||
"next-intl": "^4.5.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
@@ -50,16 +51,16 @@
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@next/bundle-analyzer": "^16.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
||||
"@typescript-eslint/parser": "^8.46.4",
|
||||
"@next/bundle-analyzer": "^16.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.3",
|
||||
"eslint-config-next": "^16.0.7",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript-eslint": "^8.46.4"
|
||||
"prettier": "^3.7.4",
|
||||
"typescript-eslint": "^8.48.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
title: 'Technical Article'
|
||||
description: 'A technical article to test internal links'
|
||||
date: '2025-01-10'
|
||||
author: 'John Doe'
|
||||
category: 'Tech'
|
||||
tags: ['tech', 'test']
|
||||
---
|
||||
|
||||
# Technical Article
|
||||
|
||||
This is a test article for internal blog post linking.
|
||||
|
||||
Imagine cooler:
|
||||
|
||||

|
||||
|
||||
## Content
|
||||
|
||||
You are reading the technical article that was linked from the example post.
|
||||
5
src/i18n/navigation.ts
Normal file
5
src/i18n/navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import {createNavigation} from 'next-intl/navigation';
|
||||
import {routing} from './routing';
|
||||
|
||||
export const {Link, redirect, usePathname, useRouter} =
|
||||
createNavigation(routing);
|
||||
15
src/i18n/request.ts
Normal file
15
src/i18n/request.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {getRequestConfig} from 'next-intl/server';
|
||||
import {routing} from './routing';
|
||||
|
||||
export default getRequestConfig(async ({requestLocale}) => {
|
||||
let locale = await requestLocale;
|
||||
|
||||
if (!locale || !routing.locales.includes(locale as any)) {
|
||||
locale = routing.defaultLocale;
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../messages/${locale}.json`)).default
|
||||
};
|
||||
});
|
||||
13
src/i18n/routing.ts
Normal file
13
src/i18n/routing.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import {defineRouting} from 'next-intl/routing';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['en', 'ro'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix: 'always',
|
||||
localeNames: {
|
||||
en: 'English',
|
||||
ro: 'Română'
|
||||
}
|
||||
} as any);
|
||||
|
||||
export type Locale = (typeof routing.locales)[number];
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -19,7 +23,12 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"@/i18n/*": [
|
||||
"./src/i18n/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -29,5 +38,7 @@
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
5
types/translations.d.ts
vendored
Normal file
5
types/translations.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
type Messages = typeof import('../messages/en.json');
|
||||
|
||||
declare global {
|
||||
interface IntlMessages extends Messages {}
|
||||
}
|
||||
Reference in New Issue
Block a user