9 Commits

Author SHA1 Message Date
RJ
b68325123b 📝 update copy
All checks were successful
PR Checks / lint-and-build (pull_request) Successful in 17s
2025-12-04 15:55:36 +02:00
RJ
087bccbb13 📝 add locale on blog ssr, add translations for other webpage content 2025-12-04 14:56:00 +02:00
RJ
d349c1a957 Update nextjs to version 16.0.7
All checks were successful
Build and Deploy Next.js Blog to Staging / 🔍 Code Quality Checks (push) Successful in 26s
Build and Deploy Next.js Blog to Staging / 🏗️ Build and Push Docker Image (push) Successful in 44s
Build and Deploy Next.js Blog to Staging / 🚀 Deploy to Staging (push) Successful in 48s
Build and Deploy Next.js Blog to Production / 🔍 Code Quality Checks (push) Successful in 17s
Build and Deploy Next.js Blog to Production / 🏗️ Build and Push Docker Image (push) Successful in 4s
Build and Deploy Next.js Blog to Production / 🚀 Deploy to Production (push) Successful in 47s
PR Checks / lint-and-build (pull_request) Successful in 18s
2025-12-04 07:41:13 +00:00
RJ
0e0c21449b 🚨 update translation
All checks were successful
Build and Deploy Next.js Blog to Staging / 🔍 Code Quality Checks (push) Successful in 17s
Build and Deploy Next.js Blog to Staging / 🏗️ Build and Push Docker Image (push) Successful in 35s
PR Checks / lint-and-build (pull_request) Successful in 21s
Build and Deploy Next.js Blog to Staging / 🚀 Deploy to Staging (push) Successful in 48s
2025-12-03 17:51:34 +02:00
RJ
73cbc8f731 🟢 pr check on any opened pr
All checks were successful
Build and Deploy Next.js Blog to Staging / 🔍 Code Quality Checks (push) Successful in 23s
PR Checks / lint-and-build (pull_request) Successful in 22s
Build and Deploy Next.js Blog to Staging / 🏗️ Build and Push Docker Image (push) Successful in 32s
Build and Deploy Next.js Blog to Staging / 🚀 Deploy to Staging (push) Successful in 48s
Build and Deploy Next.js Blog to Production / 🔍 Code Quality Checks (push) Successful in 18s
Build and Deploy Next.js Blog to Production / 🏗️ Build and Push Docker Image (push) Successful in 3s
Build and Deploy Next.js Blog to Production / 🚀 Deploy to Production (push) Successful in 47s
2025-12-03 16:34:39 +02:00
RJ
77b4e95a93 🚨 add pr checks 2025-12-03 16:33:43 +02:00
RJ
fd50757c94 📄 update staging pipeline script 2025-12-03 16:33:43 +02:00
RJ
7e8b82f571 📄 Huge intl feature 2025-12-03 16:33:43 +02:00
RJ
8b05aae5a8 🏗️ add staging CICD and env 2025-12-03 16:33:43 +02:00
54 changed files with 1926 additions and 449 deletions

View 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"

View 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

46
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,46 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: Full Stack",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
},
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"name": "Next.js: Frontend",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3030",
"webRoot": "${workspaceFolder}",
"sourceMapPathOverrides": {
"webpack://_N_E/*": "${webRoot}/*"
}
},
{
"name": "Next.js: Backend",
"type": "node",
"request": "attach",
"port": 9229,
"restart": true,
"sourceMaps": true,
"sourceMapPathOverrides": {
"webpack:///*": "${workspaceFolder}/*"
}
}
],
"compounds": [
{
"name": "Next.js: Debug Full Stack",
"configurations": ["Next.js: Backend", "Next.js: Frontend"],
"stopAll": true
}
]
}

View File

@@ -1,12 +1,20 @@
import { Metadata } from 'next' import { Metadata } from 'next'
import { Navbar } from '@/components/blog/navbar' import { Navbar } from '@/components/blog/navbar'
import {setRequestLocale, getTranslations} from 'next-intl/server'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'About', title: 'About',
description: 'Learn more about me and this blog', 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)
const t = await getTranslations('About')
return ( return (
<> <>
<Navbar /> <Navbar />
@@ -15,10 +23,10 @@ export default function AboutPage() {
{/* Classification Header */} {/* Classification Header */}
<div className="border-2 border-[rgb(var(--border-primary))] p-8 mb-10"> <div className="border-2 border-[rgb(var(--border-primary))] p-8 mb-10">
<p className="text-[rgb(var(--text-muted))] font-mono text-xs uppercase tracking-widest mb-4"> <p className="text-[rgb(var(--text-muted))] font-mono text-xs uppercase tracking-widest mb-4">
&gt;&gt; _DOC://PUBLIC_ACCESS {t('classificationHeader')}
</p> </p>
<h1 className="text-4xl md:text-5xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] tracking-tight"> <h1 className="text-4xl md:text-5xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] tracking-tight">
ABOUT ME_ {t('mainTitle')}
</h1> </h1>
</div> </div>
@@ -28,12 +36,10 @@ export default function AboutPage() {
<section className="border-2 border-[rgb(var(--border-primary))] p-8"> <section className="border-2 border-[rgb(var(--border-primary))] p-8">
<div className="border-l-4 border-[var(--neon-cyan)] pl-6"> <div className="border-l-4 border-[var(--neon-cyan)] pl-6">
<p className="font-mono text-base text-[rgb(var(--text-primary))] leading-relaxed mb-4"> <p className="font-mono text-base text-[rgb(var(--text-primary))] leading-relaxed mb-4">
Welcome to my corner of the internet! This is where I share my thoughts, opinions, {t('introParagraph1')}
and experiences - from tech adventures to life as a family man. Yes, I love
technology, but there&apos;s so much more to life than just code and servers.
</p> </p>
<p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase tracking-wider"> <p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase tracking-wider">
STATUS: ACTIVE // ROLE: DAD + DEV + LIFE ENTHUSIAST {t('introLabel')}
</p> </p>
</div> </div>
</section> </section>
@@ -41,46 +47,39 @@ export default function AboutPage() {
{/* Life & Values Section */} {/* Life & Values Section */}
<section className="border-2 border-[rgb(var(--border-primary))] p-8"> <section className="border-2 border-[rgb(var(--border-primary))] p-8">
<h2 className="text-2xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] mb-6 pb-3 border-b-2 border-[rgb(var(--border-primary))]"> <h2 className="text-2xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] mb-6 pb-3 border-b-2 border-[rgb(var(--border-primary))]">
&gt; LIFE & VALUES {t('lifeValuesTitle')}
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
<div className="border-l-4 border-[var(--neon-pink)] pl-6"> <div className="border-l-4 border-[var(--neon-pink)] pl-6">
<h3 className="font-mono text-sm font-bold text-[var(--neon-pink)] uppercase mb-2"> <h3 className="font-mono text-sm font-bold text-[var(--neon-pink)] uppercase mb-2">
[FAMILY FIRST] {t('familyFirstTitle')}
</h3> </h3>
<p className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed">
Being a dad to an amazing toddler is my most important role. Family time is {t('familyFirstText')}
sacred - whether it&apos;s building block towers, exploring parks, or just
enjoying the chaos of everyday life together. Tech can wait; these moments
can&apos;t.
</p> </p>
</div> </div>
<div className="border-l-4 border-[var(--neon-cyan)] pl-6"> <div className="border-l-4 border-[var(--neon-cyan)] pl-6">
<h3 className="font-mono text-sm font-bold text-[var(--neon-cyan)] uppercase mb-2"> <h3 className="font-mono text-sm font-bold text-[var(--neon-cyan)] uppercase mb-2">
[ACTIVE LIFESTYLE] {t('activeLifestyleTitle')}
</h3> </h3>
<p className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed">
I believe in keeping the body active. Whether it&apos;s hitting the gym, playing {t('activeLifestyleText')}
sports, or just staying on the move - physical activity keeps me sharp,
balanced, and ready for whatever life throws my way.
</p> </p>
</div> </div>
<div className="border-l-4 border-[var(--neon-green)] pl-6"> <div className="border-l-4 border-[var(--neon-green)] pl-6">
<h3 className="font-mono text-sm font-bold text-[var(--neon-green)] uppercase mb-2"> <h3 className="font-mono text-sm font-bold text-[var(--neon-green)] uppercase mb-2">
[ENJOYING THE SIMPLE THINGS] {t('simpleThingsTitle')}
</h3> </h3>
<p className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed">
Life&apos;s too short not to enjoy it. A good drink, a relaxing {t('simpleThingsText')}
evening after a long day, or just not doing anything a blowing some steam off.
</p> </p>
</div> </div>
<div className="border-l-4 border-[var(--neon-orange)] pl-6"> <div className="border-l-4 border-[var(--neon-orange)] pl-6">
<h3 className="font-mono text-sm font-bold text-[var(--neon-orange)] uppercase mb-2"> <h3 className="font-mono text-sm font-bold text-[var(--neon-orange)] uppercase mb-2">
[TECH WITH PURPOSE] {t('techPurposeTitle')}
</h3> </h3>
<p className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed">
Yes, I love tech - self-hosting, privacy, tinkering with hardware. But it&apos;s {t('techPurposeText')}
a tool, not a lifestyle. Tech should serve life, not the other way around.
</p> </p>
</div> </div>
</div> </div>
@@ -89,51 +88,46 @@ export default function AboutPage() {
{/* Content Section */} {/* Content Section */}
<section className="border-2 border-[rgb(var(--border-primary))] p-8"> <section className="border-2 border-[rgb(var(--border-primary))] p-8">
<h2 className="text-2xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] mb-6 pb-3 border-b-2 border-[rgb(var(--border-primary))]"> <h2 className="text-2xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] mb-6 pb-3 border-b-2 border-[rgb(var(--border-primary))]">
&gt; WHAT YOU&apos;LL FIND HERE {t('contentTitle')}
</h2> </h2>
<p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase tracking-wider mb-6"> <p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase tracking-wider mb-6">
CONTENT SCOPE // EVERYTHING FROM TECH TO LIFE {t('contentSubtitle')}
</p> </p>
<ul className="space-y-3"> <ul className="space-y-3">
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<span className="text-[var(--neon-pink)] font-mono font-bold">&gt;</span> <span className="text-[var(--neon-pink)] font-mono font-bold">&gt;</span>
<span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed"> <span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed">
<strong>Thoughts & Opinions</strong> - My take on life, work, and everything in <strong>{t('contentThoughts')}</strong> - {t('contentThoughtsDesc')}
between
</span> </span>
</li> </li>
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<span className="text-[var(--neon-pink)] font-mono font-bold">&gt;</span> <span className="text-[var(--neon-pink)] font-mono font-bold">&gt;</span>
<span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed"> <span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed">
<strong>Life & Family</strong> - Adventures in parenting, sports, and enjoying <strong>{t('contentLifeFamily')}</strong> - {t('contentLifeFamilyDesc')}
the simple things
</span> </span>
</li> </li>
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<span className="text-[var(--neon-cyan)] font-mono font-bold">&gt;</span> <span className="text-[var(--neon-cyan)] font-mono font-bold">&gt;</span>
<span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed"> <span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed">
<strong>Tech Research</strong> - When I dive into interesting technologies and <strong>{t('contentTechResearch')}</strong> - {t('contentTechResearchDesc')}
experiments
</span> </span>
</li> </li>
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<span className="text-[var(--neon-cyan)] font-mono font-bold">&gt;</span> <span className="text-[var(--neon-cyan)] font-mono font-bold">&gt;</span>
<span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed"> <span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed">
<strong>System Administration</strong> - Self-hosting, infrastructure, and <strong>{t('contentSysAdmin')}</strong> - {t('contentSysAdminDesc')}
DevOps adventures
</span> </span>
</li> </li>
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<span className="text-[var(--neon-cyan)] font-mono font-bold">&gt;</span> <span className="text-[var(--neon-cyan)] font-mono font-bold">&gt;</span>
<span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed"> <span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed">
<strong>Development Insights</strong> - Lessons learned from building software <strong>{t('contentDevelopment')}</strong> - {t('contentDevelopmentDesc')}
</span> </span>
</li> </li>
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<span className="text-[var(--neon-green)] font-mono font-bold">&gt;</span> <span className="text-[var(--neon-green)] font-mono font-bold">&gt;</span>
<span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed"> <span className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed">
<strong>Random Stuff</strong> - Because life doesn&apos;t fit into neat <strong>{t('contentRandom')}</strong> - {t('contentRandomDesc')}
categories!
</span> </span>
</li> </li>
</ul> </ul>
@@ -142,40 +136,39 @@ export default function AboutPage() {
{/* Areas of Focus Section */} {/* Areas of Focus Section */}
<section className="border-2 border-[rgb(var(--border-primary))] p-8"> <section className="border-2 border-[rgb(var(--border-primary))] p-8">
<h2 className="text-2xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] mb-6 pb-3 border-b-2 border-[rgb(var(--border-primary))]"> <h2 className="text-2xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] mb-6 pb-3 border-b-2 border-[rgb(var(--border-primary))]">
&gt; AREAS OF FOCUS {t('focusTitle')}
</h2> </h2>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div className="border border-[rgb(var(--border-primary))] p-4"> <div className="border border-[rgb(var(--border-primary))] p-4">
<h3 className="font-mono text-xs font-bold text-[var(--neon-pink)] uppercase mb-2"> <h3 className="font-mono text-xs font-bold text-[var(--neon-pink)] uppercase mb-2">
[BEING A DAD] {t('focusBeingDadTitle')}
</h3> </h3>
<p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed">
Playing with my boy, teaching moments, watching him grow, building memories {t('focusBeingDadText')}
together
</p> </p>
</div> </div>
<div className="border border-[rgb(var(--border-primary))] p-4"> <div className="border border-[rgb(var(--border-primary))] p-4">
<h3 className="font-mono text-xs font-bold text-[var(--neon-cyan)] uppercase mb-2"> <h3 className="font-mono text-xs font-bold text-[var(--neon-cyan)] uppercase mb-2">
[STAYING ACTIVE] {t('focusStayingActiveTitle')}
</h3> </h3>
<p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed">
Gym sessions, sports, keeping fit, maintaining energy for life&apos;s demands {t('focusStayingActiveText')}
</p> </p>
</div> </div>
<div className="border border-[rgb(var(--border-primary))] p-4"> <div className="border border-[rgb(var(--border-primary))] p-4">
<h3 className="font-mono text-xs font-bold text-[var(--neon-green)] uppercase mb-2"> <h3 className="font-mono text-xs font-bold text-[var(--neon-green)] uppercase mb-2">
[TECHNOLOGY & SYSTEMS] {t('focusTechnologyTitle')}
</h3> </h3>
<p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed">
Software development, infrastructure, DevOps, self-hosting adventures {t('focusTechnologyText')}
</p> </p>
</div> </div>
<div className="border border-[rgb(var(--border-primary))] p-4"> <div className="border border-[rgb(var(--border-primary))] p-4">
<h3 className="font-mono text-xs font-bold text-[var(--neon-orange)] uppercase mb-2"> <h3 className="font-mono text-xs font-bold text-[var(--neon-orange)] uppercase mb-2">
[LIFE BALANCE] {t('focusLifeBalanceTitle')}
</h3> </h3>
<p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed">
Relaxing with good company, enjoying downtime, appreciating the simple moments {t('focusLifeBalanceText')}
</p> </p>
</div> </div>
</div> </div>
@@ -183,42 +176,42 @@ export default function AboutPage() {
{/* Tech Stack Section */} {/* Tech Stack Section */}
<section className="border-2 border-[rgb(var(--border-primary))] p-8"> <section className="border-2 border-[rgb(var(--border-primary))] p-8">
<h2 className="text-2xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] mb-6 pb-3 border-b-2 border-[rgb(var(--border-primary))]"> <h2 className="text-2xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] mb-6 pb-3 border-b-2 border-[rgb(var(--border-primary))]">
&gt; TECH STACK {t('techStackTitle')}
</h2> </h2>
<p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase tracking-wider mb-6"> <p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase tracking-wider mb-6">
TOOLS I USE // WHEN NEEDED {t('techStackSubtitle')}
</p> </p>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div className="border border-[rgb(var(--border-primary))] p-4"> <div className="border border-[rgb(var(--border-primary))] p-4">
<h3 className="font-mono text-xs font-bold text-[var(--neon-cyan)] uppercase mb-2"> <h3 className="font-mono text-xs font-bold text-[var(--neon-cyan)] uppercase mb-2">
[DEVELOPMENT] {t('techStackDevelopmentTitle')}
</h3> </h3>
<p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed">
.NET, Golang, TypeScript, Next.js, React {t('techStackDevelopmentText')}
</p> </p>
</div> </div>
<div className="border border-[rgb(var(--border-primary))] p-4"> <div className="border border-[rgb(var(--border-primary))] p-4">
<h3 className="font-mono text-xs font-bold text-[var(--neon-cyan)] uppercase mb-2"> <h3 className="font-mono text-xs font-bold text-[var(--neon-cyan)] uppercase mb-2">
[INFRASTRUCTURE] {t('techStackInfrastructureTitle')}
</h3> </h3>
<p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed">
Windows Server, Linux, Docker, Hyper-V {t('techStackInfrastructureText')}
</p> </p>
</div> </div>
<div className="border border-[rgb(var(--border-primary))] p-4"> <div className="border border-[rgb(var(--border-primary))] p-4">
<h3 className="font-mono text-xs font-bold text-[var(--neon-cyan)] uppercase mb-2"> <h3 className="font-mono text-xs font-bold text-[var(--neon-cyan)] uppercase mb-2">
[DESIGN] {t('techStackDesignTitle')}
</h3> </h3>
<p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed">
Tailwind CSS, Markdown, Terminal aesthetics {t('techStackDesignText')}
</p> </p>
</div> </div>
<div className="border border-[rgb(var(--border-primary))] p-4"> <div className="border border-[rgb(var(--border-primary))] p-4">
<h3 className="font-mono text-xs font-bold text-[var(--neon-cyan)] uppercase mb-2"> <h3 className="font-mono text-xs font-bold text-[var(--neon-cyan)] uppercase mb-2">
[SELF-HOSTING] {t('techStackSelfHostingTitle')}
</h3> </h3>
<p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed"> <p className="font-mono text-xs text-[rgb(var(--text-primary))] leading-relaxed">
Home lab, privacy-focused services, full control, Git server {t('techStackSelfHostingText')}
</p> </p>
</div> </div>
</div> </div>
@@ -226,7 +219,7 @@ export default function AboutPage() {
{/* Contact Section */} {/* Contact Section */}
<section className="border-2 border-[rgb(var(--border-primary))] p-8"> <section className="border-2 border-[rgb(var(--border-primary))] p-8">
<h2 className="text-2xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] mb-6 pb-3 border-b-2 border-[rgb(var(--border-primary))]"> <h2 className="text-2xl font-mono font-bold uppercase text-[rgb(var(--text-primary))] mb-6 pb-3 border-b-2 border-[rgb(var(--border-primary))]">
&gt; CONTACT {t('contactTitle')}
</h2> </h2>
<div className="border-l-4 border-[var(--neon-pink)] pl-6"> <div className="border-l-4 border-[var(--neon-pink)] pl-6">
{/* <p className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed mb-4"> {/* <p className="font-mono text-sm text-[rgb(var(--text-primary))] leading-relaxed mb-4">

View File

@@ -1,26 +1,29 @@
import Link from 'next/link' import { useTranslations } from 'next-intl'
import { Link } from '@/i18n/navigation'
export default function NotFound() { export default function NotFound() {
const t = useTranslations('NotFound')
return ( return (
<div className="min-h-[60vh] flex items-center justify-center"> <div className="min-h-[60vh] flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<h1 className="text-6xl font-bold text-gray-300 dark:text-gray-700 mb-4">404</h1> <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"> <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> </p>
<div className="space-x-4"> <div className="space-x-4">
<Link <Link
href="/blog" href="/blog"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition" 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>
<Link <Link
href="/" 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" 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> </Link>
</div> </div>
</div> </div>

View File

@@ -1,26 +1,36 @@
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 '@/src/i18n/navigation'
import { getAllPosts, getPostBySlug, getRelatedPosts } from '@/lib/markdown' import { getAllPosts, getPostBySlug, getRelatedPosts } from '@/lib/markdown'
import { formatDate, formatRelativeDate } from '@/lib/utils' import { formatDate, formatRelativeDate } from '@/lib/utils'
import { TableOfContents } from '@/components/blog/table-of-contents' import { TableOfContents } from '@/components/blog/table-of-contents'
import { ReadingProgress } from '@/components/blog/reading-progress' import { ReadingProgress } from '@/components/blog/reading-progress'
import { StickyFooter } from '@/components/blog/sticky-footer' import { StickyFooter } from '@/components/blog/sticky-footer'
import MarkdownRenderer from '@/components/blog/markdown-renderer' import MarkdownRenderer from '@/components/blog/markdown-renderer'
import { setRequestLocale } from 'next-intl/server'
import { routing } from '@/src/i18n/routing'
export async function generateStaticParams() { export async function generateStaticParams() {
const posts = await getAllPosts() const locales = ['en', 'ro']
return posts.map(post => ({ slug: post.slug.split('/') })) 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({ export async function generateMetadata({
params, params,
}: { }: {
params: Promise<{ slug: string[] }> params: Promise<{ locale: string; slug: string[] }>
}): Promise<Metadata> { }): Promise<Metadata> {
const { slug } = await params const { slug, locale } = await params
const slugPath = slug.join('/') const slugPath = slug.join('/')
const post = await getPostBySlug(slugPath) const post = await getPostBySlug(slugPath, locale)
if (!post) { if (!post) {
return { title: 'Articol negăsit' } return { title: 'Articol negăsit' }
@@ -65,10 +75,14 @@ function extractHeadings(content: string) {
return headings return headings
} }
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) { export default async function BlogPostPage({
const { slug } = await params params,
}: {
params: Promise<{ locale: string; slug: string[] }>
}) {
const { slug, locale } = await params
const slugPath = slug.join('/') const slugPath = slug.join('/')
const post = await getPostBySlug(slugPath) const post = await getPostBySlug(slugPath, locale)
if (!post) { if (!post) {
notFound() notFound()

View File

@@ -1,12 +1,12 @@
'use client' 'use client'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Post } from '@/lib/types/frontmatter' import { Post } from '@/lib/types/frontmatter'
import { BlogCard } from '@/components/blog/blog-card' import { BlogCard } from '@/components/blog/blog-card'
import { SearchBar } from '@/components/blog/search-bar' import { SearchBar } from '@/components/blog/search-bar'
import { SortDropdown } from '@/components/blog/sort-dropdown' import { SortDropdown } from '@/components/blog/sort-dropdown'
import { TagFilter } from '@/components/blog/tag-filter' import { TagFilter } from '@/components/blog/tag-filter'
import { Navbar } from '@/components/blog/navbar'
interface BlogPageClientProps { interface BlogPageClientProps {
posts: Post[] posts: Post[]
@@ -16,6 +16,7 @@ interface BlogPageClientProps {
type SortOption = 'newest' | 'oldest' | 'title' type SortOption = 'newest' | 'oldest' | 'title'
export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) { export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) {
const t = useTranslations('BlogListing')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedTags, setSelectedTags] = useState<string[]>([]) const [selectedTags, setSelectedTags] = useState<string[]>([])
const [sortBy, setSortBy] = useState<SortOption>('newest') const [sortBy, setSortBy] = useState<SortOption>('newest')
@@ -67,10 +68,10 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
{/* Header */} {/* Header */}
<div className="border-l border-[var(--neon-cyan)] pl-6 mb-12"> <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"> <p className="font-mono text-xs text-[rgb(var(--text-muted))] uppercase tracking-widest mb-2">
DATABASE QUERY // SEARCH RESULTS {t("subtitle")}
</p> </p>
<h1 className="text-4xl md:text-6xl font-mono font-bold text-[rgb(var(--text-primary))] uppercase tracking-tight"> <h1 className="text-4xl md:text-6xl font-mono font-bold text-[rgb(var(--text-primary))] uppercase tracking-tight">
&gt; BLOG ARCHIVE_ &gt; {t("title")}_
</h1> </h1>
</div> </div>
@@ -102,8 +103,8 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
{/* Results Count */} {/* Results Count */}
<div className="mb-6"> <div className="mb-6">
<p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase"> <p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase">
FOUND {filteredAndSortedPosts.length}{' '} {t("foundPosts", {count: filteredAndSortedPosts.length})}{' '}
{filteredAndSortedPosts.length === 1 ? 'POST' : 'POSTS'}
</p> </p>
</div> </div>
@@ -126,7 +127,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"> <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"> <p className="font-mono text-lg text-[rgb(var(--text-muted))] uppercase">
NO POSTS FOUND // TRY DIFFERENT SEARCH TERMS {t("noPosts")}
</p> </p>
</div> </div>
)} )}
@@ -140,7 +141,7 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
disabled={currentPage === 1} disabled={currentPage === 1}
className="px-6 py-3 font-mono text-sm uppercase border border-[rgb(var(--border-primary))] text-[rgb(var(--text-primary))] disabled:opacity-30 disabled:cursor-not-allowed hover:border-[var(--neon-cyan)] hover:text-[var(--neon-cyan)] transition-colors cursor-pointer" className="px-6 py-3 font-mono text-sm uppercase border border-[rgb(var(--border-primary))] text-[rgb(var(--text-primary))] disabled:opacity-30 disabled:cursor-not-allowed hover:border-[var(--neon-cyan)] hover:text-[var(--neon-cyan)] transition-colors cursor-pointer"
> >
&lt; PREV {t('prev')}
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => ( {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
@@ -162,7 +163,7 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="px-6 py-3 font-mono text-sm uppercase border border-[rgb(var(--border-primary))] text-[rgb(var(--text-primary))] disabled:opacity-30 disabled:cursor-not-allowed hover:border-[var(--neon-cyan)] hover:text-[var(--neon-cyan)] transition-colors cursor-pointer" className="px-6 py-3 font-mono text-sm uppercase border border-[rgb(var(--border-primary))] text-[rgb(var(--text-primary))] disabled:opacity-30 disabled:cursor-not-allowed hover:border-[var(--neon-cyan)] hover:text-[var(--neon-cyan)] transition-colors cursor-pointer"
> >
NEXT &gt; {t('next')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,12 @@
import { getAllPosts } from '@/lib/markdown'
import BlogPageClient from './blog-client'
import { setRequestLocale } from 'next-intl/server'
export default async function BlogPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
await setRequestLocale(locale)
const posts = await getAllPosts(locale)
const allTags = Array.from(new Set(posts.flatMap(post => post.frontmatter.tags))).sort()
return <BlogPageClient posts={posts} allTags={allTags} />
}

35
app/[locale]/layout.tsx Normal file
View 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>
</>
)
}

View File

@@ -1,10 +1,20 @@
import Link from 'next/link' import {Link} from '@/src/i18n/navigation'
import Image from 'next/image' import Image from 'next/image'
import { getAllPosts } from '@/lib/markdown' import { getAllPosts } from '@/lib/markdown'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import { ThemeToggle } from '@/components/theme-toggle' import { ThemeToggle } from '@/components/theme-toggle'
import {setRequestLocale, getTranslations} from 'next-intl/server'
type Props = {
params: Promise<{locale: string}>
}
export default async function HomePage({params}: Props) {
const {locale} = await params
setRequestLocale(locale)
const t = await getTranslations('Home')
const tNav = await getTranslations('Navigation')
export default async function HomePage() {
const allPosts = await getAllPosts() const allPosts = await getAllPosts()
const featuredPosts = allPosts.slice(0, 6) const featuredPosts = allPosts.slice(0, 6)
@@ -23,7 +33,7 @@ export default async function HomePage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Image src="/logo.png" alt="Logo" width={32} height={32} className="opacity-80" /> <Image src="/logo.png" alt="Logo" width={32} height={32} className="opacity-80" />
<span className="font-mono text-xs text-slate-500 uppercase tracking-widest"> <span className="font-mono text-xs text-slate-500 uppercase tracking-widest">
TERMINAL:// V2.0 {t('terminalVersion')}
</span> </span>
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
@@ -31,13 +41,13 @@ export default async function HomePage() {
href="/blog" href="/blog"
className="font-mono text-xs text-slate-600 dark:text-slate-400 uppercase tracking-wider hover:text-cyan-600 dark:hover:text-cyan-400" className="font-mono text-xs text-slate-600 dark:text-slate-400 uppercase tracking-wider hover:text-cyan-600 dark:hover:text-cyan-400"
> >
[BLOG] [{tNav('blog')}]
</Link> </Link>
<Link <Link
href="/about" href="/about"
className="font-mono text-xs text-slate-600 dark:text-slate-400 uppercase tracking-wider hover:text-cyan-600 dark:hover:text-cyan-400" className="font-mono text-xs text-slate-600 dark:text-slate-400 uppercase tracking-wider hover:text-cyan-600 dark:hover:text-cyan-400"
> >
[ABOUT] [{tNav('about')}]
</Link> </Link>
<ThemeToggle /> <ThemeToggle />
</div> </div>
@@ -45,15 +55,13 @@ export default async function HomePage() {
<div className="border-l-4 border-cyan-700 dark:border-cyan-900 pl-6 mb-8"> <div className="border-l-4 border-cyan-700 dark:border-cyan-900 pl-6 mb-8">
<p className="font-mono text-xs text-slate-500 dark:text-slate-500 uppercase tracking-widest mb-2"> <p className="font-mono text-xs text-slate-500 dark:text-slate-500 uppercase tracking-widest mb-2">
DOCUMENT LEVEL-1 // {t('documentLevel')}
</p> </p>
<h1 className="text-4xl md:text-6xl lg:text-7xl font-mono font-bold text-slate-900 dark:text-slate-100 uppercase tracking-tight mb-6"> <h1 className="text-4xl md:text-6xl lg:text-7xl font-mono font-bold text-slate-900 dark:text-slate-100 uppercase tracking-tight mb-6">
BUILD. WRITE. {t('heroTitle')}
<br />
SHARE.
</h1> </h1>
<p className="text-base md:text-lg lg:text-xl text-slate-700 dark:text-slate-400 font-mono leading-relaxed max-w-2xl"> <p className="text-base md:text-lg lg:text-xl text-slate-700 dark:text-slate-400 font-mono leading-relaxed max-w-2xl">
&gt; Explore ideas {t('heroSubtitle')}
</p> </p>
</div> </div>
@@ -62,13 +70,13 @@ export default async function HomePage() {
href="/blog" href="/blog"
className="px-6 md:px-8 py-3 md:py-4 bg-cyan-700 dark:bg-cyan-900 text-white dark:text-slate-100 border-2 border-cyan-600 dark:border-cyan-700 font-mono font-bold uppercase text-xs md:text-sm tracking-wider hover:bg-cyan-600 dark:hover:bg-cyan-800 hover:border-cyan-500 dark:hover:border-cyan-600 rounded-none transition-colors duration-200" className="px-6 md:px-8 py-3 md:py-4 bg-cyan-700 dark:bg-cyan-900 text-white dark:text-slate-100 border-2 border-cyan-600 dark:border-cyan-700 font-mono font-bold uppercase text-xs md:text-sm tracking-wider hover:bg-cyan-600 dark:hover:bg-cyan-800 hover:border-cyan-500 dark:hover:border-cyan-600 rounded-none transition-colors duration-200"
> >
[CHECK POSTS] {t('checkPostsButton')}
</Link> </Link>
<Link <Link
href="/about" href="/about"
className="px-6 md:px-8 py-3 md:py-4 bg-transparent text-slate-700 dark:text-slate-300 border-2 border-slate-400 dark:border-slate-700 font-mono font-bold uppercase text-xs md:text-sm tracking-wider hover:bg-slate-200 dark:hover:bg-slate-800 hover:border-slate-500 dark:hover:border-slate-600 rounded-none transition-colors duration-200" className="px-6 md:px-8 py-3 md:py-4 bg-transparent text-slate-700 dark:text-slate-300 border-2 border-slate-400 dark:border-slate-700 font-mono font-bold uppercase text-xs md:text-sm tracking-wider hover:bg-slate-200 dark:hover:bg-slate-800 hover:border-slate-500 dark:hover:border-slate-600 rounded-none transition-colors duration-200"
> >
[ABOUT ME] {t('aboutMeButton')}
</Link> </Link>
</div> </div>
</div> </div>
@@ -80,10 +88,10 @@ export default async function HomePage() {
<div className="max-w-7xl mx-auto px-6"> <div className="max-w-7xl mx-auto px-6">
<div className="border-l-4 border-emerald-700 dark:border-emerald-900 pl-6 mb-12"> <div className="border-l-4 border-emerald-700 dark:border-emerald-900 pl-6 mb-12">
<p className="font-mono text-xs text-slate-500 dark:text-slate-500 uppercase tracking-widest mb-2"> <p className="font-mono text-xs text-slate-500 dark:text-slate-500 uppercase tracking-widest mb-2">
ARCHIVE ACCESS // RECENT ENTRIES {t('recentEntriesLabel')}
</p> </p>
<h2 className="text-3xl md:text-5xl font-mono font-bold text-slate-900 dark:text-slate-100 uppercase tracking-tight"> <h2 className="text-3xl md:text-5xl font-mono font-bold text-slate-900 dark:text-slate-100 uppercase tracking-tight">
&gt; RECENT ENTRIES {t('recentEntriesTitle')}
</h2> </h2>
</div> </div>
@@ -133,7 +141,7 @@ export default async function HomePage() {
href={`/blog/${post.slug}`} href={`/blog/${post.slug}`}
className="inline-flex items-center text-cyan-600 dark:text-cyan-400 font-mono text-xs font-bold uppercase tracking-wider hover:text-cyan-500 dark:hover:text-cyan-300 border-2 border-slate-400 dark:border-slate-700 px-4 py-2 hover:border-cyan-700 dark:hover:border-cyan-900 transition-colors duration-200" className="inline-flex items-center text-cyan-600 dark:text-cyan-400 font-mono text-xs font-bold uppercase tracking-wider hover:text-cyan-500 dark:hover:text-cyan-300 border-2 border-slate-400 dark:border-slate-700 px-4 py-2 hover:border-cyan-700 dark:hover:border-cyan-900 transition-colors duration-200"
> >
[ACCESEAZĂ] &gt;&gt; {t('accessButton')}
</Link> </Link>
</div> </div>
</article> </article>
@@ -146,14 +154,14 @@ export default async function HomePage() {
href="/blog" href="/blog"
className="inline-flex items-center px-8 py-4 bg-transparent text-slate-700 dark:text-slate-300 border-2 border-slate-400 dark:border-slate-700 font-mono font-bold uppercase text-sm tracking-wider hover:bg-slate-200 dark:hover:bg-slate-800 hover:border-slate-500 dark:hover:border-slate-600 transition-colors duration-200" className="inline-flex items-center px-8 py-4 bg-transparent text-slate-700 dark:text-slate-300 border-2 border-slate-400 dark:border-slate-700 font-mono font-bold uppercase text-sm tracking-wider hover:bg-slate-200 dark:hover:bg-slate-800 hover:border-slate-500 dark:hover:border-slate-600 transition-colors duration-200"
> >
[SEE POSTS] &gt;&gt; {t('seePostsButton')}
</Link> </Link>
)} )}
<Link <Link
href="/tags" href="/tags"
className="inline-flex items-center px-8 py-4 bg-transparent text-slate-700 dark:text-slate-300 border-2 border-slate-400 dark:border-slate-700 font-mono font-bold uppercase text-sm tracking-wider hover:bg-slate-200 dark:hover:bg-slate-800 hover:border-slate-500 dark:hover:border-slate-600 transition-colors duration-200" className="inline-flex items-center px-8 py-4 bg-transparent text-slate-700 dark:text-slate-300 border-2 border-slate-400 dark:border-slate-700 font-mono font-bold uppercase text-sm tracking-wider hover:bg-slate-200 dark:hover:bg-slate-800 hover:border-slate-500 dark:hover:border-slate-600 transition-colors duration-200"
> >
[SEE ALL TAGS] &gt;&gt; {t('seeAllTagsButton')}
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,11 @@
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 '@/src/i18n/navigation'
import { getAllTags, getPostsByTag, getTagInfo, getRelatedTags } from '@/lib/tags' import { getAllTags, getPostsByTag, getTagInfo, getRelatedTags } from '@/lib/tags'
import { TagList } from '@/components/blog/tag-list' import { TagList } from '@/components/blog/tag-list'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import {setRequestLocale} from 'next-intl/server'
import {routing} from '@/src/i18n/routing'
export async function generateStaticParams() { export async function generateStaticParams() {
const tags = await getAllTags() const tags = await getAllTags()
@@ -13,7 +15,7 @@ export async function generateStaticParams() {
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: {
params: Promise<{ tag: string }> params: Promise<{ locale: string; tag: string }>
}): Promise<Metadata> { }): Promise<Metadata> {
const { tag } = await params const { tag } = await params
const tagInfo = await getTagInfo(tag) const tagInfo = await getTagInfo(tag)

View File

@@ -1,15 +1,22 @@
import { Metadata } from 'next' import { Metadata } from 'next'
import Link from 'next/link' import {Link} from '@/src/i18n/navigation'
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'
import {setRequestLocale} from 'next-intl/server'
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() { type Props = {
params: Promise<{locale: string}>
}
export default async function TagsPage({params}: Props) {
const {locale} = await params
setRequestLocale(locale)
const allTags = await getAllTags() const allTags = await getAllTags()
const tagCloud = await getTagCloud() const tagCloud = await getTagCloud()

View File

@@ -1,9 +0,0 @@
import { getAllPosts } from '@/lib/markdown'
import BlogPageClient from './blog-client'
export default async function BlogPage() {
const posts = await getAllPosts()
const allTags = Array.from(new Set(posts.flatMap(post => post.frontmatter.tags))).sort()
return <BlogPageClient posts={posts} allTags={allTags} />
}

View File

@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
export async function GET() { export async function GET() {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3030' 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"?> const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">

View File

@@ -17,6 +17,7 @@
--text-muted: 113 113 122; --text-muted: 113 113 122;
--border-primary: 212 212 216; --border-primary: 212 212 216;
--border-subtle: 228 228 231; --border-subtle: 228 228 231;
--text-color: #1f1f1f;
/* Desaturated cyberpunk for light mode - darker for readability */ /* Desaturated cyberpunk for light mode - darker for readability */
--neon-pink: #7a3d52; --neon-pink: #7a3d52;
@@ -35,6 +36,7 @@
--text-muted: 100 116 139; --text-muted: 100 116 139;
--border-primary: 71 85 105; --border-primary: 71 85 105;
--border-subtle: 30 41 59; --border-subtle: 30 41 59;
--text-color: #d4d4d8;
/* Desaturated cyberpunk for dark mode */ /* Desaturated cyberpunk for dark mode */
--neon-pink: #8a5568; --neon-pink: #8a5568;
@@ -313,7 +315,7 @@
/* Cyberpunk Prose Styling */ /* Cyberpunk Prose Styling */
.cyberpunk-prose { .cyberpunk-prose {
color: rgb(212 212 216); color: var(--text-color);
} }
.cyberpunk-prose h1, .cyberpunk-prose h1,
@@ -347,7 +349,6 @@
} }
.cyberpunk-prose p { .cyberpunk-prose p {
color: rgb(212 212 216);
line-height: 1.625; line-height: 1.625;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
font-size: 1.125rem; font-size: 1.125rem;
@@ -366,7 +367,6 @@
.cyberpunk-prose ul, .cyberpunk-prose ul,
.cyberpunk-prose ol { .cyberpunk-prose ol {
color: rgb(212 212 216);
padding-left: 1.5rem; padding-left: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }

View File

@@ -2,7 +2,9 @@ import type { Metadata } from 'next'
import { JetBrains_Mono } from 'next/font/google' import { JetBrains_Mono } from 'next/font/google'
import './globals.css' import './globals.css'
import { ThemeProvider } from '@/providers/providers' 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' }) 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'], keywords: ['blog', 'dezvoltare web', 'nextjs', 'react', 'typescript', 'terminal'],
openGraph: { openGraph: {
type: 'website', type: 'website',
locale: 'ro_RO',
siteName: 'Terminal Blog', siteName: 'Terminal Blog',
}, },
robots: { 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 ( 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"> <body className="font-mono bg-zinc-50 text-slate-900 dark:bg-zinc-900 dark:text-slate-100 transition-colors duration-300">
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
@@ -40,22 +47,23 @@ export default function RootLayout({ children }: { children: React.ReactNode })
storageKey="blog-theme" storageKey="blog-theme"
disableTransitionOnChange={false} disableTransitionOnChange={false}
> >
<div className="flex flex-col min-h-screen"> <NextIntlClientProvider messages={messages}>
<div className="flex-1">{children}</div> <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">
<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="container mx-auto px-4 py-8"> <div className="border-2 border-slate-300 dark:border-slate-800 p-6">
<div className="border-2 border-slate-300 dark:border-slate-800 p-6"> <p className="text-center text-slate-500 dark:text-slate-500 font-mono text-xs uppercase tracking-wider">
<p className="text-center text-slate-500 dark:text-slate-500 font-mono text-xs uppercase tracking-wider"> © 2025 <span style={{ color: 'var(--neon-cyan)' }}>//</span> BLOG &{' '}
© 2025 <span style={{ color: 'var(--neon-cyan)' }}>//</span> BLOG &{' '} <span style={{ color: 'var(--neon-pink)' }}>RANDOM THOUGHTS</span>{' '}
<span style={{ color: 'var(--neon-pink)' }}>RANDOM THOUGHTS</span>{' '} <span style={{ color: 'var(--neon-cyan)' }}>//</span> ALL RIGHTS RESERVED
<span style={{ color: 'var(--neon-cyan)' }}>//</span> ALL RIGHTS RESERVED </p>
</p> </div>
</div> </div>
</div> </footer>
</footer> </div>
</div> </NextIntlClientProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -5,7 +5,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3030' const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3030'
// Get all blog posts // Get all blog posts
const posts = await getAllPosts(false) const posts = await getAllPosts("en", false)
// Generate sitemap entries for blog posts // Generate sitemap entries for blog posts
const blogPosts: MetadataRoute.Sitemap = posts.map(post => ({ const blogPosts: MetadataRoute.Sitemap = posts.map(post => ({

View File

@@ -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 Image from 'next/image'
import { Post } from '@/lib/types/frontmatter' import { Post } from '@/lib/types/frontmatter'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
@@ -9,6 +10,7 @@ interface BlogCardProps {
} }
export function BlogCard({ post, variant }: BlogCardProps) { export function BlogCard({ post, variant }: BlogCardProps) {
const t = useTranslations('BlogPost')
const hasImage = !!post.frontmatter.image const hasImage = !!post.frontmatter.image
if (!hasImage || variant === 'text-only') { if (!hasImage || variant === 'text-only') {
@@ -38,7 +40,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
))} ))}
</div> </div>
<span className="inline-flex items-center font-mono text-xs uppercase text-cyan-400 hover:text-cyan-300 transition-colors"> <span className="inline-flex items-center font-mono text-xs uppercase text-cyan-400 hover:text-cyan-300 transition-colors">
&gt; READ [{post.readingTime}MIN] &gt; {t('readingTime', {minutes: post.readingTime})}
</span> </span>
</article> </article>
</Link> </Link>
@@ -82,7 +84,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
))} ))}
</div> </div>
<span className="inline-flex items-center font-mono text-xs uppercase text-cyan-400 hover:text-cyan-300 transition-colors"> <span className="inline-flex items-center font-mono text-xs uppercase text-cyan-400 hover:text-cyan-300 transition-colors">
&gt; READ [{post.readingTime}MIN] &gt; {t('readingTime', {minutes: post.readingTime})}
</span> </span>
</div> </div>
</div> </div>
@@ -127,7 +129,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
))} ))}
</div> </div>
<span className="inline-flex items-center font-mono text-xs uppercase text-cyan-400 hover:text-cyan-300 transition-colors"> <span className="inline-flex items-center font-mono text-xs uppercase text-cyan-400 hover:text-cyan-300 transition-colors">
&gt; READ [{post.readingTime}MIN] &gt; {t('readingTime', {minutes: post.readingTime})}
</span> </span>
</div> </div>
</article> </article>

View File

@@ -6,7 +6,8 @@ import rehypeSanitize from 'rehype-sanitize'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
import { OptimizedImage } from './OptimizedImage' import { OptimizedImage } from './OptimizedImage'
import { CodeBlock } from './code-block' import { CodeBlock } from './code-block'
import Link from 'next/link' import { useLocale } from 'next-intl'
import { Link } from '@/i18n/navigation'
interface MarkdownRendererProps { interface MarkdownRendererProps {
content: string content: string
@@ -14,6 +15,7 @@ interface MarkdownRendererProps {
} }
export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) { export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
const locale = useLocale()
return ( return (
<div className={`prose prose-invert prose-zinc max-w-none ${className}`}> <div className={`prose prose-invert prose-zinc max-w-none ${className}`}>
<ReactMarkdown <ReactMarkdown

View File

@@ -1,10 +1,13 @@
'use client' 'use client'
import { useEffect, useState } from 'react' 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 { ThemeToggle } from '@/components/theme-toggle'
import LanguageSwitcher from '@/components/layout/LanguageSwitcher'
export function Navbar() { export function Navbar() {
const t = useTranslations('Navigation')
const [isVisible, setIsVisible] = useState(true) const [isVisible, setIsVisible] = useState(true)
const [lastScrollY, setLastScrollY] = useState(0) const [lastScrollY, setLastScrollY] = useState(0)
@@ -39,10 +42,10 @@ export function Navbar() {
className="font-mono text-sm uppercase tracking-wider transition-colors cursor-pointer" className="font-mono text-sm uppercase tracking-wider transition-colors cursor-pointer"
style={{ color: 'var(--neon-cyan)' }} style={{ color: 'var(--neon-cyan)' }}
> >
&lt; HOME &lt; {t('home')}
</Link> </Link>
<span className="font-mono text-sm text-zinc-100 dark:text-zinc-300 uppercase tracking-wider"> <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> </span>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
@@ -50,15 +53,16 @@ export function Navbar() {
href="/about" 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" 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>
<Link <Link
href="/blog" 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" 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> </Link>
<ThemeToggle /> <ThemeToggle />
<LanguageSwitcher />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@ 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("en", limit)
if (tags.length === 0) return null if (tags.length === 0) return null

View File

@@ -30,9 +30,9 @@ export function ReadingProgress() {
/> />
</div> </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"> {/* <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="relative z-10">[{Math.round(progress)}%]</span> <span className="left-4 z-10">[{Math.round(progress)}%]</span>
</div> </div> */}
</> </>
) )
} }

View File

@@ -43,17 +43,12 @@ export function StickyFooter({ url, title }: StickyFooterProps) {
className={` className={`
fixed bottom-0 left-0 right-0 z-40 fixed bottom-0 left-0 right-0 z-40
bg-black/98 backdrop-blur-sm 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 transition-transform duration-200 ease-in-out
${isVisible ? 'translate-y-0' : 'translate-y-full'} ${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="max-w-7xl mx-auto px-6 py-4 relative">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -1,4 +1,5 @@
import Link from 'next/link' import { useTranslations } from 'next-intl'
import { Link } from '@/i18n/navigation'
import { TagInfo } from '@/lib/tags' import { TagInfo } from '@/lib/tags'
interface TagCloudProps { interface TagCloudProps {
@@ -6,6 +7,7 @@ interface TagCloudProps {
} }
export function TagCloud({ tags }: TagCloudProps) { export function TagCloud({ tags }: TagCloudProps) {
const t = useTranslations('Tags')
const sizeClasses = { const sizeClasses = {
sm: 'text-xs opacity-70', sm: 'text-xs opacity-70',
md: 'text-sm', md: 'text-sm',
@@ -26,7 +28,7 @@ export function TagCloud({ tags }: TagCloudProps) {
hover:text-cyan-400 hover:text-cyan-400
transition-colors transition-colors
`} `}
title={`${tag.count} ${tag.count === 1 ? 'articol' : 'articole'}`} title={t('postsWithTag', {count: tag.count, tag: tag.name})}
> >
#{tag.name} #{tag.name}
</Link> </Link>

View File

@@ -1,7 +1,8 @@
'use client' 'use client'
import Link from 'next/link' import {Link} from '@/i18n/navigation'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useLocale, useTranslations } from 'next-intl'
import { Fragment } from 'react' import { Fragment } from 'react'
import { BreadcrumbsSchema } from './breadcrumbs-schema' import { BreadcrumbsSchema } from './breadcrumbs-schema'
@@ -38,25 +39,33 @@ function ChevronIcon({ className }: { className?: string }) {
) )
} }
function formatSegmentLabel(segment: string): string {
const specialCases: { [key: string]: string } = {
blog: 'Blog',
tags: 'Tag-uri',
about: 'Despre',
}
if (specialCases[segment]) {
return specialCases[segment]
}
return segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) { export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
const pathname = usePathname() 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: t('blog'),
tags: t('tags'),
about: t('about'),
}
if (specialCases[segment]) {
return specialCases[segment]
}
return segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
let breadcrumbs: BreadcrumbItem[] = items || [] let breadcrumbs: BreadcrumbItem[] = items || []
@@ -71,12 +80,8 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
}) })
} }
if (pathname === '/') {
return null
}
const schemaItems = [ const schemaItems = [
{ position: 1, name: 'Acasă', item: '/' }, { position: 1, name: t('home'), item: '/' },
...breadcrumbs.map((item, index) => ({ ...breadcrumbs.map((item, index) => ({
position: index + 2, position: index + 2,
name: item.label, name: item.label,
@@ -96,7 +101,7 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
<Link <Link
href="/" href="/"
className="flex items-center text-gray-500 hover:text-primary-600 transition" 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" /> <HomeIcon className="w-4 h-4" />
</Link> </Link>

View 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>
);
}

View File

@@ -1,5 +1,5 @@
--- ---
title: 'Why I created this page' title: 'First post'
description: 'First post' description: 'First post'
date: '2025-12-02' date: '2025-12-02'
author: 'Rares' author: 'Rares'

View File

@@ -0,0 +1,40 @@
---
title: 'Primul post'
description: 'Primul 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?
![Selfhosting rig](./whythispage/selfhostedrig.gif?w=400 "Acesta este pc-ul | Hardware-ul pe care ruleaza cest webpage")
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
View 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
#

View File

@@ -33,7 +33,7 @@ export function calculateReadingTime(content: string): number {
return Math.ceil(words / wordsPerMinute) 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') { if (!data.title || typeof data.title !== 'string') {
throw new Error('Invalid title') throw new Error('Invalid title')
} }
@@ -60,15 +60,19 @@ export function validateFrontmatter(data: any): FrontMatter {
author: data.author, author: data.author,
category: data.category, category: data.category,
tags: data.tags, tags: data.tags,
locale: data.locale || locale || 'en',
image: data.image, image: data.image,
draft: data.draft || false, 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 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, locale, ...sanitized) + '.md'
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
return null return null
@@ -76,7 +80,7 @@ export async function getPostBySlug(slug: string | string[]): Promise<Post | nul
const fileContents = fs.readFileSync(fullPath, 'utf8') const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents) const { data, content } = matter(fileContents)
const frontmatter = validateFrontmatter(data) const frontmatter = validateFrontmatter(data, locale)
const processed = await remark() const processed = await remark()
.use(remarkGfm) .use(remarkGfm)
@@ -85,13 +89,14 @@ export async function getPostBySlug(slug: string | string[]): Promise<Post | nul
publicDir: 'public/blog', publicDir: 'public/blog',
currentSlug: sanitized.join('/'), currentSlug: sanitized.join('/'),
}) })
.use(remarkInternalLinks) .use(remarkInternalLinks, { locale })
.process(content) .process(content)
const processedContent = processed.toString() const processedContent = processed.toString()
return { return {
slug: sanitized.join('/'), slug: sanitized.join('/'),
locale,
frontmatter, frontmatter,
content: processedContent, content: processedContent,
readingTime: calculateReadingTime(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 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> { async function walkDir(dir: string, prefix = ''): Promise<void> {
const files = fs.readdirSync(dir) const files = fs.readdirSync(dir)
@@ -114,7 +125,7 @@ export async function getAllPosts(includeContent = false): Promise<Post[]> {
} 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 = await getPostBySlug(slug.split('/')) const post = await getPostBySlug(slug.split('/'), locale)
if (post && !post.frontmatter.draft) { if (post && !post.frontmatter.draft) {
posts.push(includeContent ? post : { ...post, content: '' }) 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(localeDir)
await walkDir(POSTS_PATH)
}
return posts.sort( return posts.sort(
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime() (a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
) )
} }
export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<Post[]> { export async function getRelatedPosts(
const currentPost = await getPostBySlug(currentSlug) currentSlug: string,
locale: string = 'en',
limit = 3
): Promise<Post[]> {
const currentPost = await getPostBySlug(currentSlug, locale)
if (!currentPost) return [] if (!currentPost) return []
const allPosts = await getAllPosts(false) const allPosts = await getAllPosts(locale, false)
const { category, tags } = currentPost.frontmatter const { category, tags } = currentPost.frontmatter
const scored = allPosts 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) return scored.slice(0, limit).map(({ post }) => post)
} }
export function getAllPostSlugs(): string[][] { export function getAllPostSlugs(locale: string = 'en'): string[][] {
const slugs: string[][] = [] const slugs: string[][] = []
const localeDir = path.join(POSTS_PATH, locale)
if (!fs.existsSync(localeDir)) {
return []
}
function walkDir(dir: string, prefix: string[] = []): void { function walkDir(dir: string, prefix: string[] = []): void {
const files = fs.readdirSync(dir) const files = fs.readdirSync(dir)
@@ -173,9 +191,26 @@ export function getAllPostSlugs(): string[][] {
} }
} }
if (fs.existsSync(POSTS_PATH)) { walkDir(localeDir)
walkDir(POSTS_PATH)
}
return slugs 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
}

View File

@@ -7,6 +7,10 @@ interface LinkNode extends Node {
children: Node[] children: Node[]
} }
interface Options {
locale?: string
}
/** /**
* Detects internal blog post links: * Detects internal blog post links:
* - Relative paths (no http/https) * - Relative paths (no http/https)
@@ -24,11 +28,11 @@ function isInternalBlogLink(url: string): boolean {
/** /**
* Transforms internal .md links to blog routes: * Transforms internal .md links to blog routes:
* - tech/article.md → /blog/tech/article * - tech/article.md → /[locale]/blog/tech/article
* - article.md#section → /blog/article#section * - article.md#section → /[locale]/blog/article#section
* - nested/path/post.md?ref=foo → /blog/nested/path/post?ref=foo * - 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 // Split into path, hash, and query
const hashIndex = url.indexOf('#') const hashIndex = url.indexOf('#')
const queryIndex = url.indexOf('?') const queryIndex = url.indexOf('?')
@@ -50,17 +54,19 @@ function transformToBlogPath(url: string): string {
// Remove .md extension // Remove .md extension
const cleanPath = path.replace(/\.md$/, '') const cleanPath = path.replace(/\.md$/, '')
// Build final URL // Build final URL with locale prefix
return `/blog/${cleanPath}${query}${hash}` return `/${locale}/blog/${cleanPath}${query}${hash}`
} }
export function remarkInternalLinks() { export function remarkInternalLinks(options: Options = {}) {
const locale = options.locale || 'en'
return (tree: Node) => { return (tree: Node) => {
visit(tree, 'link', (node: Node) => { visit(tree, 'link', (node: Node) => {
const linkNode = node as LinkNode const linkNode = node as LinkNode
if (isInternalBlogLink(linkNode.url)) { if (isInternalBlogLink(linkNode.url)) {
linkNode.url = transformToBlogPath(linkNode.url) linkNode.url = transformToBlogPath(linkNode.url, locale)
} }
}) })
} }

View File

@@ -25,8 +25,8 @@ export function slugifyTag(tag: string): string {
.replace(/^-|-$/g, '') .replace(/^-|-$/g, '')
} }
export async function getAllTags(): Promise<TagInfo[]> { export async function getAllTags(locale: string = 'en'): Promise<TagInfo[]> {
const posts = await getAllPosts() const posts = await getAllPosts(locale)
const tagMap = new Map<string, number>() const tagMap = new Map<string, number>()
posts.forEach(post => { posts.forEach(post => {
@@ -46,8 +46,8 @@ export async function getAllTags(): Promise<TagInfo[]> {
.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, locale: string = 'en'): Promise<Post[]> {
const posts = await getAllPosts() const posts = await getAllPosts(locale)
return posts.filter(post => { return posts.filter(post => {
const tags = post.frontmatter.tags?.filter(Boolean) || [] 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> { export async function getTagInfo(tagSlug: string, locale: string = 'en'): Promise<TagInfo | null> {
const allTags = await getAllTags() const allTags = await getAllTags(locale)
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(locale: string = 'en', limit = 10): Promise<TagInfo[]> {
const allTags = await getAllTags() const allTags = await getAllTags(locale)
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, locale: string = 'en', limit = 5): Promise<TagInfo[]> {
const posts = await getPostsByTag(tagSlug) const posts = await getPostsByTag(tagSlug, locale)
const relatedTagMap = new Map<string, number>() const relatedTagMap = new Map<string, number>()
posts.forEach(post => { posts.forEach(post => {
@@ -107,8 +107,8 @@ export function validateTags(tags: any): string[] {
return validTags return validTags
} }
export async function getTagCloud(): Promise<Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>> { export async function getTagCloud(locale: string = 'en'): Promise<Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>> {
const tags = await getAllTags() const tags = await getAllTags(locale)
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))

View File

@@ -5,12 +5,14 @@ export interface FrontMatter {
author: string author: string
category: string category: string
tags: string[] tags: string[]
locale: string
image?: string image?: string
draft?: boolean draft?: boolean
} }
export interface Post { export interface Post {
slug: string slug: string
locale: string
frontmatter: FrontMatter frontmatter: FrontMatter
content: string content: string
readingTime: number readingTime: number

133
messages/en.json Normal file
View File

@@ -0,0 +1,133 @@
{
"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"
},
"Home": {
"terminalVersion": "TERMINAL:// V2.0",
"documentLevel": "DOCUMENT LEVEL-1 //",
"heroTitle": "BUILD. WRITE. SHARE.",
"heroSubtitle": "> Explore ideas",
"checkPostsButton": "[CHECK POSTS]",
"aboutMeButton": "[ABOUT ME]",
"recentEntriesLabel": "ARCHIVE ACCESS // RECENT ENTRIES",
"recentEntriesTitle": "> RECENT ENTRIES",
"fileLabel": "FILE#{number} // {category}",
"accessButton": "[ACCESS] >>",
"seePostsButton": "[SEE POSTS] >>",
"seeAllTagsButton": "[SEE ALL TAGS] >>"
},
"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",
"prev": "< PREV",
"next": "NEXT >"
},
"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",
"classificationHeader": ">> _DOC://PUBLIC_ACCESS",
"mainTitle": "ABOUT ME_",
"introLabel": "STATUS: ACTIVE // ROLE: DAD + DEV",
"introParagraph1": "Welcome to my corner of the internet! This is where I share my thoughts, opinions, and experiences - from tech adventures to life as a family man. Yes, I love technology, but there's so much more to life than just code and servers.",
"lifeValuesTitle": "> LIFE & VALUES",
"familyFirstTitle": "[FAMILY FIRST]",
"familyFirstText": "Being a dad to an amazing toddler is my most important role. Family time is sacred - whether it's building block towers, exploring parks, or just enjoying the chaos of everyday life together. Tech can wait; these moments can't.",
"activeLifestyleTitle": "[ACTIVE LIFESTYLE]",
"activeLifestyleText": "I believe in keeping the body active. Whether it's hitting the gym, playing sports, or just staying on the move - physical activity keeps me sharp, balanced, and ready for whatever life throws my way.",
"simpleThingsTitle": "[ENJOYING THE SIMPLE THINGS]",
"simpleThingsText": "Life's too short not to enjoy it. A good drink, a relaxing evening after a long day, or just not doing anything a blowing some steam off.",
"techPurposeTitle": "[TECH WITH PURPOSE]",
"techPurposeText": "Yes, I love tech - self-hosting, privacy, tinkering with hardware. But it's a tool, not a lifestyle. Tech should serve life, not the other way around.",
"contentTitle": "> WHAT YOU'LL FIND HERE",
"contentSubtitle": "CONTENT SCOPE // EVERYTHING FROM TECH TO LIFE",
"contentThoughts": "Thoughts & Opinions",
"contentThoughtsDesc": "My take on life, work, and everything in between",
"contentLifeFamily": "Life & Family",
"contentLifeFamilyDesc": "Adventures in parenting, sports, and enjoying the simple things",
"contentTechResearch": "Tech Research",
"contentTechResearchDesc": "When I dive into interesting technologies and experiments",
"contentSysAdmin": "System Administration",
"contentSysAdminDesc": "Self-hosting, infrastructure, and DevOps adventures",
"contentDevelopment": "Development Insights",
"contentDevelopmentDesc": "Lessons learned from building software",
"contentRandom": "Random Stuff",
"contentRandomDesc": "Because life doesn't fit into neat categories!",
"focusTitle": "> AREAS OF FOCUS",
"focusBeingDadTitle": "[BEING A DAD]",
"focusBeingDadText": "Playing with my boy, teaching moments, watching him grow, building memories together",
"focusStayingActiveTitle": "[STAYING ACTIVE]",
"focusStayingActiveText": "Gym sessions, sports, keeping fit, maintaining energy for life's demands",
"focusTechnologyTitle": "[TECHNOLOGY & SYSTEMS]",
"focusTechnologyText": "Software development, infrastructure, DevOps, self-hosting adventures",
"focusLifeBalanceTitle": "[LIFE BALANCE]",
"focusLifeBalanceText": "Relaxing with good company, enjoying downtime, appreciating the simple moments",
"techStackTitle": "> TECH STACK",
"techStackSubtitle": "TOOLS I USE // WHEN NEEDED",
"techStackDevelopmentTitle": "[DEVELOPMENT]",
"techStackDevelopmentText": ".NET, Golang, TypeScript, Next.js, React",
"techStackInfrastructureTitle": "[INFRASTRUCTURE]",
"techStackInfrastructureText": "Windows Server, Linux, Docker, Hyper-V",
"techStackDesignTitle": "[DESIGN]",
"techStackDesignText": "Tailwind CSS, Markdown, Terminal aesthetics",
"techStackSelfHostingTitle": "[SELF-HOSTING]",
"techStackSelfHostingText": "Home lab, privacy-focused services, full control, Git server",
"contactTitle": "> CONTACT"
},
"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"
}
}

124
messages/ro.json Normal file
View File

@@ -0,0 +1,124 @@
{
"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"
},
"Home": {
"terminalVersion": "TERMINAL:// V2.0",
"documentLevel": "DOCUMENT LEVEL-1 //",
"heroTitle": "BUILD. WRITE. SHARE.",
"heroSubtitle": "> Explore ideas",
"checkPostsButton": "[CHECK POSTS]",
"aboutMeButton": "[ABOUT ME]",
"recentEntriesLabel": "ARCHIVE ACCESS // RECENT ENTRIES",
"recentEntriesTitle": "> RECENT ENTRIES",
"fileLabel": "FILE#{number} // {category}",
"accessButton": "[ACCESS] >>",
"seePostsButton": "[SEE POSTS] >>",
"seeAllTagsButton": "[SEE ALL TAGS] >>"
},
"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",
"prev": "< PREV",
"next": "NEXT >"
},
"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",
"classificationHeader": ">> _DOC://PUBLIC_ACCESS",
"mainTitle": "DESPRE_",
"introLabel": "STATUS: ACTIV // ROL: TATĂ + DEV",
"introParagraph1": "Mi-am făcut un colțișor pe internet unde pot să împărtășesc cam tot ce vreau. O să găsești aici și tech, și viață, și haosul de zi cu zi.",
"lifeValuesTitle": "> VIAȚĂ & VALORI",
"familyFirstTitle": "[FAMILIA PE PRIMUL LOC]",
"familyFirstText": "Să fiu tată pentru un puști genial e cel mai important rol al meu. Timpul cu familia e sfânt fie că construim turnuri din cuburi, explorăm parcuri sau doar trăim frumos haosul de zi cu zi. Tech-ul poate să aștepte, momentele astea nu.",
"activeLifestyleTitle": "[STIL DE VIAȚĂ ACTIV]",
"activeLifestyleText": "Încerc să-mi țin corpul în mișcare. Sală, sport, orice mă scoate din scaun. Mă ajută să fiu mai clar la minte, mai echilibrat și pregătit de ce aruncă viața în mine.",
"simpleThingsTitle": "[BUCURIA LUCRURILOR SIMPLE]",
"simpleThingsText": "Viața e prea scurtă să n-o savurezi. O băutură bună, o seară liniștită după o zi grea sau pur și simplu să nu faci nimic și să lași aburii să iasă… și e perfect așa.",
"techPurposeTitle": "[TECH CU SENS]",
"techPurposeText": "Da, îmi place tehnologia self-hosting, privacy, joacă cu hardware. Dar pentru mine e o unealtă, nu un stil de viață. Tech-ul ar trebui să lucreze pentru tine, nu tu pentru el.",
"contentTitle": "> CE GĂSEȘTI AICI",
"contentSubtitle": "CONTENT SCOPE // DE LA TECH LA VIAȚĂ",
"contentThoughts": "Gânduri & Opinii",
"contentThoughtsDesc": "Cum văd eu viața, munca și tot ce e între ele",
"contentLifeFamily": "Viață & Familie",
"contentLifeFamilyDesc": "Aventuri de părinte, sport și bucuria lucrurilor mici",
"contentTechResearch": "Experimente Tech",
"contentTechResearchDesc": "Când mă afund în tehnologii interesante și experimente ciudate",
"contentSysAdmin": "Administrare Sisteme",
"contentSysAdminDesc": "Self-hosting, infrastructură și aventuri de tip DevOps",
"contentDevelopment": "Development Insights",
"contentDevelopmentDesc": "Lecții învățate din proiectele pe care le construiesc",
"contentRandom": "Chestii Random",
"contentRandomDesc": "Pentru că viața nu intră mereu frumos pe categorii!",
"focusTitle": "> ZONE DE FOCUS",
"focusBeingDadTitle": "[TATĂ ÎN PRIMUL RÂND]",
"focusBeingDadText": "Joacă cu băiatul meu, momente de învățat, să-l văd cum crește și să strângem amintiri împreună",
"focusStayingActiveTitle": "[SĂ RĂMÂN ACTIV]",
"focusStayingActiveText": "Sesiuni la sală, sport, să mă țin în formă și cu energie pentru tot ce am de dus",
"focusTechnologyTitle": "[TECH & SISTEME]",
"focusTechnologyText": "Dezvoltare software, infrastructură, DevOps, aventuri de self-hosting",
"focusLifeBalanceTitle": "[ECHILIBRU ÎN VIAȚĂ]",
"focusLifeBalanceText": "Relax cu prietenii, timp de respiro, apreciat momentele simple",
"techStackTitle": "> TECH STACK",
"techStackSubtitle": "UNELTELE PE CARE LE FOLOSESC // CÂND TREBUIE",
"techStackDevelopmentTitle": "[DEVELOPMENT]",
"techStackDevelopmentText": ".NET, Golang, TypeScript, Next.js, React",
"techStackInfrastructureTitle": "[INFRASTRUCTURĂ]",
"techStackInfrastructureText": "Windows Server, Linux, Docker, Hyper-V",
"techStackDesignTitle": "[DESIGN]",
"techStackDesignText": "Tailwind CSS, Markdown",
"techStackSelfHostingTitle": "[SELF-HOSTING]",
"techStackSelfHostingText": "Home lab, servicii cu focus pe privacy, control total, server Git",
"contactTitle": "> CONTACT"
},
"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
View 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|.*\\..*).*)'
]
};

View File

@@ -1,3 +1,5 @@
const withNextIntl = require('next-intl/plugin')();
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
// ============================================ // ============================================
// Next.js 16 Configuration // Next.js 16 Configuration
@@ -245,4 +247,4 @@ const nextConfig = {
// }, // },
} }
module.exports = nextConfig module.exports = withNextIntl(nextConfig)

729
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,15 +30,16 @@
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.0", "@types/node": "^24.10.1",
"@types/react": "^19.2.2", "@types/react": "^19.2.7",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.22",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"next": "^16.0.1", "next": "^16.0.7",
"next-intl": "^4.5.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.0", "react": "^19.2.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
@@ -50,16 +51,16 @@
"unist-util-visit": "^5.0.0" "unist-util-visit": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@next/bundle-analyzer": "^16.0.3", "@next/bundle-analyzer": "^16.0.7",
"@typescript-eslint/eslint-plugin": "^8.46.4", "@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.46.4", "@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.3", "eslint-config-next": "^16.0.7",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"prettier": "^3.6.2", "prettier": "^3.7.4",
"typescript-eslint": "^8.46.4" "typescript-eslint": "^8.48.1"
} }
} }

View File

@@ -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:
![Cooler image:](articol-tehnic.md)
## Content
You are reading the technical article that was linked from the example post.

5
src/i18n/navigation.ts Normal file
View 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
View 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
View 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];

View File

@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -19,7 +23,12 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
"./*"
],
"@/i18n/*": [
"./src/i18n/*"
]
} }
}, },
"include": [ "include": [
@@ -29,5 +38,7 @@
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": ["node_modules"] "exclude": [
"node_modules"
]
} }

5
types/translations.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
type Messages = typeof import('../messages/en.json');
declare global {
interface IntlMessages extends Messages {}
}