1 Commits

Author SHA1 Message Date
RJ
daf253540f 🚀 add cicd 2025-11-14 16:20:14 +02:00
48 changed files with 1262 additions and 5717 deletions

View File

@@ -15,7 +15,6 @@ Reference this skill when writing or reviewing code to ensure consistency with p
### Files and Directories
**Use kebab-case for all file names:**
```
✅ user-profile.tsx
✅ blog-post-card.tsx
@@ -27,14 +26,12 @@ Reference this skill when writing or reviewing code to ensure consistency with p
```
**Why kebab-case?**
- Cross-platform compatibility (Windows vs Unix)
- URL-friendly (file names often map to routes)
- Easier to parse and read
- Industry standard for Next.js projects
**Special Next.js Files:**
```
page.tsx # Route pages
layout.tsx # Layout components
@@ -47,7 +44,6 @@ route.ts # API route handlers
### Component Names (Inside Files)
**Use PascalCase for component names:**
```typescript
// File: user-profile.tsx
export function UserProfile() {
@@ -63,7 +59,6 @@ export default function BlogPostCard() {
### Variables, Functions, Props
**Use camelCase:**
```typescript
// Variables
const userSettings = {}
@@ -88,11 +83,10 @@ function useMarkdown() {}
### Constants
**Use SCREAMING_SNAKE_CASE:**
```typescript
const API_BASE_URL = 'https://api.example.com'
const API_BASE_URL = "https://api.example.com"
const MAX_RETRIES = 3
const DEFAULT_LOCALE = 'ro-RO'
const DEFAULT_LOCALE = "ro-RO"
```
---
@@ -139,7 +133,10 @@ export async function POST(request: NextRequest) {
const parsed = bodySchema.safeParse(json)
if (!parsed.success) {
return NextResponse.json({ error: 'Validation failed', details: parsed.error }, { status: 400 })
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error },
{ status: 400 }
)
}
// parsed.data is fully typed
@@ -149,7 +146,6 @@ export async function POST(request: NextRequest) {
```
**Key Points:**
- Use `safeParse()` instead of `parse()` to avoid try/catch
- Return structured error responses
- Use appropriate HTTP status codes
@@ -158,7 +154,6 @@ export async function POST(request: NextRequest) {
### Error Handling
**Return meaningful status codes:**
```typescript
200 // Success
201 // Created
@@ -170,13 +165,12 @@ export async function POST(request: NextRequest) {
```
**Structured error responses:**
```typescript
return NextResponse.json(
{
error: 'Resource not found',
code: 'NOT_FOUND',
timestamp: new Date().toISOString(),
timestamp: new Date().toISOString()
},
{ status: 404 }
)
@@ -283,12 +277,12 @@ export function ThemeToggle() {
```javascript
// tailwind.config.js
module.exports = {
darkMode: 'class', // Required for next-themes
darkMode: 'class', // Required for next-themes
theme: {
extend: {
colors: {
'dark-primary': '#18181b',
accent: {
'accent': {
DEFAULT: '#164e63',
hover: '#155e75',
},
@@ -386,7 +380,6 @@ export function Card({ children, className = "" }) {
```
**Standard breakpoints:**
```
sm: 640px // Small tablets
md: 768px // Tablets
@@ -466,21 +459,18 @@ content/ # Content files (outside app/)
### lib/ Organization
**Modules in lib/:**
- Substantial business logic (markdown.ts, seo.ts)
- API clients and data fetching
- Database connections
- Authentication logic
**Utils in lib/utils.ts:**
- Pure helper functions
- Formatters (formatDate, formatCurrency)
- Validators (isEmail, isValidUrl)
- String manipulations
**Types in lib/types/:**
- Shared TypeScript interfaces
- API response types
- Domain models
@@ -489,7 +479,6 @@ content/ # Content files (outside app/)
### Component Organization
**By domain/feature:**
```
components/
├── blog/ # Blog-specific
@@ -506,7 +495,6 @@ components/
```
**Not by type:**
```
❌ Don't organize like this:
components/
@@ -519,7 +507,6 @@ components/
### Public Assets
**Organize by feature:**
```
public/
├── blog/
@@ -532,7 +519,6 @@ public/
```
**Naming conventions:**
- Use descriptive names: `hero-background.jpg` not `img1.jpg`
- Use kebab-case: `user-avatar.png`
- Include dimensions for images: `logo-512x512.png`
@@ -544,10 +530,9 @@ public/
### Type Safety
**Avoid `any`:**
```typescript
// ❌ Bad
function processData(data: any) {}
function processData(data: any) { }
// ✅ Good
function processData(data: unknown) {
@@ -561,7 +546,7 @@ interface PostData {
title: string
content: string
}
function processData(data: PostData) {}
function processData(data: PostData) { }
```
### Infer Types from Zod
@@ -680,7 +665,6 @@ export function InteractiveCard({ title }) {
```
**When to use 'use client':**
- Using React hooks (useState, useEffect, etc.)
- Using event handlers (onClick, onChange, etc.)
- Using browser APIs (window, localStorage, etc.)
@@ -759,7 +743,7 @@ export async function generateStaticParams() {
```typescript
// In frontmatter
date: '2025-01-15'
date: "2025-01-15"
// For display
formatDate(post.frontmatter.date) // "15 ianuarie 2025" (Romanian)

337
.gitea/workflows/main.yml Normal file
View File

@@ -0,0 +1,337 @@
# Gitea Actions Workflow for Next.js Blog Application
# This workflow builds a Docker image and deploys it to production
#
# Workflow triggers:
# - Push to master branch (automatic deployment)
# - Manual trigger via workflow_dispatch
#
# Required Secrets (configure in Gitea repository settings):
# - PRODUCTION_HOST: IP address or hostname of production server
# - PRODUCTION_USER: SSH username (e.g., 'deployer')
# - SSH_PRIVATE_KEY: Private SSH key for authentication
# - REGISTRY_USERNAME: Docker registry username (optional, if registry requires auth)
# - REGISTRY_PASSWORD: Docker registry password (optional, if registry requires auth)
#
# Environment Variables (configured below):
# - REGISTRY: Docker registry URL
# - IMAGE_NAME: Docker image name
name: Build and Deploy Next.js Blog to Production
on:
push:
branches:
- master # Trigger on push to master 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: ubuntu-latest
steps:
- name: 🔎 Checkout code
uses: actions/checkout@v4
- name: 📦 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: 📥 Install dependencies
run: npm ci
- name: 🔍 Run ESLint
run: npm run lint
- name: 💅 Check code formatting (Prettier)
run: npm run format:check
- 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: 🔐 Log in to Docker Registry (if credentials provided)
run: |
if [ -n "${{ secrets.REGISTRY_USERNAME }}" ] && [ -n "${{ secrets.REGISTRY_PASSWORD }}" ]; then
echo "Logging into ${{ env.REGISTRY }} with credentials..."
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
echo "✅ Login successful"
else
echo "⚠️ No registry credentials provided - using insecure/public registry"
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..."
echo "Build context size:"
du -sh . 2>/dev/null || echo "Cannot measure context size"
# Build the Docker image
# - Uses Dockerfile.nextjs from project root
# - Tags image with both 'latest' and commit SHA
# - Enables inline cache for faster subsequent builds
docker build \
--progress=plain \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-f Dockerfile.nextjs \
.
echo "✅ Build successful"
echo "Image size:"
docker images ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: 🚀 Push Docker image to registry
run: |
echo "Pushing image to registry..."
# Push both tags (latest and commit SHA)
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
echo "✅ Image pushed successfully"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
# ============================================
# Job 2: Deploy to Production Server
# ============================================
deploy-production:
name: 🚀 Deploy to Production
runs-on: ubuntu-latest
needs: [build-and-push] # Wait for build job to complete
environment:
name: production
url: http://your-production-url.com # Update with your actual production URL
steps:
- name: 🔎 Checkout code (for docker-compose file)
uses: actions/checkout@v4
- name: 🔐 Validate Registry Access on Production Server
uses: appleboy/ssh-action@v1.0.3
env:
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_URL: ${{ env.REGISTRY }}
with:
host: ${{ secrets.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
envs: REGISTRY_PASSWORD,REGISTRY_USERNAME,REGISTRY_URL
script: |
echo "=== Validating Docker Registry access ==="
if [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ]; then
echo "Logging into $REGISTRY_URL with credentials..."
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USERNAME" --password-stdin
echo "✅ Registry authentication successful"
else
echo "⚠️ No registry credentials - using insecure/public registry"
echo "Testing registry connectivity..."
curl -f "http://$REGISTRY_URL/v2/" || { echo "❌ Registry not accessible"; exit 1; }
echo "✅ Registry is accessible"
fi
- name: 📁 Ensure application directory structure
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
script: |
echo "=== Ensuring application directory structure ==="
# Verify base directory exists and is writable
# Update /opt/mypage to match your deployment directory
if [ ! -d /opt/mypage ]; then
echo "❌ /opt/mypage does not exist!"
echo "Please run manually on production server:"
echo " sudo mkdir -p /opt/mypage"
echo " sudo chown -R deployer:docker /opt/mypage"
echo " sudo chmod -R 775 /opt/mypage"
exit 1
fi
if [ ! -w /opt/mypage ]; then
echo "❌ /opt/mypage is not writable by $USER user"
echo "Please run manually on production server:"
echo " sudo chown -R deployer:docker /opt/mypage"
echo " sudo chmod -R 775 /opt/mypage"
exit 1
fi
# Create data directories for logs
mkdir -p /opt/mypage/data/logs || { echo "❌ Failed to create logs directory"; exit 1; }
echo "✅ Directory structure ready"
ls -la /opt/mypage
- name: 📦 Copy docker-compose.prod.yml to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
source: "docker-compose.prod.yml"
target: "/opt/mypage/"
overwrite: true
- name: 🐳 Deploy application via Docker Compose
uses: appleboy/ssh-action@v1.0.3
env:
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD || '' }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME || '' }}
REGISTRY_URL: ${{ env.REGISTRY }}
IMAGE_FULL: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
with:
host: ${{ secrets.PRODUCTION_HOST }}
username: ${{ secrets.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 production server ==="
cd /opt/mypage
# Log in to Docker registry (if credentials are configured)
if [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ]; then
echo "=== Logging in to Docker registry ==="
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USERNAME" --password-stdin
echo "✅ Registry login successful"
else
echo "⚠️ No registry credentials - using insecure/public registry (no login required)"
fi
# Pull latest image from registry
echo "=== Pulling latest Docker image ==="
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 container ==="
docker compose -f docker-compose.prod.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.prod.yml ps
# Show recent logs for debugging
echo "=== Recent application logs ==="
docker compose -f docker-compose.prod.yml logs --tail=50
# Clean up old/unused images to save disk space
echo "=== Cleaning up old Docker images ==="
docker image prune -f
echo "✅ Deployment completed successfully ==="
- name: ❤️ Health check
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
script: |
echo "=== Performing health check ==="
cd /opt/mypage
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 3030
if curl -f http://localhost:3030/ > /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.prod.yml ps
echo ""
echo "=== Container Health ==="
docker inspect mypage-prod --format='{{.State.Health.Status}}' 2>/dev/null || echo "No health status"
echo ""
echo "=== Recent Application Logs ==="
docker compose -f docker-compose.prod.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**: Production" >> $GITHUB_STEP_SUMMARY
echo "- **Image**: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $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 production URL" >> $GITHUB_STEP_SUMMARY
echo "2. Check application logs for any errors" >> $GITHUB_STEP_SUMMARY
echo "3. Monitor resource usage and performance" >> $GITHUB_STEP_SUMMARY

View File

@@ -1,9 +0,0 @@
node_modules
.next
out
build
dist
.cache
package-lock.json
public
coverage

View File

@@ -1,7 +0,0 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"arrowParens": "avoid",
"trailingComma": "es5"
}

View File

@@ -74,16 +74,15 @@ lib/
```
**Frontmatter Schema:**
```yaml
---
title: string # Required
description: string # Required
date: 'YYYY-MM-DD' # Required, ISO format
author: string # Required
tags: [string, string?, string?] # Max 3 tags
image?: string # Optional hero image
draft?: boolean # Exclude from listings if true
title: string # Required
description: string # Required
date: "YYYY-MM-DD" # Required, ISO format
author: string # Required
tags: [string, string?, string?] # Max 3 tags
image?: string # Optional hero image
draft?: boolean # Exclude from listings if true
---
```
@@ -108,25 +107,20 @@ components/
### File Naming Conventions
**Files and Directories:**
- Use **kebab-case** for all file names: `user-profile.tsx`, `blog-post.tsx`
- Special Next.js files: `page.tsx`, `layout.tsx`, `not-found.tsx`, `loading.tsx`
**Component Names (inside files):**
- Use **PascalCase**: `export function UserProfile()`, `export default BlogPost`
**Variables, Functions, Props:**
- Use **camelCase**: `const userSettings = {}`, `function handleSubmit() {}`
- Hooks: `useTheme`, `useMarkdown`
**Constants:**
- Use **SCREAMING_SNAKE_CASE**: `const API_BASE_URL = "..."`
**Why kebab-case for files?**
- Cross-platform compatibility (Windows vs Unix)
- URL-friendly (file names often map to routes)
- Easier to parse and read
@@ -158,7 +152,6 @@ export default function RootLayout({ children }) {
```
**Client Component for Toggle:**
```typescript
// components/theme-toggle.tsx
'use client'
@@ -180,25 +173,23 @@ export function ThemeToggle() {
```
**Tailwind Configuration:**
```javascript
// tailwind.config.js
module.exports = {
darkMode: 'class', // Use 'class' strategy for next-themes
darkMode: 'class', // Use 'class' strategy for next-themes
theme: {
extend: {
colors: {
// Define custom colors for consistency
'dark-primary': '#18181b',
accent: { DEFAULT: '#164e63', hover: '#155e75' },
},
},
},
'accent': { DEFAULT: '#164e63', hover: '#155e75' }
}
}
}
}
```
**CSS Variables Pattern:**
```css
/* globals.css */
:root {
@@ -228,7 +219,6 @@ module.exports = {
### Next.js 16 Specific Patterns
**Async Server Components:**
```typescript
// app/blog/page.tsx
export default async function BlogPage() {
@@ -238,7 +228,6 @@ export default async function BlogPage() {
```
**Static Generation with Dynamic Routes:**
```typescript
// app/blog/[...slug]/page.tsx
export async function generateStaticParams() {
@@ -253,7 +242,6 @@ export async function generateMetadata({ params }) {
```
**Parallel Routes for Layout Composition:**
```typescript
// app/layout.tsx
export default function RootLayout({
@@ -290,14 +278,12 @@ export default function RootLayout({
### Styling Guidelines
**Color Palette:**
- Backgrounds: `zinc-900`, `slate-900`, `slate-800`
- Accents: `cyan-900`, `emerald-900`, `teal-900`
- Text: `slate-100`, `slate-300`, `slate-500`
- Borders: `border-2`, `border-4` (thick, sharp)
**Design Tokens:**
- **NO rounded corners:** Use `rounded-none` or omit (default is sharp)
- **Monospace fonts:** Apply `font-mono` for terminal aesthetic
- **Uppercase labels:** Use `uppercase tracking-wider` for headers
@@ -305,7 +291,6 @@ export default function RootLayout({
- **Classification labels:** Add metadata like "FILE#001", "DOCUMENT LEVEL-1"
**Typography:**
- Primary font: `JetBrains Mono` (monospace)
- Headings: `font-mono font-bold uppercase`
- Body: `font-mono text-sm`

108
Dockerfile.nextjs Normal file
View File

@@ -0,0 +1,108 @@
# Multi-stage Dockerfile for Next.js Blog Application
# Optimized for Static Site Generation with standalone output
# Final image size: ~150MB
# ============================================
# Stage 1: Dependencies Installation
# ============================================
FROM node:20-alpine AS deps
# Install libc6-compat for better compatibility
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files for dependency installation
# These files are copied first to leverage Docker layer caching
# If package.json hasn't changed, this layer will be reused
COPY package.json package-lock.json* ./
# Install dependencies using npm ci for reproducible builds
# --only=production flag is not used here because we need dev dependencies for build
RUN npm ci
# ============================================
# Stage 2: Build Next.js Application
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy all application source code
# This includes:
# - app/ directory (Next.js 16 App Router)
# - components/ directory
# - lib/ directory (markdown utilities)
# - content/blog/ directory (markdown blog posts)
# - public/ directory (static assets)
# - next.config.js, tsconfig.json, tailwind.config.js, etc.
COPY . .
# Set environment variables for build
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Build the Next.js application
# This will:
# 1. Process all markdown files from content/blog/
# 2. Generate static pages for all blog posts (SSG)
# 3. Create standalone output in .next/standalone/
# 4. Optimize images and assets
# 5. Bundle and minify JavaScript
RUN npm run build
# ============================================
# Stage 3: Production Runtime
# ============================================
FROM node:20-alpine AS runner
# Install curl for health checks
RUN apk add --no-cache curl
WORKDIR /app
# Set production environment
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create a non-root user for security
# The application will run as this user instead of root
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy only the necessary files from builder stage
# Next.js standalone output includes all dependencies needed to run
COPY --from=builder /app/public ./public
# Copy standalone output (includes minimal node_modules and server files)
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# Copy static files (CSS, JS bundles, optimized images)
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Create directories for logs (optional, if your app writes logs)
RUN mkdir -p /app/logs && chown nextjs:nodejs /app/logs
# Switch to non-root user
USER nextjs
# Expose the application port
# Note: This matches the port in package.json "dev" script (-p 3030)
EXPOSE 3030
# Set the port environment variable
ENV PORT=3030
ENV HOSTNAME="0.0.0.0"
# Health check to verify the application is running
# Docker will periodically check this endpoint
# If it fails, the container is marked as unhealthy
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3030/ || exit 1
# Start the Next.js server
# The standalone output includes a minimal server.js file
CMD ["node", "server.js"]

View File

@@ -1,4 +1,4 @@
import { Breadcrumbs } from '@/components/layout/Breadcrumbs'
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
export default function AboutBreadcrumb() {
return (
@@ -11,5 +11,5 @@ export default function AboutBreadcrumb() {
},
]}
/>
)
);
}

View File

@@ -1,10 +1,10 @@
import { Breadcrumbs } from '@/components/layout/Breadcrumbs'
import { getPostBySlug } from '@/lib/markdown'
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
import { getPostBySlug } from '@/lib/markdown';
interface BreadcrumbItem {
label: string
href: string
current?: boolean
label: string;
href: string;
current?: boolean;
}
function formatDirectoryName(name: string): string {
@@ -12,34 +12,34 @@ function formatDirectoryName(name: string): string {
tech: 'Tehnologie',
design: 'Design',
tutorial: 'Tutoriale',
}
};
return directoryNames[name] || name.charAt(0).toUpperCase() + name.slice(1)
return directoryNames[name] || name.charAt(0).toUpperCase() + name.slice(1);
}
export default async function BlogPostBreadcrumb({
params,
}: {
params: Promise<{ slug: string[] }>
params: Promise<{ slug: string[] }>;
}) {
const { slug } = await params
const slugPath = slug.join('/')
const post = getPostBySlug(slugPath)
const { slug } = await params;
const slugPath = slug.join('/');
const post = getPostBySlug(slugPath);
const items: BreadcrumbItem[] = [
{
label: 'Blog',
href: '/blog',
},
]
];
if (slug.length > 1) {
for (let i = 0; i < slug.length - 1; i++) {
const segmentPath = slug.slice(0, i + 1).join('/')
const segmentPath = slug.slice(0, i + 1).join('/');
items.push({
label: formatDirectoryName(slug[i]),
href: `/blog/${segmentPath}`,
})
});
}
}
@@ -47,7 +47,7 @@ export default async function BlogPostBreadcrumb({
label: post ? post.frontmatter.title : slug[slug.length - 1],
href: `/blog/${slugPath}`,
current: true,
})
});
return <Breadcrumbs items={items} />
return <Breadcrumbs items={items} />;
}

View File

@@ -1,4 +1,4 @@
import { Breadcrumbs } from '@/components/layout/Breadcrumbs'
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
export default function BlogBreadcrumb() {
return (
@@ -11,5 +11,5 @@ export default function BlogBreadcrumb() {
},
]}
/>
)
);
}

View File

@@ -1,7 +1,7 @@
'use client'
'use client';
import { Breadcrumbs } from '@/components/layout/Breadcrumbs'
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
export default function DefaultBreadcrumb() {
return <Breadcrumbs />
return <Breadcrumbs />;
}

View File

@@ -1,11 +1,15 @@
import { Breadcrumbs } from '@/components/layout/Breadcrumbs'
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
export default async function TagBreadcrumb({ params }: { params: Promise<{ tag: string }> }) {
const { tag } = await params
export default async function TagBreadcrumb({
params,
}: {
params: Promise<{ tag: string }>;
}) {
const { tag } = await params;
const tagName = tag
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
.join(' ');
return (
<Breadcrumbs
@@ -21,5 +25,5 @@ export default async function TagBreadcrumb({ params }: { params: Promise<{ tag:
},
]}
/>
)
);
}

View File

@@ -1,4 +1,4 @@
import { Breadcrumbs } from '@/components/layout/Breadcrumbs'
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
export default function TagsBreadcrumb() {
return (
@@ -11,5 +11,5 @@ export default function TagsBreadcrumb() {
},
]}
/>
)
);
}

View File

@@ -12,8 +12,8 @@ export default function AboutPage() {
<div className="prose dark:prose-invert max-w-none">
<p className="text-lg leading-relaxed mb-6">
Bun venit pe blogul meu! Sunt un dezvoltator pasionat de tehnologie, specializat în
dezvoltarea web modernă cu Next.js, React și TypeScript.
Bun venit pe blogul meu! Sunt un dezvoltator pasionat de tehnologie,
specializat în dezvoltarea web modernă cu Next.js, React și TypeScript.
</p>
<h2 className="text-2xl font-semibold mt-8 mb-4">Ce vei găsi aici</h2>
@@ -27,18 +27,10 @@ export default function AboutPage() {
<h2 className="text-2xl font-semibold mt-8 mb-4">Tehnologii folosite</h2>
<p>Acest blog este construit cu:</p>
<ul className="space-y-2">
<li>
<strong>Next.js 15</strong> - Framework React pentru producție
</li>
<li>
<strong>TypeScript</strong> - Pentru type safety
</li>
<li>
<strong>Tailwind CSS</strong> - Pentru stilizare rapidă
</li>
<li>
<strong>Markdown</strong> - Pentru conținut
</li>
<li><strong>Next.js 15</strong> - Framework React pentru producție</li>
<li><strong>TypeScript</strong> - Pentru type safety</li>
<li><strong>Tailwind CSS</strong> - Pentru stilizare rapidă</li>
<li><strong>Markdown</strong> - Pentru conținut</li>
</ul>
<h2 className="text-2xl font-semibold mt-8 mb-4">Contact</h2>

View File

@@ -10,16 +10,10 @@ export default function NotFound() {
Ne pare rău, dar articolul pe care îl cauți nu există sau a fost mutat.
</p>
<div className="space-x-4">
<Link
href="/blog"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition"
>
<Link href="/blog" className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition">
Vezi toate articolele
</Link>
<Link
href="/"
className="inline-block px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
<Link href="/" className="inline-block px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition">
Pagina principală
</Link>
</div>

View File

@@ -10,14 +10,10 @@ import MarkdownRenderer from '@/components/blog/markdown-renderer'
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map(post => ({ slug: post.slug.split('/') }))
return posts.map((post) => ({ slug: post.slug.split('/') }))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string[] }>
}): Promise<Metadata> {
export async function generateMetadata({ params }: { params: Promise<{ slug: string[] }> }): Promise<Metadata> {
const { slug } = await params
const slugPath = slug.join('/')
const post = getPostBySlug(slugPath)
@@ -55,10 +51,7 @@ function extractHeadings(content: string) {
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length
const text = match[2]
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
headings.push({ id, text, level })
}
@@ -117,8 +110,7 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
</h1>
<p className="text-lg text-[rgb(var(--text-secondary))] leading-relaxed mb-6 font-mono">
<span className="text-[var(--neon-pink)]">&gt;&gt;</span>{' '}
{post.frontmatter.description}
<span className="text-[var(--neon-pink)]">&gt;&gt;</span> {post.frontmatter.description}
</p>
</div>
@@ -129,13 +121,9 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
</span>
</div>
<div>
<p className="font-mono font-bold text-[var(--neon-cyan)] uppercase text-sm">
{post.frontmatter.author}
</p>
<p className="font-mono font-bold text-[var(--neon-cyan)] uppercase text-sm">{post.frontmatter.author}</p>
<div className="flex items-center gap-2 text-xs text-[rgb(var(--text-muted))] font-mono">
<time className="text-[var(--neon-magenta)]">
{formatDate(post.frontmatter.date)}
</time>
<time className="text-[var(--neon-magenta)]">{formatDate(post.frontmatter.date)}</time>
<span className="text-[var(--neon-pink)]">//</span>
<span className="text-[var(--neon-cyan)]">{post.readingTime}min READ</span>
</div>
@@ -159,25 +147,17 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
{relatedPosts.length > 0 && (
<section className="mt-12 pt-8 border-t border-zinc-800">
<h2 className="text-2xl font-mono font-bold uppercase text-[var(--neon-cyan)] mb-6">
// Articole similare
</h2>
<h2 className="text-2xl font-mono font-bold uppercase text-[var(--neon-cyan)] mb-6">// Articole similare</h2>
<div className="grid gap-6 md:grid-cols-3">
{relatedPosts.map(relatedPost => (
{relatedPosts.map((relatedPost) => (
<Link
key={relatedPost.slug}
href={`/blog/${relatedPost.slug}`}
className="block p-4 border border-zinc-800 bg-zinc-950 hover:border-[var(--neon-cyan)] transition-all hover:shadow-[0_0_8px_rgba(90,139,149,0.2)]"
>
<h3 className="font-mono font-semibold text-cyan-400 mb-2 line-clamp-2">
{relatedPost.frontmatter.title}
</h3>
<p className="text-sm text-zinc-400 line-clamp-2">
{relatedPost.frontmatter.description}
</p>
<p className="text-xs text-zinc-600 mt-2 font-mono">
{formatDate(relatedPost.frontmatter.date)}
</p>
<h3 className="font-mono font-semibold text-cyan-400 mb-2 line-clamp-2">{relatedPost.frontmatter.title}</h3>
<p className="text-sm text-zinc-400 line-clamp-2">{relatedPost.frontmatter.description}</p>
<p className="text-xs text-zinc-600 mt-2 font-mono">{formatDate(relatedPost.frontmatter.date)}</p>
</Link>
))}
</div>
@@ -190,12 +170,7 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
className="flex items-center text-[var(--neon-pink)] hover:text-[var(--neon-magenta)] transition-all font-mono text-sm uppercase border border-[var(--neon-pink)] px-4 py-2 hover:shadow-[0_0_6px_rgba(155,90,110,0.3)]"
>
<svg className="mr-2 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
[BACK TO BLOG]
</Link>

View File

@@ -23,14 +23,15 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
const postsPerPage = 9
const filteredAndSortedPosts = useMemo(() => {
const result = posts.filter(post => {
let result = posts.filter((post) => {
const matchesSearch =
searchQuery === '' ||
post.frontmatter.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
post.frontmatter.description.toLowerCase().includes(searchQuery.toLowerCase())
const matchesTags =
selectedTags.length === 0 || selectedTags.every(tag => post.frontmatter.tags.includes(tag))
selectedTags.length === 0 ||
selectedTags.every((tag) => post.frontmatter.tags.includes(tag))
return matchesSearch && matchesTags
})
@@ -57,12 +58,15 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
)
const toggleTag = (tag: string) => {
setSelectedTags(prev => (prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]))
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
)
setCurrentPage(1)
}
return (
<div className="min-h-screen bg-[rgb(var(--bg-primary))]">
<div className="max-w-7xl mx-auto px-6 py-12">
{/* Header */}
<div className="border-l border-[var(--neon-cyan)] pl-6 mb-12">
@@ -79,12 +83,15 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
<div className="flex flex-col lg:flex-row gap-4">
<SearchBar
searchQuery={searchQuery}
onSearchChange={value => {
onSearchChange={(value) => {
setSearchQuery(value)
setCurrentPage(1)
}}
/>
<SortDropdown sortBy={sortBy} onSortChange={setSortBy} />
<SortDropdown
sortBy={sortBy}
onSortChange={setSortBy}
/>
</div>
</div>
@@ -102,8 +109,7 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
{/* Results Count */}
<div className="mb-6">
<p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase">
FOUND {filteredAndSortedPosts.length}{' '}
{filteredAndSortedPosts.length === 1 ? 'POST' : 'POSTS'}
FOUND {filteredAndSortedPosts.length} {filteredAndSortedPosts.length === 1 ? 'POST' : 'POSTS'}
</p>
</div>
@@ -136,14 +142,14 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
<div className="border border-[rgb(var(--border-primary))] bg-[rgb(var(--bg-secondary))] p-6">
<div className="flex items-center justify-between">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
onClick={() => setCurrentPage((p) => Math.max(1, p - 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"
>
&lt; PREV
</button>
<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) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
@@ -158,7 +164,7 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps)
))}
</div>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
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"
>

View File

@@ -3,7 +3,7 @@ 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()
const allTags = Array.from(new Set(posts.flatMap((post) => post.frontmatter.tags))).sort()
return <BlogPageClient posts={posts} allTags={allTags} />
}

View File

@@ -1,4 +1,4 @@
@import 'tailwindcss';
@import "tailwindcss";
@theme {
--color-*: initial;
@@ -70,7 +70,12 @@
/* Scanline effect */
.scanline {
background: linear-gradient(0deg, transparent 0%, rgba(6, 182, 212, 0.1) 50%, transparent 100%);
background: linear-gradient(
0deg,
transparent 0%,
rgba(6, 182, 212, 0.1) 50%,
transparent 100%
);
background-size: 100% 3px;
pointer-events: none;
}
@@ -130,43 +135,19 @@
}
@keyframes glitch-1 {
0%,
100% {
clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%);
transform: translate(0);
}
20% {
clip-path: polygon(0 15%, 100% 15%, 100% 65%, 0 65%);
}
40% {
clip-path: polygon(0 30%, 100% 30%, 100% 70%, 0 70%);
}
60% {
clip-path: polygon(0 5%, 100% 5%, 100% 60%, 0 60%);
}
80% {
clip-path: polygon(0 25%, 100% 25%, 100% 40%, 0 40%);
}
0%, 100% { clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%); transform: translate(0); }
20% { clip-path: polygon(0 15%, 100% 15%, 100% 65%, 0 65%); }
40% { clip-path: polygon(0 30%, 100% 30%, 100% 70%, 0 70%); }
60% { clip-path: polygon(0 5%, 100% 5%, 100% 60%, 0 60%); }
80% { clip-path: polygon(0 25%, 100% 25%, 100% 40%, 0 40%); }
}
@keyframes glitch-2 {
0%,
100% {
clip-path: polygon(0 55%, 100% 55%, 100% 100%, 0 100%);
transform: translate(0);
}
20% {
clip-path: polygon(0 70%, 100% 70%, 100% 95%, 0 95%);
}
40% {
clip-path: polygon(0 40%, 100% 40%, 100% 85%, 0 85%);
}
60% {
clip-path: polygon(0 60%, 100% 60%, 100% 100%, 0 100%);
}
80% {
clip-path: polygon(0 50%, 100% 50%, 100% 90%, 0 90%);
}
0%, 100% { clip-path: polygon(0 55%, 100% 55%, 100% 100%, 0 100%); transform: translate(0); }
20% { clip-path: polygon(0 70%, 100% 70%, 100% 95%, 0 95%); }
40% { clip-path: polygon(0 40%, 100% 40%, 100% 85%, 0 85%); }
60% { clip-path: polygon(0 60%, 100% 60%, 100% 100%, 0 100%); }
80% { clip-path: polygon(0 50%, 100% 50%, 100% 90%, 0 90%); }
}
/* Grayscale filter with instant toggle */
@@ -181,7 +162,7 @@
/* Cyberpunk Glitch Effect for Button */
.glitch-btn {
position: relative;
animation: glitch 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
animation: glitch 300ms cubic-bezier(.25, .46, .45, .94);
}
.glitch-layer {
@@ -198,22 +179,21 @@
}
.glitch-layer:first-of-type {
animation: glitch-1 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
animation: glitch-1 300ms cubic-bezier(.25, .46, .45, .94);
color: rgb(6 182 212); /* cyan-500 */
transform: translate(-2px, 0);
clip-path: polygon(0 0, 100% 0, 100% 35%, 0 35%);
}
.glitch-layer:last-of-type {
animation: glitch-2 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
animation: glitch-2 300ms cubic-bezier(.25, .46, .45, .94);
color: rgb(16 185 129); /* emerald-500 */
transform: translate(2px, 0);
clip-path: polygon(0 65%, 100% 65%, 100% 100%, 0 100%);
}
@keyframes glitch-1 {
0%,
100% {
0%, 100% {
transform: translate(0, 0);
clip-path: polygon(0 0, 100% 0, 100% 35%, 0 35%);
}
@@ -232,8 +212,7 @@
}
@keyframes glitch-2 {
0%,
100% {
0%, 100% {
transform: translate(0, 0);
clip-path: polygon(0 65%, 100% 65%, 100% 100%, 0 100%);
}
@@ -283,8 +262,7 @@
/* SCP-style subtle flicker hover */
@keyframes scp-flicker {
0%,
100% {
0%, 100% {
border-color: rgb(71 85 105);
box-shadow: 0 0 0 rgba(90, 139, 149, 0);
transform: translate(0, 0);
@@ -314,9 +292,7 @@
.cyber-glitch-hover:hover {
animation: scp-flicker 150ms ease-in-out 3;
border-color: var(--neon-cyan) !important;
box-shadow:
0 1px 4px rgba(90, 139, 149, 0.15),
inset 0 0 8px rgba(90, 139, 149, 0.05);
box-shadow: 0 1px 4px rgba(90, 139, 149, 0.15), inset 0 0 8px rgba(90, 139, 149, 0.05);
}
/* Navbar hide on scroll */
@@ -429,9 +405,7 @@
margin-top: 2rem;
margin-bottom: 2rem;
position: relative;
box-shadow:
-4px 0 15px rgba(155, 90, 142, 0.3),
inset 0 0 20px rgba(155, 90, 142, 0.05);
box-shadow: -4px 0 15px rgba(155,90,142,0.3), inset 0 0 20px rgba(155,90,142,0.05);
}
.cyberpunk-prose blockquote::before {
@@ -452,8 +426,8 @@
font-size: 0.875rem;
font-family: monospace;
border: 2px solid var(--neon-cyan);
box-shadow: 0 0 8px rgba(90, 139, 149, 0.3);
text-shadow: 0 0 6px rgba(90, 139, 149, 0.6);
box-shadow: 0 0 8px rgba(90,139,149,0.3);
text-shadow: 0 0 6px rgba(90,139,149,0.6);
}
.cyberpunk-prose pre {
@@ -463,9 +437,7 @@
margin-top: 2rem;
margin-bottom: 2rem;
overflow-x: auto;
box-shadow:
0 0 25px rgba(123, 101, 147, 0.6),
inset 0 0 25px rgba(123, 101, 147, 0.1);
box-shadow: 0 0 25px rgba(123,101,147,0.6), inset 0 0 25px rgba(123,101,147,0.1);
}
.cyberpunk-prose pre code {
@@ -478,7 +450,7 @@
margin-top: 2rem;
margin-bottom: 2rem;
border: 4px solid var(--neon-pink);
box-shadow: 0 0 20px rgba(155, 90, 110, 0.5);
box-shadow: 0 0 20px rgba(155,90,110,0.5);
}
.cyberpunk-prose hr {
@@ -488,3 +460,4 @@
margin-bottom: 3rem;
}
}

View File

@@ -28,7 +28,11 @@ export const metadata: Metadata = {
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ro" 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">
@@ -47,9 +51,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<div className="container mx-auto px-4 py-8">
<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">
© 2025 <span style={{ color: 'var(--neon-cyan)' }}>//</span> BLOG &{' '}
<span style={{ color: 'var(--neon-pink)' }}>PORTOFOLIU</span>{' '}
<span style={{ color: 'var(--neon-cyan)' }}>//</span> ALL RIGHTS RESERVED
© 2025 <span style={{ color: 'var(--neon-cyan)' }}>//</span> BLOG & <span style={{ color: 'var(--neon-pink)' }}>PORTOFOLIU</span> <span style={{ color: 'var(--neon-cyan)' }}>//</span> ALL RIGHTS RESERVED
</p>
</div>
</div>

View File

@@ -22,35 +22,19 @@ export default async function HomePage() {
<div className="mb-8 flex items-center justify-between border-b-2 border-slate-300 dark:border-slate-800 pb-4">
<div className="flex items-center gap-3">
<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">
TERMINAL:// V2.0
</span>
<span className="font-mono text-xs text-slate-500 uppercase tracking-widest">TERMINAL:// V2.0</span>
</div>
<div className="flex gap-4 items-center">
<Link
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"
>
[BLOG]
</Link>
<Link
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"
>
[ABOUT]
</Link>
<Link 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">[BLOG]</Link>
<Link 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">[ABOUT]</Link>
<ThemeToggle />
</div>
</div>
<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">
DOCUMENT LEVEL-1 // CLASSIFIED
</p>
<p className="font-mono text-xs text-slate-500 dark:text-slate-500 uppercase tracking-widest mb-2">DOCUMENT LEVEL-1 // CLASSIFIED</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">
BUILD. WRITE.
<br />
SHARE.
BUILD. WRITE.<br/>SHARE.
</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">
&gt; Explorează idei despre dezvoltare, design și tehnologie_
@@ -58,16 +42,10 @@ export default async function HomePage() {
</div>
<div className="flex gap-4 flex-wrap mt-12">
<Link
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"
>
<Link 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">
[EXPLOREAZĂ BLOG]
</Link>
<Link
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"
>
<Link 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">
[DESPRE MINE]
</Link>
</div>
@@ -103,9 +81,7 @@ export default async function HomePage() {
/>
) : (
<div className="w-full h-full bg-zinc-300 dark:bg-zinc-800 flex items-center justify-center">
<span className="font-mono text-6xl text-slate-400 dark:text-slate-700">
#{String(index + 1).padStart(2, '0')}
</span>
<span className="font-mono text-6xl text-slate-400 dark:text-slate-700">#{String(index + 1).padStart(2, '0')}</span>
</div>
)}
<div className="absolute inset-0 bg-zinc-100/60 dark:bg-zinc-900/60"></div>

View File

@@ -17,8 +17,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
<article className="border border-slate-700 bg-slate-900 p-6 h-full cyber-glitch-hover">
<div className="border-l-2 pl-4 mb-4" style={{ borderColor: 'var(--neon-pink)' }}>
<span className="font-mono text-xs text-zinc-100 uppercase tracking-wider">
{post.frontmatter.category} <span style={{ color: 'var(--neon-cyan)' }}>//</span>{' '}
{formatDate(post.frontmatter.date)}
{post.frontmatter.category} <span style={{ color: 'var(--neon-cyan)' }}>//</span> {formatDate(post.frontmatter.date)}
</span>
</div>
<h3 className="font-mono text-xl font-bold text-zinc-100 uppercase mb-3">
@@ -28,11 +27,8 @@ export function BlogCard({ post, variant }: BlogCardProps) {
{post.frontmatter.description}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{post.frontmatter.tags.map(tag => (
<span
key={tag}
className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase"
>
{post.frontmatter.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase">
#{tag}
</span>
))}
@@ -72,11 +68,8 @@ export function BlogCard({ post, variant }: BlogCardProps) {
{post.frontmatter.description}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{post.frontmatter.tags.map(tag => (
<span
key={tag}
className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase"
>
{post.frontmatter.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase">
#{tag}
</span>
))}
@@ -106,8 +99,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
<div className="p-6">
<div className="border-l-2 pl-4 mb-4" style={{ borderColor: 'var(--neon-pink)' }}>
<span className="font-mono text-xs text-zinc-100 uppercase tracking-wider">
{post.frontmatter.category} <span style={{ color: 'var(--neon-cyan)' }}>//</span>{' '}
{formatDate(post.frontmatter.date)}
{post.frontmatter.category} <span style={{ color: 'var(--neon-cyan)' }}>//</span> {formatDate(post.frontmatter.date)}
</span>
</div>
<h3 className="font-mono text-xl font-bold text-zinc-100 uppercase mb-3">
@@ -117,11 +109,8 @@ export function BlogCard({ post, variant }: BlogCardProps) {
{post.frontmatter.description}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{post.frontmatter.tags.map(tag => (
<span
key={tag}
className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase"
>
{post.frontmatter.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase">
#{tag}
</span>
))}

View File

@@ -23,9 +23,7 @@ export function CodeBlock({ code, language, filename, showLineNumbers = true }:
<div className="flex items-center justify-between px-4 py-2 bg-[rgb(var(--bg-secondary))] dark:bg-zinc-950 border-b-2 border-[var(--neon-purple)] relative">
<div className="flex items-center gap-3">
{filename && (
<span className="text-[var(--neon-cyan)] font-mono text-sm uppercase">
&gt;&gt; {filename}
</span>
<span className="text-[var(--neon-cyan)] font-mono text-sm uppercase">&gt;&gt; {filename}</span>
)}
<span className="px-2 py-1 border border-[var(--neon-purple)] text-[var(--neon-purple)] text-xs font-mono uppercase">
[{language}]

View File

@@ -1,13 +1,13 @@
'use client'
'use client';
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import Image from 'next/image'
import Link from 'next/link'
import { CodeBlock } from './code-block'
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import Image from 'next/image';
import Link from 'next/link';
import { CodeBlock } from './code-block';
interface MarkdownRendererProps {
content: string
content: string;
}
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
@@ -16,42 +16,48 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h1 id={id}>{children}</h1>
const text = String(children);
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
return <h1 id={id}>{children}</h1>;
},
h2: ({ children }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h2 id={id}>{children}</h2>
const text = String(children);
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
return <h2 id={id}>{children}</h2>;
},
h3: ({ children }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h3 id={id}>{children}</h3>
const text = String(children);
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
return <h3 id={id}>{children}</h3>;
},
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '')
const match = /language-(\w+)/.exec(className || '');
if (!inline && match) {
return <CodeBlock code={String(children).replace(/\n$/, '')} language={match[1]} />
return (
<CodeBlock
code={String(children).replace(/\n$/, '')}
language={match[1]}
/>
);
}
return <code {...props}>{children}</code>
return (
<code {...props}>
{children}
</code>
);
},
img: ({ src, alt }) => {
if (!src || typeof src !== 'string') return null
const isExternal = src.startsWith('http://') || src.startsWith('https://')
if (!src || typeof src !== 'string') return null;
const isExternal = src.startsWith('http://') || src.startsWith('https://');
if (isExternal) {
return <img src={src} alt={alt || ''} className="w-full h-auto" />
return (
<img
src={src}
alt={alt || ''}
className="w-full h-auto"
/>
);
}
return (
@@ -64,25 +70,33 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
style={{ width: '100%', height: 'auto' }}
/>
</div>
)
);
},
a: ({ href, children }) => {
if (!href) return <>{children}</>
const isExternal = href.startsWith('http://') || href.startsWith('https://')
if (!href) return <>{children}</>;
const isExternal = href.startsWith('http://') || href.startsWith('https://');
if (isExternal) {
return (
<a href={href} target="_blank" rel="noopener noreferrer">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
)
);
}
return <Link href={href}>{children}</Link>
return (
<Link href={href}>
{children}
</Link>
);
},
}}
>
{content}
</ReactMarkdown>
)
);
}

View File

@@ -28,17 +28,11 @@ export function Navbar() {
}, [lastScrollY])
return (
<nav
className={`border-b-4 border-slate-700 bg-slate-900 dark:bg-zinc-950 sticky top-0 z-50 ${isVisible ? 'navbar-visible' : 'navbar-hidden'}`}
>
<nav className={`border-b-4 border-slate-700 bg-slate-900 dark:bg-zinc-950 sticky top-0 z-50 ${isVisible ? 'navbar-visible' : 'navbar-hidden'}`}>
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-8">
<Link
href="/"
className="font-mono text-sm uppercase tracking-wider transition-colors cursor-pointer"
style={{ color: 'var(--neon-cyan)' }}
>
<Link href="/" className="font-mono text-sm uppercase tracking-wider transition-colors cursor-pointer" style={{ color: 'var(--neon-cyan)' }}>
&lt; HOME
</Link>
<span className="font-mono text-sm text-zinc-100 dark:text-zinc-300 uppercase tracking-wider">
@@ -46,10 +40,7 @@ export function Navbar() {
</span>
</div>
<div className="flex items-center gap-6">
<Link
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"
>
<Link href="/about" className="font-mono text-sm text-zinc-400 dark:text-zinc-500 uppercase tracking-wider hover:text-cyan-400 dark:hover:text-cyan-300 transition-colors cursor-pointer">
[ABOUT]
</Link>
<ThemeToggle />

View File

@@ -25,13 +25,15 @@ export function ReadingProgress() {
className="h-full bg-gradient-to-r from-[var(--neon-cyan)] via-[var(--neon-magenta)] to-[var(--neon-pink)] transition-all duration-150"
style={{
width: `${progress}%`,
boxShadow: progress > 0 ? '0 0 8px var(--neon-cyan)' : 'none',
boxShadow: progress > 0 ? '0 0 8px var(--neon-cyan)' : 'none'
}}
/>
</div>
<div className="fixed top-4 right-4 z-50 px-3 py-1.5 bg-[rgb(var(--bg-primary))] border-2 border-[var(--neon-cyan)] text-xs font-mono font-bold text-[var(--neon-cyan)] relative">
<span className="relative z-10">[{Math.round(progress)}%]</span>
<span className="relative z-10">
[{Math.round(progress)}%]
</span>
</div>
</>
)

View File

@@ -6,14 +6,12 @@ interface SearchBarProps {
export function SearchBar({ searchQuery, onSearchChange }: SearchBarProps) {
return (
<div className="flex-1 flex items-center border border-slate-700 bg-zinc-900 transition-all focus-within:border-[var(--neon-cyan)] focus-within:shadow-[0_0_6px_rgba(0,255,255,0.4),inset_0_0_6px_rgba(0,255,255,0.05)]">
<span className="pl-4 pr-2 font-mono text-lg" style={{ color: 'var(--neon-cyan)' }}>
&gt;
</span>
<span className="pl-4 pr-2 font-mono text-lg" style={{ color: 'var(--neon-cyan)' }}>&gt;</span>
<input
type="text"
placeholder="SEARCH POSTS..."
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 bg-transparent font-mono text-zinc-100 dark:text-zinc-100 px-2 py-3 focus:outline-none placeholder:text-zinc-600 uppercase text-sm"
/>
</div>

View File

@@ -9,30 +9,12 @@ export function SortDropdown({ sortBy, onSortChange }: SortDropdownProps) {
return (
<select
value={sortBy}
onChange={e => onSortChange(e.target.value as SortOption)}
onChange={(e) => onSortChange(e.target.value as SortOption)}
className="border-2 border-slate-700 bg-zinc-900 dark:bg-zinc-900 text-zinc-100 dark:text-zinc-100 font-mono px-6 py-3 uppercase text-sm cyber-focus-pink transition-all hover:border-cyan-400 cursor-pointer"
>
<option
value="newest"
className="bg-zinc-900 text-zinc-100"
style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}
>
NEWEST FIRST
</option>
<option
value="oldest"
className="bg-zinc-900 text-zinc-100"
style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}
>
OLDEST FIRST
</option>
<option
value="title"
className="bg-zinc-900 text-zinc-100"
style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}
>
BY TITLE
</option>
<option value="newest" className="bg-zinc-900 text-zinc-100" style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}>NEWEST FIRST</option>
<option value="oldest" className="bg-zinc-900 text-zinc-100" style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}>OLDEST FIRST</option>
<option value="title" className="bg-zinc-900 text-zinc-100" style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}>BY TITLE</option>
</select>
)
}

View File

@@ -48,9 +48,7 @@ export function StickyFooter({ url, title }: StickyFooterProps) {
${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',
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" />
@@ -62,10 +60,7 @@ export function StickyFooter({ url, title }: StickyFooterProps) {
<div className="w-2 h-2 bg-[var(--neon-cyan)] shadow-[0_0_6px_rgba(90,139,149,1)]" />
<div className="w-2 h-2 bg-[var(--neon-pink)] shadow-[0_0_6px_rgba(155,90,110,1)]" />
</div>
<span
className="text-[var(--neon-cyan)] font-mono text-xs uppercase tracking-wider"
style={{ textShadow: '0 0 8px rgba(90,139,149,0.6)' }}
>
<span className="text-[var(--neon-cyan)] font-mono text-xs uppercase tracking-wider" style={{ textShadow: '0 0 8px rgba(90,139,149,0.6)' }}>
&gt;&gt; SHARE:
</span>
</div>
@@ -94,9 +89,7 @@ export function StickyFooter({ url, title }: StickyFooterProps) {
<button
onClick={handleCopyLink}
className="flex items-center gap-2 px-4 py-2 bg-black border-4 border-[var(--neon-pink)] text-[var(--neon-pink)] font-mono text-xs uppercase tracking-wider transition-all hover:shadow-[0_0_25px_rgba(155,90,110,0.8)] hover:bg-pink-900/20"
style={{
textShadow: copied ? '0 0 10px rgba(155,90,110,1)' : '0 0 8px rgba(155,90,110,0.6)',
}}
style={{ textShadow: copied ? '0 0 10px rgba(155,90,110,1)' : '0 0 8px rgba(155,90,110,0.6)' }}
>
{copied ? '[✓ COPIED]' : '[COPY]'}
</button>

View File

@@ -17,8 +17,8 @@ export function TableOfContents({ headings }: TOCProps) {
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id)
}
@@ -43,45 +43,31 @@ export function TableOfContents({ headings }: TOCProps) {
<div className="border-b border-[var(--neon-magenta)] pb-3 mb-4 relative">
<div className="flex gap-1.5 mb-2 justify-end">
<div
className="w-3 h-3 border border-[var(--neon-cyan)]/40 hover:bg-[var(--neon-cyan)]/10 transition-colors cursor-pointer"
title="Minimize"
/>
<div
className="w-3 h-3 border border-[var(--neon-cyan)]/40 hover:bg-[var(--neon-cyan)]/10 transition-colors cursor-pointer"
title="Maximize"
/>
<div
className="w-3 h-3 border border-[var(--neon-pink)]/40 hover:bg-[var(--neon-pink)]/10 transition-colors cursor-pointer"
title="Close"
/>
<div className="w-3 h-3 border border-[var(--neon-cyan)]/40 hover:bg-[var(--neon-cyan)]/10 transition-colors cursor-pointer" title="Minimize" />
<div className="w-3 h-3 border border-[var(--neon-cyan)]/40 hover:bg-[var(--neon-cyan)]/10 transition-colors cursor-pointer" title="Maximize" />
<div className="w-3 h-3 border border-[var(--neon-pink)]/40 hover:bg-[var(--neon-pink)]/10 transition-colors cursor-pointer" title="Close" />
</div>
<h3
className="text-xs font-mono font-bold text-[var(--neon-cyan)] uppercase tracking-wider"
style={{ textShadow: '0 0 6px rgba(90,139,149,0.5)' }}
>
<h3 className="text-xs font-mono font-bold text-[var(--neon-cyan)] uppercase tracking-wider" style={{ textShadow: '0 0 6px rgba(90,139,149,0.5)' }}>
&gt;&gt; NAVIGATION
</h3>
</div>
<nav className="space-y-1 relative">
{headings.map(heading => (
{headings.map((heading) => (
<a
key={heading.id}
href={`#${heading.id}`}
className={`
block text-sm font-mono py-2 border-l-2 transition-all duration-150
${heading.level === 2 ? 'pl-3' : 'pl-6'}
${
activeId === heading.id
? 'text-[var(--neon-cyan)] border-[var(--neon-cyan)] bg-cyan-500/5 shadow-[0_0_8px_rgba(90,139,149,0.3)]'
: 'text-zinc-500 border-zinc-900 hover:border-[var(--neon-magenta)] hover:text-[var(--neon-magenta)] hover:bg-magenta-500/3 hover:shadow-[0_0_4px_rgba(155,90,142,0.2)]'
${activeId === heading.id
? 'text-[var(--neon-cyan)] border-[var(--neon-cyan)] bg-cyan-500/5 shadow-[0_0_8px_rgba(90,139,149,0.3)]'
: 'text-zinc-500 border-zinc-900 hover:border-[var(--neon-magenta)] hover:text-[var(--neon-magenta)] hover:bg-magenta-500/3 hover:shadow-[0_0_4px_rgba(155,90,142,0.2)]'
}
`}
style={activeId === heading.id ? { textShadow: '0 0 4px rgba(90,139,149,0.5)' } : {}}
>
<span className="inline-block">{activeId === heading.id ? '▶ ' : '◆ '}</span>
{heading.text}
<span className="inline-block">{activeId === heading.id ? '▶ ' : '◆ '}</span>{heading.text}
</a>
))}
</nav>

View File

@@ -14,7 +14,7 @@ export function TagFilter({ allTags, selectedTags, onToggleTag, onClearTags }: T
FILTER BY TAG
</p>
<div className="flex flex-wrap gap-3">
{allTags.map(tag => (
{allTags.map((tag) => (
<button
key={tag}
onClick={() => onToggleTag(tag)}

View File

@@ -1,14 +1,14 @@
'use client'
'use client';
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Fragment } from 'react'
import { BreadcrumbsSchema } from './breadcrumbs-schema'
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Fragment } from 'react';
import { BreadcrumbsSchema } from './breadcrumbs-schema';
interface BreadcrumbItem {
label: string
href: string
current?: boolean
label: string;
href: string;
current?: boolean;
}
function HomeIcon({ className }: { className?: string }) {
@@ -27,15 +27,25 @@ function HomeIcon({ className }: { className?: string }) {
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
)
);
}
function ChevronIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
)
);
}
function formatSegmentLabel(segment: string): string {
@@ -43,36 +53,36 @@ function formatSegmentLabel(segment: string): string {
blog: 'Blog',
tags: 'Tag-uri',
about: 'Despre',
}
};
if (specialCases[segment]) {
return specialCases[segment]
return specialCases[segment];
}
return segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
.join(' ');
}
export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
const pathname = usePathname()
const pathname = usePathname();
let breadcrumbs: BreadcrumbItem[] = items || []
let breadcrumbs: BreadcrumbItem[] = items || [];
if (!items) {
const segments = pathname.split('/').filter(Boolean)
const segments = pathname.split('/').filter(Boolean);
breadcrumbs = segments.map((segment, index) => {
const href = '/' + segments.slice(0, index + 1).join('/')
const label = formatSegmentLabel(segment)
const current = index === segments.length - 1
const href = '/' + segments.slice(0, index + 1).join('/');
const label = formatSegmentLabel(segment);
const current = index === segments.length - 1;
return { label, href, current }
})
return { label, href, current };
});
}
if (pathname === '/') {
return null
return null;
}
const schemaItems = [
@@ -82,7 +92,7 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
name: item.label,
item: item.href,
})),
]
];
return (
<>
@@ -102,7 +112,7 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
</Link>
</li>
{breadcrumbs.map(item => (
{breadcrumbs.map((item) => (
<Fragment key={item.href}>
<li className="text-gray-400 flex-shrink-0">
<ChevronIcon className="w-4 h-4" />
@@ -131,5 +141,5 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
</nav>
<BreadcrumbsSchema items={schemaItems} />
</>
)
);
}

View File

@@ -1,25 +1,25 @@
interface BreadcrumbSchemaItem {
position: number
name: string
item: string
position: number;
name: string;
item: string;
}
export function BreadcrumbsSchema({ items }: { items: BreadcrumbSchemaItem[] }) {
const structuredData = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map(item => ({
itemListElement: items.map((item) => ({
'@type': 'ListItem',
position: item.position,
name: item.name,
item: `http://localhost:3000${item.item}`,
})),
}
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
)
);
}

View File

@@ -43,17 +43,18 @@ export function ThemeToggle() {
className={`
relative font-mono text-xs uppercase tracking-wider
px-3 py-1 border-2 transition-all duration-300
${
theme === 'dark'
? 'text-cyan-400 border-cyan-900 hover:border-cyan-700 bg-cyan-950/20'
: 'text-emerald-600 border-emerald-700 hover:border-emerald-500 bg-emerald-50/50'
${theme === 'dark'
? 'text-cyan-400 border-cyan-900 hover:border-cyan-700 bg-cyan-950/20'
: 'text-emerald-600 border-emerald-700 hover:border-emerald-500 bg-emerald-50/50'
}
${isGlitching ? 'glitch-btn' : ''}
border-pulse overflow-hidden
`}
aria-label="Toggle theme"
>
<span className="relative z-10">{theme === 'dark' ? '[DARK MODE]' : '[LIGHT MODE]'}</span>
<span className="relative z-10">
{theme === 'dark' ? '[DARK MODE]' : '[LIGHT MODE]'}
</span>
{isGlitching && (
<>
<span className="glitch-layer" aria-hidden="true">

View File

@@ -1,10 +1,10 @@
---
title: 'Getting Started with Next.js 15'
description: 'Learn how to build modern web applications with Next.js 15 and TypeScript.'
date: '2025-01-07'
author: 'John Doe'
category: 'Tutorial'
tags: ['nextjs', 'typescript', 'tutorial']
title: "Getting Started with Next.js 15"
description: "Learn how to build modern web applications with Next.js 15 and TypeScript."
date: "2025-01-07"
author: "John Doe"
category: "Tutorial"
tags: ["nextjs", "typescript", "tutorial"]
---
# Getting Started with Next.js 15

View File

@@ -1,10 +1,10 @@
---
title: 'Articol Tehnic din Subdirector'
description: 'Test pentru subdirectoare și organizare ierarhică'
date: '2025-01-10'
author: 'Tech Writer'
category: 'Tehnologie'
tags: ['nextjs', 'react', 'typescript']
title: "Articol Tehnic din Subdirector"
description: "Test pentru subdirectoare și organizare ierarhică"
date: "2025-01-10"
author: "Tech Writer"
category: "Tehnologie"
tags: ["nextjs", "react", "typescript"]
draft: false
---
@@ -25,14 +25,14 @@ Next.js este un framework React puternic care oferă:
```typescript
interface User {
id: number
name: string
email: string
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`)
return response.json()
const response = await fetch(`/api/users/${id}`);
return response.json();
}
```

View File

@@ -1,17 +1,17 @@
---
title: 'Test Complet Markdown'
description: 'Un articol de test care demonstrează toate elementele markdown suportate'
date: '2025-01-15'
author: 'Test Author'
category: 'Tutorial'
tags: ['markdown', 'test', 'demo']
image: '/38636.jpg'
title: "Test Complet Markdown"
description: "Un articol de test care demonstrează toate elementele markdown suportate"
date: "2025-01-15"
author: "Test Author"
category: "Tutorial"
tags: ["markdown", "test", "demo"]
image: "/38636.jpg"
draft: false
---
# Heading 1
Acesta este un paragraf normal cu **text bold** și _text italic_. Putem combina **_bold și italic_**.
Acesta este un paragraf normal cu **text bold** și *text italic*. Putem combina ***bold și italic***.
## Heading 2
@@ -47,11 +47,11 @@ Bloc de cod JavaScript:
```javascript
function greet(name) {
console.log(`Hello, ${name}!`)
return true
console.log(`Hello, ${name}!`);
return true;
}
greet('World')
greet("World");
```
Bloc de cod Python:
@@ -85,7 +85,7 @@ print(f"Result: {result}")
## Tabele
| Coloana 1 | Coloana 2 | Coloana 3 |
| --------- | --------- | --------- |
|-----------|-----------|-----------|
| Celula 1 | Celula 2 | Celula 3 |
| Date 1 | Date 2 | Date 3 |
| Info 1 | Info 2 | Info 3 |

134
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,134 @@
# Docker Compose Configuration for Production Deployment
# This file is used by CI/CD to deploy the application on production servers
#
# Key differences from local docker-compose.yml:
# - Uses pre-built image from registry (not local build)
# - Includes resource limits and logging configuration
# - More stringent health checks
# - Production-grade restart policies
#
# Usage:
# 1. This file is automatically copied to server by CI/CD workflow
# 2. Server pulls image from registry: docker compose -f docker-compose.prod.yml pull
# 3. Server starts container: docker compose -f docker-compose.prod.yml up -d
#
# Manual deployment (if CI/CD is not available):
# ssh user@production-server
# cd /opt/mypage
# docker compose -f docker-compose.prod.yml pull
# docker compose -f docker-compose.prod.yml up -d --force-recreate
version: '3.8'
services:
mypage:
# Use pre-built image from private registry
# This image is built and pushed by the CI/CD workflow
# Format: REGISTRY_URL/IMAGE_NAME:TAG
image: repository.workspace:5000/mypage:latest
container_name: mypage-prod
# Restart policy: always restart on failure or server reboot
# This ensures high availability in production
restart: always
# Port mapping: host:container
# The application will be accessible at http://SERVER_IP:3030
# Usually, a reverse proxy (Caddy/Nginx) will forward traffic to this port
ports:
- "3030:3030"
# Production environment variables
environment:
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
- PORT=3030
- HOSTNAME=0.0.0.0
# Add any other production-specific environment variables here
# Example:
# - DATABASE_URL=postgresql://user:pass@db:5432/mypage
# - 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 production
# 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-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-network:
driver: bridge
# ============================================
# Production Deployment Commands
# ============================================
#
# Pull latest image from registry:
# docker compose -f docker-compose.prod.yml pull
#
# Start/update containers:
# docker compose -f docker-compose.prod.yml up -d --force-recreate
#
# View logs:
# docker compose -f docker-compose.prod.yml logs -f mypage
#
# Check health status:
# docker inspect mypage-prod | grep -A 10 Health
#
# Stop containers:
# docker compose -f docker-compose.prod.yml down
#
# Restart containers:
# docker compose -f docker-compose.prod.yml restart
#
# Remove old/unused images (cleanup):
# docker image prune -f
#
# ============================================
# Troubleshooting
# ============================================
#
# If container keeps restarting:
# 1. Check logs: docker compose -f docker-compose.prod.yml logs --tail=100
# 2. Check health: docker inspect mypage-prod | grep -A 10 Health
# 3. Verify port is not already in use: netstat -tulpn | grep 3030
# 4. Check resource usage: docker stats mypage-prod
#
# If health check fails:
# 1. Test manually: docker exec mypage-prod curl -f http://localhost:3030/
# 2. Check if Next.js server is running: docker exec mypage-prod ps aux
# 3. Verify environment variables: docker exec mypage-prod env

90
docker-compose.yml Normal file
View File

@@ -0,0 +1,90 @@
# Docker Compose Configuration for Local Development/Testing
# This file is used to run the Next.js blog application locally using Docker
#
# Usage:
# 1. Copy this file to project root: cp docker-compose.yml.example docker-compose.yml
# 2. Build and start: docker compose up -d
# 3. View logs: docker compose logs -f
# 4. Stop: docker compose down
#
# Note: This builds from local Dockerfile, not registry image
version: '3.8'
services:
mypage:
# Build configuration
build:
context: .
dockerfile: Dockerfile.nextjs # Use the Next.js-specific Dockerfile
container_name: mypage-dev
# Port mapping: host:container
# Access the app at http://localhost:3030
ports:
- "3030:3030"
# Environment variables for development
environment:
- NODE_ENV=production # Use production mode even locally to test production build
- NEXT_TELEMETRY_DISABLED=1
- PORT=3030
- HOSTNAME=0.0.0.0
# Optional: Mount logs directory for debugging
# Uncomment if your application writes logs to /app/logs
# volumes:
# - ./logs:/app/logs
# Restart policy: restart unless explicitly stopped
restart: unless-stopped
# Network configuration
networks:
- mypage-network
# Health check configuration
# Docker will check if the app is healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3030/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
mypage-network:
driver: bridge
# ============================================
# Common Commands
# ============================================
#
# Build and start containers:
# docker compose up -d
#
# Build with no cache (clean build):
# docker compose build --no-cache
# docker compose up -d
#
# View logs (follow mode):
# docker compose logs -f mypage
#
# Stop containers:
# docker compose down
#
# Stop and remove volumes:
# docker compose down -v
#
# Restart service:
# docker compose restart mypage
#
# Access container shell:
# docker compose exec mypage /bin/sh
#
# Check container status:
# docker compose ps
#
# View resource usage:
# docker stats mypage-dev

View File

@@ -1,33 +0,0 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import prettier from 'eslint-config-prettier'
export default [
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
{
ignores: [
'node_modules/',
'.next/',
'out/',
'build/',
'dist/',
'.cache/',
'*.config.js',
'public/',
'coverage/',
],
},
]

View File

@@ -1,43 +1,43 @@
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { FrontMatter, Post } from './types/frontmatter'
import { generateExcerpt } from './utils'
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { FrontMatter, Post } from './types/frontmatter';
import { generateExcerpt } from './utils';
const POSTS_PATH = path.join(process.cwd(), 'content', 'blog')
const POSTS_PATH = path.join(process.cwd(), 'content', 'blog');
export function sanitizePath(inputPath: string): string {
const normalized = path.normalize(inputPath).replace(/^(\.\.[/\\])+/, '')
const normalized = path.normalize(inputPath).replace(/^(\.\.[\/\\])+/, '');
if (normalized.includes('..') || path.isAbsolute(normalized)) {
throw new Error('Invalid path')
throw new Error('Invalid path');
}
return normalized
return normalized;
}
export function calculateReadingTime(content: string): number {
const wordsPerMinute = 200
const words = content.trim().split(/\s+/).length
return Math.ceil(words / wordsPerMinute)
const wordsPerMinute = 200;
const words = content.trim().split(/\s+/).length;
return Math.ceil(words / wordsPerMinute);
}
export function validateFrontmatter(data: any): FrontMatter {
if (!data.title || typeof data.title !== 'string') {
throw new Error('Invalid title')
throw new Error('Invalid title');
}
if (!data.description || typeof data.description !== 'string') {
throw new Error('Invalid description')
throw new Error('Invalid description');
}
if (!data.date || typeof data.date !== 'string') {
throw new Error('Invalid date')
throw new Error('Invalid date');
}
if (!data.author || typeof data.author !== 'string') {
throw new Error('Invalid author')
throw new Error('Invalid author');
}
if (!data.category || typeof data.category !== 'string') {
throw new Error('Invalid category')
throw new Error('Invalid category');
}
if (!Array.isArray(data.tags) || data.tags.length === 0 || data.tags.length > 3) {
throw new Error('Tags must be array with 1-3 items')
throw new Error('Tags must be array with 1-3 items');
}
return {
@@ -49,21 +49,21 @@ export function validateFrontmatter(data: any): FrontMatter {
tags: data.tags,
image: data.image,
draft: data.draft || false,
}
};
}
export function getPostBySlug(slug: string | string[]): Post | null {
const slugArray = Array.isArray(slug) ? slug : slug.split('/')
const sanitized = slugArray.map(s => sanitizePath(s))
const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md'
const slugArray = Array.isArray(slug) ? slug : slug.split('/');
const sanitized = slugArray.map(s => sanitizePath(s));
const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md';
if (!fs.existsSync(fullPath)) {
return null
return null;
}
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)
const frontmatter = validateFrontmatter(data)
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
const frontmatter = validateFrontmatter(data);
return {
slug: sanitized.join('/'),
@@ -71,86 +71,84 @@ export function getPostBySlug(slug: string | string[]): Post | null {
content,
readingTime: calculateReadingTime(content),
excerpt: generateExcerpt(content),
}
};
}
export function getAllPosts(includeContent = false): Post[] {
const posts: Post[] = []
const posts: Post[] = [];
function walkDir(dir: string, prefix = ''): void {
const files = fs.readdirSync(dir)
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file)
const stat = fs.statSync(filePath)
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
walkDir(filePath, prefix ? `${prefix}/${file}` : file)
walkDir(filePath, prefix ? `${prefix}/${file}` : file);
} 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 {
const post = getPostBySlug(slug.split('/'))
const post = getPostBySlug(slug.split('/'));
if (post && !post.frontmatter.draft) {
posts.push(includeContent ? post : { ...post, content: '' })
posts.push(includeContent ? post : { ...post, content: '' });
}
} catch (error) {
console.error(`Error loading post ${slug}:`, error)
console.error(`Error loading post ${slug}:`, error);
}
}
}
}
if (fs.existsSync(POSTS_PATH)) {
walkDir(POSTS_PATH)
walkDir(POSTS_PATH);
}
return posts.sort(
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
)
return posts.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime());
}
export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<Post[]> {
const currentPost = getPostBySlug(currentSlug)
if (!currentPost) return []
const currentPost = getPostBySlug(currentSlug);
if (!currentPost) return [];
const allPosts = getAllPosts(false)
const { category, tags } = currentPost.frontmatter
const allPosts = getAllPosts(false);
const { category, tags } = currentPost.frontmatter;
const scored = allPosts
.filter(post => post.slug !== currentSlug)
.map(post => {
let score = 0
if (post.frontmatter.category === category) score += 3
score += post.frontmatter.tags.filter(tag => tags.includes(tag)).length * 2
return { post, score }
let score = 0;
if (post.frontmatter.category === category) score += 3;
score += post.frontmatter.tags.filter(tag => tags.includes(tag)).length * 2;
return { post, score };
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.sort((a, b) => b.score - a.score);
return scored.slice(0, limit).map(({ post }) => post)
return scored.slice(0, limit).map(({ post }) => post);
}
export function getAllPostSlugs(): string[][] {
const slugs: string[][] = []
const slugs: string[][] = [];
function walkDir(dir: string, prefix: string[] = []): void {
const files = fs.readdirSync(dir)
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file)
const stat = fs.statSync(filePath)
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
walkDir(filePath, [...prefix, file])
walkDir(filePath, [...prefix, file]);
} else if (file.endsWith('.md')) {
slugs.push([...prefix, file.replace(/\.md$/, '')])
slugs.push([...prefix, file.replace(/\.md$/, '')]);
}
}
}
if (fs.existsSync(POSTS_PATH)) {
walkDir(POSTS_PATH)
walkDir(POSTS_PATH);
}
return slugs
return slugs;
}

View File

@@ -1,22 +1,22 @@
export interface FrontMatter {
title: string
description: string
date: string
author: string
category: string
tags: string[]
image?: string
draft?: boolean
title: string;
description: string;
date: string;
author: string;
category: string;
tags: string[];
image?: string;
draft?: boolean;
}
export interface Post {
slug: string
frontmatter: FrontMatter
content: string
readingTime: number
excerpt: string
slug: string;
frontmatter: FrontMatter;
content: string;
readingTime: number;
excerpt: string;
}
export interface BlogParams {
slug: string[]
slug: string[];
}

View File

@@ -1,65 +1,47 @@
export function formatDate(dateString: string): string {
const date = new Date(dateString)
const date = new Date(dateString);
const months = [
'ianuarie',
'februarie',
'martie',
'aprilie',
'mai',
'iunie',
'iulie',
'august',
'septembrie',
'octombrie',
'noiembrie',
'decembrie',
]
'ianuarie', 'februarie', 'martie', 'aprilie', 'mai', 'iunie',
'iulie', 'august', 'septembrie', 'octombrie', 'noiembrie', 'decembrie'
];
return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`
return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`;
}
export function formatRelativeDate(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffTime = Math.abs(now.getTime() - date.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'astăzi'
if (diffDays === 1) return 'ieri'
if (diffDays < 7) return `acum ${diffDays} zile`
if (diffDays < 30) return `acum ${Math.floor(diffDays / 7)} săptămâni`
if (diffDays < 365) return `acum ${Math.floor(diffDays / 30)} luni`
return `acum ${Math.floor(diffDays / 365)} ani`
if (diffDays === 0) return 'astăzi';
if (diffDays === 1) return 'ieri';
if (diffDays < 7) return `acum ${diffDays} zile`;
if (diffDays < 30) return `acum ${Math.floor(diffDays / 7)} săptămâni`;
if (diffDays < 365) return `acum ${Math.floor(diffDays / 30)} luni`;
return `acum ${Math.floor(diffDays / 365)} ani`;
}
export function generateExcerpt(content: string, maxLength = 160): string {
const text = content
.replace(/^---[\s\S]*?---/, '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1')
.replace(/[#*`]/g, '')
.trim()
.trim();
if (text.length <= maxLength) return text
if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength)
const lastSpace = truncated.lastIndexOf(' ')
return truncated.slice(0, lastSpace) + '...'
const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
return truncated.slice(0, lastSpace) + '...';
}
export function generateSlug(title: string): string {
const romanianMap: Record<string, string> = {
ă: 'a',
â: 'a',
î: 'i',
ș: 's',
ț: 't',
Ă: 'a',
Â: 'a',
Î: 'i',
Ș: 's',
Ț: 't',
}
'ă': 'a', 'â': 'a', 'î': 'i', 'ș': 's', 'ț': 't',
'Ă': 'a', 'Â': 'a', 'Î': 'i', 'Ș': 's', 'Ț': 't'
};
return title
.split('')
@@ -67,5 +49,5 @@ export function generateSlug(title: string): string {
.join('')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/^-+|-+$/g, '');
}

View File

@@ -1,10 +1,180 @@
/** @type {import('next').NextConfig} */
// Production-ready Next.js configuration with standalone output
// This configuration is optimized for Docker deployment with minimal image size
//
// Key features:
// - Standalone output mode (includes only necessary dependencies)
// - Image optimization with modern formats
// - Static Site Generation (SSG) for all blog posts
// - Production-grade caching and performance settings
//
// Usage:
// 1. Copy this file to project root: cp next.config.js.production next.config.js
// 2. Build application: npm run build
// 3. The .next/standalone directory will contain everything needed to run the app
const nextConfig = {
// ============================================
// Standalone Output Mode
// ============================================
// This is REQUIRED for Docker deployment
// Outputs a minimal server with only necessary dependencies
// Reduces Docker image size from ~1GB to ~150MB
output: 'standalone',
// ============================================
// Image Optimization
// ============================================
images: {
// Modern image formats (smaller file sizes)
formats: ['image/avif', 'image/webp'],
// Device sizes for responsive images
// Next.js will generate optimized images for these widths
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
// Image sizes for <Image> component size prop
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
// Disable image optimization during build (optional)
// Uncomment if build times are too long
// unoptimized: false,
// External image domains (if loading images from CDN)
// Uncomment and add domains if needed
// remotePatterns: [
// {
// protocol: 'https',
// hostname: 'cdn.example.com',
// },
// ],
},
// ============================================
// Performance Optimization
// ============================================
// Enable SWC minification (faster than Terser)
swcMinify: true,
// Compress static pages (reduces bandwidth)
compress: true,
// ============================================
// Production Settings
// ============================================
// Disable X-Powered-By header for security
poweredByHeader: false,
// Generate ETags for caching
generateEtags: true,
// ============================================
// Static Generation Settings
// ============================================
// Automatically generate static pages at build time
// This is the default behavior for Next.js App Router
// All markdown blog posts will be pre-rendered
// ============================================
// TypeScript Settings
// ============================================
// Type checking during build
// Set to false to skip type checking (not recommended)
typescript: {
// ignoreBuildErrors: false,
},
// ============================================
// ESLint Settings
// ============================================
// ESLint during build
// Set to false to skip linting (not recommended)
eslint: {
// ignoreDuringBuilds: false,
},
// ============================================
// Experimental Features (Next.js 16)
// ============================================
experimental: {
// Enable optimistic client cache
// Improves navigation performance
staleTimes: {
dynamic: 30,
static: 180,
},
// Enable PPR (Partial Prerendering) - Next.js 16 feature
// Uncomment to enable (currently in beta)
// ppr: false,
},
// ============================================
// Headers (Optional)
// ============================================
// Custom headers for all routes
// Note: Caddy/Nginx reverse proxy can also set these headers
// Uncomment if you want Next.js to handle headers instead
//
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
],
},
]
},
// ============================================
// Redirects (Optional)
// ============================================
// Add permanent redirects for old URLs
// Uncomment and add your redirects
//
// async redirects() {
// return [
// {
// source: '/old-blog/:slug',
// destination: '/blog/:slug',
// permanent: true,
// },
// ]
// },
// ============================================
// Rewrites (Optional)
// ============================================
// Add URL rewrites for API proxying or URL masking
// Uncomment and add your rewrites
//
// async rewrites() {
// return [
// {
// source: '/api/:path*',
// destination: 'https://api.example.com/:path*',
// },
// ]
// },
}
module.exports = nextConfig

5075
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,7 @@
"dev": "next dev -p 3030",
"build": "next build",
"start": "next start",
"lint": "eslint . --ext .ts,.tsx,.js,.jsx",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
"lint": "next lint",
"validate-posts": "node scripts/validate-posts.js"
},
"repository": {
@@ -41,17 +38,5 @@
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.39.1",
"@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.46.4",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"prettier": "^3.6.2",
"typescript-eslint": "^8.46.4"
}
}

View File

@@ -2,9 +2,9 @@
module.exports = {
darkMode: 'class',
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
@@ -24,7 +24,7 @@ module.exports = {
'dark-primary': '#18181b',
'dark-secondary': '#0f172a',
'dark-tertiary': '#1e293b',
accent: {
'accent': {
DEFAULT: '#164e63',
hover: '#155e75',
light: '#0e7490',
@@ -39,10 +39,10 @@ module.exports = {
},
},
animation: {
glitch: 'glitch 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both',
flicker: 'flicker 0.15s infinite',
scanline: 'scanline 8s linear infinite',
noise: 'noise 0.2s infinite',
'glitch': 'glitch 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both',
'flicker': 'flicker 0.15s infinite',
'scanline': 'scanline 8s linear infinite',
'noise': 'noise 0.2s infinite',
},
keyframes: {
glitch: {

View File

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