Compare commits
37 Commits
21513b20c4
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f7ea8ca4d | ||
|
|
c00fe6b06c | ||
|
|
dcaa1f8c3c | ||
|
|
c49493bb44 | ||
|
|
c9cd2d734d | ||
|
|
ef72f5fc58 | ||
|
|
8b440dd60b | ||
|
|
9a55dc7f81 | ||
|
|
3ac7c7a5b3 | ||
|
|
96d7ae5747 | ||
|
|
f7b7eaeaff | ||
|
|
32e621df14 | ||
|
|
6c5297836c | ||
|
|
9c7e564f6f | ||
|
|
4046a3c5b3 | ||
|
|
3e83dcfa15 | ||
|
|
b0ec4fd4b7 | ||
|
|
6ee52ffc8e | ||
|
|
450fe1b3eb | ||
|
|
f1d42818ee | ||
|
|
e0e0551a83 | ||
|
|
97c600df14 | ||
|
|
6c47cdbd83 | ||
|
|
bd6007f299 | ||
|
|
689cfa18cf | ||
|
|
4029cd660d | ||
|
|
b754af20e6 | ||
|
|
3f31d6f5bb | ||
|
|
8eff9106f5 | ||
|
|
af30449071 | ||
|
|
98c3ebb96c | ||
|
|
9e2040cefc | ||
|
|
719071345e | ||
|
|
efafd38b1a | ||
|
|
5c70b26508 | ||
|
|
ede591c89e | ||
|
|
2defd7a4a9 |
32
.gitea/workflows/ci.yml
Normal file
32
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Gitea CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev, production]
|
||||
pull_request:
|
||||
branches: [main, dev, production]
|
||||
|
||||
jobs:
|
||||
test-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
|
||||
# Remove old images to force re-pull with correct architecture
|
||||
echo "🔄 Removing old images to force re-pull..."
|
||||
docker rmi postgres:15-alpine redis:7-alpine 2>/dev/null || true
|
||||
docker rmi postgres:16-alpine redis:7-alpine 2>/dev/null || true
|
||||
|
||||
# Ensure networks exist before compose starts (network is external)
|
||||
echo "🌐 Ensuring networks exist..."
|
||||
334
.github/workflows/ci-cd.yml
vendored
334
.github/workflows/ci-cd.yml
vendored
@@ -1,334 +0,0 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev, production]
|
||||
pull_request:
|
||||
branches: [main, dev, production]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Test Job (parallel)
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: self-hosted # Use your own server for speed!
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Create test environment file
|
||||
run: |
|
||||
cat > .env <<EOF
|
||||
NODE_ENV=test
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
MY_EMAIL=test@example.com
|
||||
MY_INFO_EMAIL=test@example.com
|
||||
MY_PASSWORD=test
|
||||
MY_INFO_PASSWORD=test
|
||||
NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=b3665829-927a-4ada-b9bb-fcf24171061e
|
||||
ADMIN_BASIC_AUTH=admin:test
|
||||
LOG_LEVEL=info
|
||||
EOF
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Build application
|
||||
run: npm run build
|
||||
|
||||
# Security scan (parallel)
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: self-hosted # Use your own server for speed!
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
skip-version-check: true
|
||||
scanners: 'vuln,secret,config'
|
||||
|
||||
- name: Upload Trivy scan results as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: trivy-security-report
|
||||
path: trivy-results.sarif
|
||||
retention-days: 30
|
||||
|
||||
# Build and push Docker image
|
||||
build:
|
||||
name: Build and Push Docker Image
|
||||
runs-on: self-hosted # Use your own server for speed!
|
||||
needs: [test, security] # Wait for parallel jobs to complete
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/production')
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix={{branch}}-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=staging,enable={{is_default_branch==false && branch=='dev'}}
|
||||
type=raw,value=staging,enable={{is_default_branch==false && branch=='main'}}
|
||||
|
||||
- name: Create production environment file
|
||||
run: |
|
||||
cat > .env <<EOF
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=${{ vars.NEXT_PUBLIC_BASE_URL }}
|
||||
MY_EMAIL=${{ vars.MY_EMAIL }}
|
||||
MY_INFO_EMAIL=${{ vars.MY_INFO_EMAIL }}
|
||||
MY_PASSWORD=${{ secrets.MY_PASSWORD }}
|
||||
MY_INFO_PASSWORD=${{ secrets.MY_INFO_PASSWORD }}
|
||||
NEXT_PUBLIC_UMAMI_URL=${{ vars.NEXT_PUBLIC_UMAMI_URL }}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
ADMIN_BASIC_AUTH=${{ secrets.ADMIN_BASIC_AUTH }}
|
||||
LOG_LEVEL=info
|
||||
EOF
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64 # Only AMD64 for speed
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# Optimize for speed
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
|
||||
# Deploy to staging (dev/main branches)
|
||||
deploy-staging:
|
||||
name: Deploy to Staging
|
||||
runs-on: self-hosted
|
||||
needs: build
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||
environment: staging
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy staging to server
|
||||
run: |
|
||||
# Set deployment variables
|
||||
export IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging"
|
||||
export CONTAINER_NAME="portfolio-app-staging"
|
||||
export COMPOSE_FILE="docker-compose.staging.yml"
|
||||
|
||||
# Set environment variables for docker-compose
|
||||
export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL_STAGING || vars.NEXT_PUBLIC_BASE_URL }}"
|
||||
export MY_EMAIL="${{ vars.MY_EMAIL }}"
|
||||
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
|
||||
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
|
||||
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
|
||||
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
|
||||
|
||||
# Pull latest staging image
|
||||
docker pull $IMAGE_NAME || docker pull "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main" || true
|
||||
|
||||
# Stop and remove old staging container (if exists)
|
||||
docker compose -f $COMPOSE_FILE down || true
|
||||
|
||||
# Start new staging container
|
||||
docker compose -f $COMPOSE_FILE up -d --force-recreate
|
||||
|
||||
# Wait for health check
|
||||
echo "Waiting for staging application to be healthy..."
|
||||
for i in {1..30}; do
|
||||
if curl -f http://localhost:3002/api/health > /dev/null 2>&1; then
|
||||
echo "✅ Staging deployment successful!"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Verify deployment
|
||||
if curl -f http://localhost:3002/api/health; then
|
||||
echo "✅ Staging deployment verified!"
|
||||
else
|
||||
echo "⚠️ Staging health check failed, but container is running"
|
||||
docker compose -f $COMPOSE_FILE logs --tail=50
|
||||
fi
|
||||
|
||||
# Deploy to production
|
||||
deploy:
|
||||
name: Deploy to Production
|
||||
runs-on: self-hosted
|
||||
needs: build
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/production'
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to production (zero-downtime)
|
||||
run: |
|
||||
# Set deployment variables
|
||||
export IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production"
|
||||
export CONTAINER_NAME="portfolio-app"
|
||||
export COMPOSE_FILE="docker-compose.production.yml"
|
||||
export BACKUP_CONTAINER="portfolio-app-backup"
|
||||
|
||||
# Set environment variables for docker-compose
|
||||
export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}"
|
||||
export MY_EMAIL="${{ vars.MY_EMAIL }}"
|
||||
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
|
||||
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
|
||||
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
|
||||
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
|
||||
|
||||
# Pull latest production image
|
||||
echo "📦 Pulling latest production image..."
|
||||
docker pull $IMAGE_NAME
|
||||
|
||||
# Check if production container is running
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo "🔄 Production container is running - performing zero-downtime deployment..."
|
||||
|
||||
# Start new container with different name first (blue-green)
|
||||
echo "🚀 Starting new container (green)..."
|
||||
docker run -d \
|
||||
--name ${BACKUP_CONTAINER} \
|
||||
--network portfolio_net \
|
||||
-p 3002:3000 \
|
||||
-e NODE_ENV=production \
|
||||
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
|
||||
-e REDIS_URL=redis://redis:6379 \
|
||||
-e NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}" \
|
||||
-e MY_EMAIL="${{ vars.MY_EMAIL }}" \
|
||||
-e MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}" \
|
||||
-e MY_PASSWORD="${{ secrets.MY_PASSWORD }}" \
|
||||
-e MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" \
|
||||
-e ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" \
|
||||
$IMAGE_NAME || true
|
||||
|
||||
# Wait for new container to be healthy
|
||||
echo "⏳ Waiting for new container to be healthy..."
|
||||
for i in {1..30}; do
|
||||
if curl -f http://localhost:3002/api/health > /dev/null 2>&1; then
|
||||
echo "✅ New container is healthy!"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Stop old container
|
||||
echo "🛑 Stopping old container..."
|
||||
docker stop ${CONTAINER_NAME} || true
|
||||
|
||||
# Remove old container
|
||||
docker rm ${CONTAINER_NAME} || true
|
||||
|
||||
# Rename new container to production name
|
||||
docker rename ${BACKUP_CONTAINER} ${CONTAINER_NAME}
|
||||
|
||||
# Update port mapping (requires container restart, but it's already healthy)
|
||||
docker stop ${CONTAINER_NAME}
|
||||
docker rm ${CONTAINER_NAME}
|
||||
|
||||
# Start with correct port using docker-compose
|
||||
docker compose -f $COMPOSE_FILE up -d --force-recreate
|
||||
else
|
||||
echo "🆕 No existing container - starting fresh deployment..."
|
||||
docker compose -f $COMPOSE_FILE up -d --force-recreate
|
||||
fi
|
||||
|
||||
# Wait for health check
|
||||
echo "⏳ Waiting for production application to be healthy..."
|
||||
for i in {1..30}; do
|
||||
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||
echo "✅ Production deployment successful!"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Verify deployment
|
||||
if curl -f http://localhost:3000/api/health; then
|
||||
echo "✅ Production deployment verified!"
|
||||
else
|
||||
echo "❌ Production deployment failed!"
|
||||
docker compose -f $COMPOSE_FILE logs --tail=100
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Cleanup backup container if it exists
|
||||
docker rm -f ${BACKUP_CONTAINER} 2>/dev/null || true
|
||||
|
||||
- name: Cleanup old images
|
||||
run: |
|
||||
# Remove unused images older than 7 days
|
||||
docker image prune -f --filter "until=168h"
|
||||
|
||||
# Remove unused containers
|
||||
docker container prune -f
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,9 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Local tooling
|
||||
.claude/
|
||||
._*
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
||||
@@ -46,13 +46,7 @@ export default function ProjectDetailClient({
|
||||
setCanGoBack(true);
|
||||
}
|
||||
|
||||
try {
|
||||
navigator.sendBeacon?.(
|
||||
"/api/analytics/track",
|
||||
new Blob([JSON.stringify({ type: "pageview", projectId: project.id.toString(), page: `/${locale}/projects/${project.slug}` })], { type: "application/json" }),
|
||||
);
|
||||
} catch {}
|
||||
}, [project.id, project.slug, locale]);
|
||||
}, []);
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma, projectService } from '@/lib/prisma';
|
||||
import { analyticsCache } from '@/lib/redis';
|
||||
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting - more generous for admin dashboard
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getRateLimitHeaders(ip, 20, 60000)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Admin-only endpoint: require explicit admin header AND a valid signed session token
|
||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Check cache first (but allow bypass with cache-bust parameter)
|
||||
const url = new URL(request.url);
|
||||
const bypassCache = url.searchParams.get('nocache') === 'true';
|
||||
|
||||
if (!bypassCache) {
|
||||
const cachedStats = await analyticsCache.getOverallStats();
|
||||
if (cachedStats) {
|
||||
return NextResponse.json(cachedStats);
|
||||
}
|
||||
}
|
||||
|
||||
// Get analytics data
|
||||
const projectsResult = await projectService.getAllProjects();
|
||||
const projects = projectsResult.projects || projectsResult;
|
||||
const performanceStats = await projectService.getPerformanceStats();
|
||||
|
||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Use DB aggregation instead of loading every PageView row into memory
|
||||
const [totalViews, sessionsByIp, viewsByProjectRows] = await Promise.all([
|
||||
prisma.pageView.count({ where: { timestamp: { gte: since } } }),
|
||||
prisma.pageView.groupBy({
|
||||
by: ['ip'],
|
||||
where: {
|
||||
timestamp: { gte: since },
|
||||
ip: { not: null },
|
||||
},
|
||||
_count: { _all: true },
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
prisma.pageView.groupBy({
|
||||
by: ['projectId'],
|
||||
where: {
|
||||
timestamp: { gte: since },
|
||||
projectId: { not: null },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalSessions = sessionsByIp.length;
|
||||
const bouncedSessions = sessionsByIp.filter(s => (s as unknown as { _count?: { _all?: number } })._count?._all === 1).length;
|
||||
const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0;
|
||||
|
||||
const sessionDurationsMs = sessionsByIp
|
||||
.map(s => {
|
||||
const count = (s as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
|
||||
if (count < 2) return 0;
|
||||
const minTs = (s as unknown as { _min?: { timestamp?: Date | null } })._min?.timestamp;
|
||||
const maxTs = (s as unknown as { _max?: { timestamp?: Date | null } })._max?.timestamp;
|
||||
if (!minTs || !maxTs) return 0;
|
||||
return maxTs.getTime() - minTs.getTime();
|
||||
})
|
||||
.filter(ms => ms > 0);
|
||||
|
||||
const avgSessionDuration = sessionDurationsMs.length > 0
|
||||
? Math.round(sessionDurationsMs.reduce((a, b) => a + b, 0) / sessionDurationsMs.length / 1000)
|
||||
: 0;
|
||||
|
||||
const totalUsers = totalSessions;
|
||||
|
||||
const viewsByProject = viewsByProjectRows.reduce((acc, row) => {
|
||||
const projectId = row.projectId as number | null;
|
||||
if (projectId != null) {
|
||||
acc[projectId] = (row as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<number, number>);
|
||||
|
||||
// Calculate analytics metrics
|
||||
const analytics = {
|
||||
overview: {
|
||||
totalProjects: projects.length,
|
||||
publishedProjects: projects.filter(p => p.published).length,
|
||||
featuredProjects: projects.filter(p => p.featured).length,
|
||||
totalViews, // Real views from PageView table
|
||||
totalLikes: 0, // Not implemented - no like buttons
|
||||
totalShares: 0, // Not implemented - no share buttons
|
||||
avgLighthouse: (() => {
|
||||
// Only calculate if we have real performance data (not defaults)
|
||||
const projectsWithPerf = projects.filter(p => {
|
||||
const perf = (p.performance as Record<string, unknown>) || {};
|
||||
const lighthouse = perf.lighthouse as number || 0;
|
||||
return lighthouse > 0; // Only count projects with actual performance data
|
||||
});
|
||||
return projectsWithPerf.length > 0
|
||||
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
|
||||
: 0;
|
||||
})()
|
||||
},
|
||||
projects: projects.map(project => ({
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
category: project.category,
|
||||
difficulty: project.difficulty,
|
||||
views: viewsByProject[project.id] || 0, // Only real views from PageView table
|
||||
likes: 0, // Not implemented
|
||||
shares: 0, // Not implemented
|
||||
lighthouse: (() => {
|
||||
const perf = (project.performance as Record<string, unknown>) || {};
|
||||
const score = perf.lighthouse as number || 0;
|
||||
return score > 0 ? score : 0; // Only return if we have real data
|
||||
})(),
|
||||
published: project.published,
|
||||
featured: project.featured,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt
|
||||
})),
|
||||
categories: performanceStats.byCategory,
|
||||
difficulties: performanceStats.byDifficulty,
|
||||
performance: {
|
||||
avgLighthouse: (() => {
|
||||
const projectsWithPerf = projects.filter(p => {
|
||||
const perf = (p.performance as Record<string, unknown>) || {};
|
||||
return (perf.lighthouse as number || 0) > 0;
|
||||
});
|
||||
return projectsWithPerf.length > 0
|
||||
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
|
||||
: 0;
|
||||
})(),
|
||||
totalViews, // Real total views
|
||||
totalLikes: 0,
|
||||
totalShares: 0
|
||||
},
|
||||
metrics: {
|
||||
bounceRate,
|
||||
avgSessionDuration,
|
||||
pagesPerSession: totalSessions > 0 ? (totalViews / totalSessions).toFixed(1) : '0',
|
||||
newUsers: totalUsers,
|
||||
totalUsers
|
||||
}
|
||||
};
|
||||
|
||||
// Cache the results
|
||||
await analyticsCache.setOverallStats(analytics);
|
||||
|
||||
return NextResponse.json(analytics);
|
||||
} catch (error) {
|
||||
console.error('Analytics dashboard error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch analytics data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireSessionAuth } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Admin-only endpoint
|
||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Get performance data from database
|
||||
const pageViews = await prisma.pageView.findMany({
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 1000 // Last 1000 page views
|
||||
});
|
||||
|
||||
const userInteractions = await prisma.userInteraction.findMany({
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 1000 // Last 1000 interactions
|
||||
});
|
||||
|
||||
// Get all projects for performance data
|
||||
const projects = await prisma.project.findMany();
|
||||
|
||||
// Calculate real performance metrics from projects
|
||||
const projectsWithPerformance = projects.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
lighthouse: ((p.performance as Record<string, unknown>)?.lighthouse as number) || 0,
|
||||
loadTime: ((p.performance as Record<string, unknown>)?.loadTime as number) || 0,
|
||||
fcp: ((p.performance as Record<string, unknown>)?.firstContentfulPaint as number) || 0,
|
||||
lcp: ((p.performance as Record<string, unknown>)?.coreWebVitals as Record<string, unknown>)?.lcp as number || 0,
|
||||
cls: ((p.performance as Record<string, unknown>)?.coreWebVitals as Record<string, unknown>)?.cls as number || 0
|
||||
}));
|
||||
|
||||
// Calculate average lighthouse score (currently unused but kept for future use)
|
||||
const _avgLighthouse = projectsWithPerformance.length > 0
|
||||
? Math.round(projectsWithPerformance.reduce((sum, p) => sum + p.lighthouse, 0) / projectsWithPerformance.length)
|
||||
: 0;
|
||||
|
||||
// Calculate bounce rate from page views
|
||||
const pageViewsByIP = pageViews.reduce((acc, pv) => {
|
||||
const ip = pv.ip || 'unknown';
|
||||
if (!acc[ip]) acc[ip] = [];
|
||||
acc[ip].push(pv);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof pageViews>);
|
||||
|
||||
const totalSessions = Object.keys(pageViewsByIP).length;
|
||||
const bouncedSessions = Object.values(pageViewsByIP).filter(session => session.length === 1).length;
|
||||
const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0;
|
||||
|
||||
// Calculate average session duration
|
||||
const sessionDurations = Object.values(pageViewsByIP)
|
||||
.map(session => {
|
||||
if (session.length < 2) return 0;
|
||||
const sorted = session.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
return sorted[sorted.length - 1].timestamp.getTime() - sorted[0].timestamp.getTime();
|
||||
})
|
||||
.filter(d => d > 0);
|
||||
const avgSessionDuration = sessionDurations.length > 0
|
||||
? Math.round(sessionDurations.reduce((a, b) => a + b, 0) / sessionDurations.length / 1000) // in seconds
|
||||
: 0;
|
||||
|
||||
// Calculate pages per session
|
||||
const pagesPerSession = totalSessions > 0 ? (pageViews.length / totalSessions).toFixed(1) : '0';
|
||||
|
||||
// Calculate performance metrics
|
||||
const performance = {
|
||||
avgLighthouse: (() => {
|
||||
const projectsWithPerf = projects.filter(p => {
|
||||
const perf = (p.performance as Record<string, unknown>) || {};
|
||||
return (perf.lighthouse as number || 0) > 0;
|
||||
});
|
||||
return projectsWithPerf.length > 0
|
||||
? Math.round(projectsWithPerf.reduce((sum, p) => {
|
||||
const perf = (p.performance as Record<string, unknown>) || {};
|
||||
return sum + (perf.lighthouse as number || 0);
|
||||
}, 0) / projectsWithPerf.length)
|
||||
: 0;
|
||||
})(),
|
||||
totalViews: pageViews.length,
|
||||
metrics: {
|
||||
bounceRate,
|
||||
avgSessionDuration: avgSessionDuration,
|
||||
pagesPerSession: parseFloat(pagesPerSession),
|
||||
newUsers: new Set(pageViews.map(pv => pv.ip).filter(Boolean)).size
|
||||
},
|
||||
pageViews: {
|
||||
total: pageViews.length,
|
||||
last24h: pageViews.filter(pv => {
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
return new Date(pv.timestamp) > dayAgo;
|
||||
}).length,
|
||||
last7d: pageViews.filter(pv => {
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
return new Date(pv.timestamp) > weekAgo;
|
||||
}).length,
|
||||
last30d: pageViews.filter(pv => {
|
||||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
return new Date(pv.timestamp) > monthAgo;
|
||||
}).length
|
||||
},
|
||||
interactions: {
|
||||
total: userInteractions.length,
|
||||
last24h: userInteractions.filter(ui => {
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
return new Date(ui.timestamp) > dayAgo;
|
||||
}).length,
|
||||
last7d: userInteractions.filter(ui => {
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
return new Date(ui.timestamp) > weekAgo;
|
||||
}).length,
|
||||
last30d: userInteractions.filter(ui => {
|
||||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
return new Date(ui.timestamp) > monthAgo;
|
||||
}).length
|
||||
},
|
||||
topPages: pageViews.reduce((acc, pv) => {
|
||||
acc[pv.page] = (acc[pv.page] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
topInteractions: userInteractions.reduce((acc, ui) => {
|
||||
acc[ui.type] = (acc[ui.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)
|
||||
};
|
||||
|
||||
return NextResponse.json(performance);
|
||||
} catch (error) {
|
||||
console.error('Performance analytics error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch performance data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { analyticsCache } from '@/lib/redis';
|
||||
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
if (!checkRateLimit(ip, 3, 300000)) { // 3 requests per 5 minutes - more restrictive for reset
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getRateLimitHeaders(ip, 3, 300000)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Check admin authentication
|
||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { type } = await request.json();
|
||||
|
||||
switch (type) {
|
||||
case 'analytics':
|
||||
// Reset all project analytics (view counts in project.analytics JSON)
|
||||
const projects = await prisma.project.findMany();
|
||||
for (const project of projects) {
|
||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
||||
await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
analytics: {
|
||||
...analytics,
|
||||
views: 0,
|
||||
likes: 0,
|
||||
shares: 0,
|
||||
comments: 0,
|
||||
bookmarks: 0,
|
||||
clickThroughs: 0,
|
||||
bounceRate: 0,
|
||||
avgTimeOnPage: 0,
|
||||
uniqueVisitors: 0,
|
||||
returningVisitors: 0,
|
||||
conversionRate: 0,
|
||||
socialShares: {
|
||||
twitter: 0,
|
||||
linkedin: 0,
|
||||
facebook: 0,
|
||||
github: 0
|
||||
},
|
||||
deviceStats: {
|
||||
mobile: 0,
|
||||
desktop: 0,
|
||||
tablet: 0
|
||||
},
|
||||
locationStats: {},
|
||||
referrerStats: {},
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pageviews':
|
||||
// Clear PageView table
|
||||
await prisma.pageView.deleteMany({});
|
||||
break;
|
||||
|
||||
case 'interactions':
|
||||
// Clear UserInteraction table
|
||||
await prisma.userInteraction.deleteMany({});
|
||||
break;
|
||||
|
||||
case 'performance':
|
||||
// Reset performance metrics (preserve structure)
|
||||
const projectsForPerf = await prisma.project.findMany();
|
||||
for (const project of projectsForPerf) {
|
||||
const perf = (project.performance as Record<string, unknown>) || {};
|
||||
await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
performance: {
|
||||
...perf,
|
||||
lighthouse: 0,
|
||||
loadTime: 0,
|
||||
firstContentfulPaint: 0,
|
||||
largestContentfulPaint: 0,
|
||||
cumulativeLayoutShift: 0,
|
||||
totalBlockingTime: 0,
|
||||
speedIndex: 0,
|
||||
accessibility: 0,
|
||||
bestPractices: 0,
|
||||
seo: 0,
|
||||
performanceScore: 0,
|
||||
mobileScore: 0,
|
||||
desktopScore: 0,
|
||||
coreWebVitals: {
|
||||
lcp: 0,
|
||||
fid: 0,
|
||||
cls: 0
|
||||
},
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'all':
|
||||
// Reset everything
|
||||
const allProjects = await prisma.project.findMany();
|
||||
await Promise.all([
|
||||
// Reset analytics and performance for each project (preserve structure)
|
||||
...allProjects.map(project => {
|
||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
||||
const perf = (project.performance as Record<string, unknown>) || {};
|
||||
return prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
analytics: {
|
||||
...analytics,
|
||||
views: 0,
|
||||
likes: 0,
|
||||
shares: 0,
|
||||
comments: 0,
|
||||
bookmarks: 0,
|
||||
clickThroughs: 0,
|
||||
bounceRate: 0,
|
||||
avgTimeOnPage: 0,
|
||||
uniqueVisitors: 0,
|
||||
returningVisitors: 0,
|
||||
conversionRate: 0,
|
||||
socialShares: {
|
||||
twitter: 0,
|
||||
linkedin: 0,
|
||||
facebook: 0,
|
||||
github: 0
|
||||
},
|
||||
deviceStats: {
|
||||
mobile: 0,
|
||||
desktop: 0,
|
||||
tablet: 0
|
||||
},
|
||||
locationStats: {},
|
||||
referrerStats: {},
|
||||
lastUpdated: new Date().toISOString()
|
||||
},
|
||||
performance: {
|
||||
...perf,
|
||||
lighthouse: 0,
|
||||
loadTime: 0,
|
||||
firstContentfulPaint: 0,
|
||||
largestContentfulPaint: 0,
|
||||
cumulativeLayoutShift: 0,
|
||||
totalBlockingTime: 0,
|
||||
speedIndex: 0,
|
||||
accessibility: 0,
|
||||
bestPractices: 0,
|
||||
seo: 0,
|
||||
performanceScore: 0,
|
||||
mobileScore: 0,
|
||||
desktopScore: 0,
|
||||
coreWebVitals: {
|
||||
lcp: 0,
|
||||
fid: 0,
|
||||
cls: 0
|
||||
},
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}),
|
||||
// Clear tracking tables
|
||||
prisma.pageView.deleteMany({}),
|
||||
prisma.userInteraction.deleteMany({})
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid reset type. Use: analytics, pageviews, interactions, performance, or all' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
await analyticsCache.clearAll();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Successfully reset ${type} data`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Analytics reset error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reset analytics data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting for POST requests
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
if (!checkRateLimit(ip, 30, 60000)) { // 30 requests per minute for analytics
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getRateLimitHeaders(ip, 30, 60000)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Log performance metrics (you can extend this to store in database)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Performance Metric:', {
|
||||
timestamp: new Date().toISOString(),
|
||||
...body,
|
||||
});
|
||||
}
|
||||
|
||||
// You could store this in a database or send to external service
|
||||
// For now, we'll just log it since Umami handles the main analytics
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Analytics API Error:', error);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process analytics data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
message: 'Analytics API is running',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
if (!checkRateLimit(ip, 100, 60000)) { // 100 requests per minute for tracking
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getRateLimitHeaders(ip, 100, 60000)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { type, projectId, page, performance, session } = body;
|
||||
const userAgent = request.headers.get('user-agent') || undefined;
|
||||
const referrer = request.headers.get('referer') || undefined;
|
||||
|
||||
// Track page view
|
||||
if (type === 'pageview' && page) {
|
||||
let projectIdNum: number | null = null;
|
||||
if (projectId != null) {
|
||||
const raw = projectId.toString();
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
projectIdNum = parsed;
|
||||
} else {
|
||||
const bySlug = await prisma.project.findFirst({
|
||||
where: { slug: raw },
|
||||
select: { id: true },
|
||||
});
|
||||
projectIdNum = bySlug?.id ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create page view record
|
||||
await prisma.pageView.create({
|
||||
data: {
|
||||
projectId: projectIdNum,
|
||||
page,
|
||||
ip,
|
||||
userAgent,
|
||||
referrer
|
||||
}
|
||||
});
|
||||
|
||||
// Update project analytics if projectId exists
|
||||
if (projectIdNum) {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectIdNum }
|
||||
});
|
||||
|
||||
if (project) {
|
||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
||||
const currentViews = (analytics.views as number) || 0;
|
||||
|
||||
await prisma.project.update({
|
||||
where: { id: projectIdNum },
|
||||
data: {
|
||||
analytics: {
|
||||
...analytics,
|
||||
views: currentViews + 1,
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track performance metrics
|
||||
if (type === 'performance' && performance) {
|
||||
// Try to get projectId from page path if not provided
|
||||
let projectIdNum: number | null = null;
|
||||
if (projectId) {
|
||||
projectIdNum = parseInt(projectId.toString());
|
||||
} else if (page) {
|
||||
// Try to extract from page path like /projects/123 or /projects/slug
|
||||
const match = page.match(/\/projects\/(\d+)/);
|
||||
if (match) {
|
||||
projectIdNum = parseInt(match[1]);
|
||||
} else {
|
||||
// Try to find by slug
|
||||
const slugMatch = page.match(/\/projects\/([^\/]+)/);
|
||||
if (slugMatch) {
|
||||
const slug = slugMatch[1];
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: parseInt(slug) || 0 },
|
||||
{ slug }
|
||||
]
|
||||
}
|
||||
});
|
||||
if (project) projectIdNum = project.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (projectIdNum) {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectIdNum }
|
||||
});
|
||||
|
||||
if (project) {
|
||||
const perf = (project.performance as Record<string, unknown>) || {};
|
||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
||||
|
||||
// Calculate lighthouse score from web vitals
|
||||
const lcp = performance.lcp || 0;
|
||||
const fid = performance.fid || 0;
|
||||
const cls = performance.cls || 0;
|
||||
const fcp = performance.fcp || 0;
|
||||
const ttfb = performance.ttfb || 0;
|
||||
|
||||
// Only calculate lighthouse score if we have real web vitals data
|
||||
// Check if we have at least LCP and FCP (most important metrics)
|
||||
if (lcp > 0 || fcp > 0) {
|
||||
// Simple lighthouse score calculation (0-100)
|
||||
let lighthouseScore = 100;
|
||||
if (lcp > 4000) lighthouseScore -= 25;
|
||||
else if (lcp > 2500) lighthouseScore -= 15;
|
||||
if (fid > 300) lighthouseScore -= 25;
|
||||
else if (fid > 100) lighthouseScore -= 15;
|
||||
if (cls > 0.25) lighthouseScore -= 25;
|
||||
else if (cls > 0.1) lighthouseScore -= 15;
|
||||
if (fcp > 3000) lighthouseScore -= 15;
|
||||
if (ttfb > 800) lighthouseScore -= 10;
|
||||
|
||||
lighthouseScore = Math.max(0, Math.min(100, lighthouseScore));
|
||||
|
||||
await prisma.project.update({
|
||||
where: { id: projectIdNum },
|
||||
data: {
|
||||
performance: {
|
||||
...perf,
|
||||
lighthouse: lighthouseScore,
|
||||
loadTime: performance.loadTime || perf.loadTime || 0,
|
||||
firstContentfulPaint: fcp || perf.firstContentfulPaint || 0,
|
||||
largestContentfulPaint: lcp || perf.largestContentfulPaint || 0,
|
||||
cumulativeLayoutShift: cls || perf.cumulativeLayoutShift || 0,
|
||||
totalBlockingTime: performance.tbt || perf.totalBlockingTime || 0,
|
||||
speedIndex: performance.si || perf.speedIndex || 0,
|
||||
coreWebVitals: {
|
||||
lcp: lcp || (perf.coreWebVitals as Record<string, unknown>)?.lcp || 0,
|
||||
fid: fid || (perf.coreWebVitals as Record<string, unknown>)?.fid || 0,
|
||||
cls: cls || (perf.coreWebVitals as Record<string, unknown>)?.cls || 0
|
||||
},
|
||||
lastUpdated: new Date().toISOString()
|
||||
},
|
||||
analytics: {
|
||||
...analytics,
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track session data (for bounce rate calculation)
|
||||
if (type === 'session' && session) {
|
||||
// Store session data in a way that allows bounce rate calculation
|
||||
// A bounce is a session with only one pageview
|
||||
// We'll track this via PageView records and calculate bounce rate from them
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Analytics tracking error:', error);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to track analytics' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { getBookReviews } from '@/lib/directus';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* GET /api/book-reviews
|
||||
@@ -31,25 +33,23 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (reviews && reviews.length > 0) {
|
||||
return NextResponse.json({
|
||||
bookReviews: reviews,
|
||||
source: 'directus'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ bookReviews: reviews, source: 'directus' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
bookReviews: null,
|
||||
source: 'fallback'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ bookReviews: null, source: 'fallback' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error loading book reviews:', error);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
bookReviews: null,
|
||||
error: 'Failed to load book reviews',
|
||||
source: 'error'
|
||||
},
|
||||
{ bookReviews: null, error: 'Failed to load book reviews', source: 'error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getContentByKey } from "@/lib/content";
|
||||
import { getContentPage } from "@/lib/directus";
|
||||
|
||||
const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get("key");
|
||||
@@ -15,7 +17,8 @@ export async function GET(request: NextRequest) {
|
||||
// 1) Try Directus first
|
||||
const directusPage = await getContentPage(key, locale);
|
||||
if (directusPage) {
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
content: {
|
||||
title: directusPage.title,
|
||||
slug: directusPage.slug,
|
||||
@@ -23,13 +26,23 @@ export async function GET(request: NextRequest) {
|
||||
content: directusPage.content,
|
||||
},
|
||||
source: "directus",
|
||||
});
|
||||
},
|
||||
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
}
|
||||
|
||||
// 2) Fallback: PostgreSQL
|
||||
const translation = await getContentByKey({ key, locale });
|
||||
if (!translation) return NextResponse.json({ content: null });
|
||||
return NextResponse.json({ content: translation, source: "postgresql" });
|
||||
if (!translation) {
|
||||
return NextResponse.json(
|
||||
{ content: null },
|
||||
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ content: translation, source: "postgresql" },
|
||||
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
} catch (error) {
|
||||
// If DB isn't migrated/available, fail soft so the UI can fall back to next-intl strings.
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { getHobbies } from '@/lib/directus';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* GET /api/hobbies
|
||||
@@ -28,26 +30,24 @@ export async function GET(request: NextRequest) {
|
||||
const hobbies = await getHobbies(locale);
|
||||
|
||||
if (hobbies && hobbies.length > 0) {
|
||||
return NextResponse.json({
|
||||
hobbies,
|
||||
source: 'directus'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ hobbies, source: 'directus' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: return empty (component will use hardcoded fallback)
|
||||
return NextResponse.json({
|
||||
hobbies: null,
|
||||
source: 'fallback'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ hobbies: null, source: 'fallback' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error loading hobbies:', error);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
hobbies: null,
|
||||
error: 'Failed to load hobbies',
|
||||
source: 'error'
|
||||
},
|
||||
{ hobbies: null, error: 'Failed to load hobbies', source: 'error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getMessages } from "@/lib/directus";
|
||||
|
||||
const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get("locale") || "en";
|
||||
|
||||
try {
|
||||
const messages = await getMessages(locale);
|
||||
return NextResponse.json({ messages });
|
||||
return NextResponse.json(
|
||||
{ messages },
|
||||
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json({ messages: {} }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
|
||||
: ip;
|
||||
const maxPerMinute = process.env.NODE_ENV === "development" ? 60 : 10;
|
||||
|
||||
if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute
|
||||
if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-reading')) { // requests per minute
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 }
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
|
||||
: ip;
|
||||
const maxPerMinute = process.env.NODE_ENV === "development" ? 300 : 30;
|
||||
|
||||
if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute
|
||||
if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-status')) { // requests per minute
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSnippets } from '@/lib/directus';
|
||||
|
||||
const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -9,9 +11,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const snippets = await getSnippets(limit, featured);
|
||||
|
||||
return NextResponse.json({
|
||||
snippets: snippets || []
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ snippets: snippets || [] },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
} catch (_error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch snippets' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { getTechStack } from '@/lib/directus';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* GET /api/tech-stack
|
||||
@@ -28,26 +30,24 @@ export async function GET(request: NextRequest) {
|
||||
const techStack = await getTechStack(locale);
|
||||
|
||||
if (techStack && techStack.length > 0) {
|
||||
return NextResponse.json({
|
||||
techStack,
|
||||
source: 'directus'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ techStack, source: 'directus' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: return empty (component will use hardcoded fallback)
|
||||
return NextResponse.json({
|
||||
techStack: null,
|
||||
source: 'fallback'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ techStack: null, source: 'fallback' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error loading tech stack:', error);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
techStack: null,
|
||||
error: 'Failed to load tech stack',
|
||||
source: 'error'
|
||||
},
|
||||
{ techStack: null, error: 'Failed to load tech stack', source: 'error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useState, useEffect } from "react";
|
||||
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import type { JSONContent } from "@tiptap/react";
|
||||
import RichTextClient from "./RichTextClient";
|
||||
import dynamic from "next/dynamic";
|
||||
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
||||
import CurrentlyReading from "./CurrentlyReading";
|
||||
import ReadBooks from "./ReadBooks";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
@@ -28,18 +29,17 @@ const About = () => {
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [cmsRes, techRes, hobbiesRes, msgRes, booksRes, snippetsRes] = await Promise.all([
|
||||
const [cmsRes, techRes, hobbiesRes, msgRes, snippetsRes] = await Promise.all([
|
||||
fetch(`/api/content/page?key=home-about&locale=${locale}`),
|
||||
fetch(`/api/tech-stack?locale=${locale}`),
|
||||
fetch(`/api/hobbies?locale=${locale}`),
|
||||
fetch(`/api/messages?locale=${locale}`),
|
||||
fetch(`/api/book-reviews?locale=${locale}`),
|
||||
fetch(`/api/snippets?limit=3&featured=true`)
|
||||
]);
|
||||
|
||||
@@ -57,9 +57,6 @@ const About = () => {
|
||||
|
||||
const snippetsData = await snippetsRes.json();
|
||||
if (snippetsData?.snippets) setSnippets(snippetsData.snippets);
|
||||
|
||||
await booksRes.json();
|
||||
// Books data is available but we don't need to track count anymore
|
||||
} catch (error) {
|
||||
console.error("About data fetch failed:", error);
|
||||
} finally {
|
||||
@@ -90,7 +87,7 @@ const About = () => {
|
||||
>
|
||||
<div className="space-y-5 sm:space-y-6 md:space-y-8">
|
||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
|
||||
{t("title")}<span className="text-liquid-mint">.</span>
|
||||
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||
</h2>
|
||||
<div className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||
{isLoading ? (
|
||||
@@ -107,7 +104,7 @@ const About = () => {
|
||||
</div>
|
||||
<div className="pt-4 sm:pt-6 md:pt-8">
|
||||
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-2xl sm:rounded-3xl border border-stone-100 dark:border-stone-700">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-mint mb-1 sm:mb-2">{t("funFactTitle")}</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400 mb-1 sm:mb-2">{t("funFactTitle")}</p>
|
||||
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,7 +123,7 @@ const About = () => {
|
||||
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
|
||||
<Activity size={20} /> Status
|
||||
</h3>
|
||||
<ActivityFeed idleQuote={cmsMessages["about.quote.idle"]} locale={locale} />
|
||||
<ActivityFeed locale={locale} />
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
|
||||
</motion.div>
|
||||
@@ -171,7 +168,7 @@ const About = () => {
|
||||
) : (
|
||||
techStack.map((cat) => (
|
||||
<div key={cat.id} className="space-y-6">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">{cat.name}</h4>
|
||||
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{cat.name}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cat.items?.map((item: TechStackItem) => (
|
||||
<span key={item.id} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700/50 hover:border-liquid-mint transition-colors">
|
||||
@@ -267,16 +264,16 @@ const About = () => {
|
||||
onClick={() => setSelectedSnippet(s)}
|
||||
className="w-full text-left p-3 bg-stone-50 dark:bg-stone-800 rounded-xl border border-stone-100 dark:border-stone-700 hover:border-liquid-purple transition-all group/s"
|
||||
>
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400 mb-0.5 group-hover/s:text-liquid-purple transition-colors">{s.category}</p>
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-600 dark:text-stone-400 mb-0.5 group-hover/s:text-liquid-purple transition-colors">{s.category}</p>
|
||||
<p className="text-xs font-bold text-stone-800 dark:text-stone-200">{s.title}</p>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-stone-400 italic">No snippets yet.</p>
|
||||
<p className="text-xs text-stone-500 dark:text-stone-400 italic">No snippets yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/${locale}/snippets`} className="mt-6 group/btn inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">
|
||||
<Link href={`/${locale}/snippets`} className="mt-6 group/btn inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">
|
||||
Enter the Lab <ArrowRight size={12} className="group-hover/btn:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</motion.div>
|
||||
@@ -375,7 +372,7 @@ const About = () => {
|
||||
<div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
|
||||
<button
|
||||
onClick={() => setSelectedSnippet(null)}
|
||||
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
Close Laboratory
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Disc3, Gamepad2, Zap, Quote as QuoteIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
|
||||
interface CustomActivity {
|
||||
[key: string]: unknown;
|
||||
@@ -22,14 +23,28 @@ const techQuotes = {
|
||||
{ content: "Einfachheit ist die Voraussetzung für Verlässlichkeit.", author: "Edsger W. Dijkstra" },
|
||||
{ content: "Wenn Debugging der Prozess des Entfernens von Fehlern ist, dann muss Programmieren der Prozess des Einbauens sein.", author: "Edsger W. Dijkstra" },
|
||||
{ content: "Gelöschter Code ist gedebuggter Code.", author: "Jeff Sickel" },
|
||||
{ content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" }
|
||||
{ content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" },
|
||||
{ content: "Jedes Programm kann um mindestens einen Faktor zwei vereinfacht werden. Jedes Programm hat mindestens einen Bug.", author: "Kernighan's Law" },
|
||||
{ content: "Code lesen ist schwieriger als Code schreiben — deshalb schreibt jeder neu.", author: "Joel Spolsky" },
|
||||
{ content: "Die beste Performance-Optimierung ist der Übergang von nicht-funktionierend zu funktionierend.", author: "J. Osterhout" },
|
||||
{ content: "Mach es funktionierend, dann mach es schön, dann mach es schnell — in dieser Reihenfolge.", author: "Kent Beck" },
|
||||
{ content: "Software ist wie Entropie: Es ist schwer zu fassen, wiegt nichts und gehorcht dem zweiten Hauptsatz der Thermodynamik.", author: "Norman Augustine" },
|
||||
{ content: "Gute Software ist nicht die, die keine Bugs hat — sondern die, deren Bugs keine Rolle spielen.", author: "Bruce Eckel" },
|
||||
{ content: "Der einzige Weg, schnell zu gehen, ist, gut zu gehen.", author: "Robert C. Martin" },
|
||||
],
|
||||
en: [
|
||||
{ content: "Computer Science is no more about computers than astronomy is about telescopes.", author: "Edsger W. Dijkstra" },
|
||||
{ content: "Simplicity is prerequisite for reliability.", author: "Edsger W. Dijkstra" },
|
||||
{ content: "If debugging is the process of removing software bugs, then programming must be the process of putting them in.", author: "Edsger W. Dijkstra" },
|
||||
{ content: "Deleted code is debugged code.", author: "Jeff Sickel" },
|
||||
{ content: "First, solve the problem. Then, write the code.", author: "John Johnson" }
|
||||
{ content: "First, solve the problem. Then, write the code.", author: "John Johnson" },
|
||||
{ content: "Any program can be simplified by at least a factor of two. Every program has at least one bug.", author: "Kernighan's Law" },
|
||||
{ content: "It's harder to read code than to write it — that's why everyone rewrites.", author: "Joel Spolsky" },
|
||||
{ content: "The best performance optimization is the transition from a non-working state to a working state.", author: "J. Osterhout" },
|
||||
{ content: "Make it work, make it right, make it fast — in that order.", author: "Kent Beck" },
|
||||
{ content: "Software is like entropy: it is difficult to grasp, weighs nothing, and obeys the second law of thermodynamics.", author: "Norman Augustine" },
|
||||
{ content: "Good software isn't software with no bugs — it's software whose bugs don't matter.", author: "Bruce Eckel" },
|
||||
{ content: "The only way to go fast is to go well.", author: "Robert C. Martin" },
|
||||
]
|
||||
};
|
||||
|
||||
@@ -41,28 +56,18 @@ function getSafeGamingText(details: string | number | undefined, state: string |
|
||||
|
||||
export default function ActivityFeed({
|
||||
onActivityChange,
|
||||
idleQuote,
|
||||
locale = 'en'
|
||||
}: {
|
||||
onActivityChange?: (active: boolean) => void;
|
||||
idleQuote?: string;
|
||||
locale?: string;
|
||||
}) {
|
||||
const [data, setData] = useState<StatusData | null>(null);
|
||||
const [hasActivity, setHasActivity] = useState(false);
|
||||
const [quoteIndex, setQuoteIndex] = useState(0);
|
||||
const [quoteIndex, setQuoteIndex] = useState(() => Math.floor(Math.random() * (techQuotes[locale as keyof typeof techQuotes] || techQuotes.en).length));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const t = useTranslations("home.about.activity");
|
||||
|
||||
const currentQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en;
|
||||
|
||||
// Combine CMS quote with tech quotes if available
|
||||
const allQuotes = React.useMemo(() => {
|
||||
if (idleQuote) {
|
||||
return [{ content: idleQuote, author: "Dennis Konkol" }, ...currentQuotes];
|
||||
}
|
||||
return currentQuotes;
|
||||
}, [idleQuote, currentQuotes]);
|
||||
const allQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -92,16 +97,20 @@ export default function ActivityFeed({
|
||||
fetchData();
|
||||
const statusInterval = setInterval(fetchData, 30000);
|
||||
|
||||
// Cycle quotes every 10 seconds
|
||||
// Pick a random quote every 10 seconds (never the same one twice in a row)
|
||||
const quoteInterval = setInterval(() => {
|
||||
setQuoteIndex((prev) => (prev + 1) % allQuotes.length);
|
||||
setQuoteIndex((prev) => {
|
||||
let next;
|
||||
do { next = Math.floor(Math.random() * allQuotes.length); } while (next === prev && allQuotes.length > 1);
|
||||
return next;
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
return () => {
|
||||
clearInterval(statusInterval);
|
||||
clearInterval(quoteInterval);
|
||||
};
|
||||
}, [onActivityChange, allQuotes.length]);
|
||||
}, [onActivityChange]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse space-y-4">
|
||||
@@ -125,7 +134,7 @@ export default function ActivityFeed({
|
||||
transition={{ duration: 0.5 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-700 dark:text-stone-300 italic">
|
||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-300 italic">
|
||||
“{allQuotes[quoteIndex].content}”
|
||||
</p>
|
||||
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">
|
||||
@@ -164,7 +173,7 @@ export default function ActivityFeed({
|
||||
<div className="flex gap-4">
|
||||
{data.gaming.image && (
|
||||
<div className="w-12 h-12 rounded-xl overflow-hidden shrink-0 shadow-lg relative">
|
||||
<img src={data.gaming.image} alt={data.gaming.name} className="w-full h-full object-cover" />
|
||||
<Image src={data.gaming.image} alt={data.gaming.name} fill className="object-cover" sizes="48px" unoptimized />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex flex-col justify-center">
|
||||
@@ -207,10 +216,12 @@ export default function ActivityFeed({
|
||||
</div>
|
||||
<div className="flex gap-4 relative z-10">
|
||||
<div className="w-16 h-16 rounded-lg overflow-hidden shrink-0 shadow-md relative group-hover:shadow-xl transition-shadow duration-500">
|
||||
<img
|
||||
<Image
|
||||
src={data.music.albumArt}
|
||||
alt="Album Art"
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
sizes="64px"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col justify-center">
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function BentoChat() {
|
||||
placeholder="Ask me..."
|
||||
className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-all shadow-inner dark:text-white"
|
||||
/>
|
||||
<button onClick={handleSend} className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
|
||||
<button onClick={handleSend} aria-label="Send message" className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,7 @@ import { usePathname } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ToastProvider } from "@/components/Toast";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
||||
import { ConsentProvider, useConsent } from "./ConsentProvider";
|
||||
import { ConsentProvider } from "./ConsentProvider";
|
||||
import { ThemeProvider } from "./ThemeProvider";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
@@ -15,6 +14,11 @@ const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").cat
|
||||
loading: () => null,
|
||||
});
|
||||
|
||||
const ShaderGradientBackground = dynamic(
|
||||
() => import("./ShaderGradientBackground"),
|
||||
{ ssr: false, loading: () => null }
|
||||
);
|
||||
|
||||
export default function ClientProviders({
|
||||
children,
|
||||
}: {
|
||||
@@ -97,19 +101,13 @@ function GatedProviders({
|
||||
mounted: boolean;
|
||||
is404Page: boolean;
|
||||
}) {
|
||||
const { consent } = useConsent();
|
||||
|
||||
// If consent is not decided yet, treat optional features as off
|
||||
const analyticsEnabled = !!consent?.analytics;
|
||||
|
||||
const content = (
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
{mounted && <BackgroundBlobs />}
|
||||
{mounted && <ShaderGradientBackground />}
|
||||
<div className="relative z-10">{children}</div>
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
return analyticsEnabled ? <AnalyticsProvider>{content}</AnalyticsProvider> : content;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ConsentBanner() {
|
||||
const { consent, ready, setConsent } = useConsent();
|
||||
const [draft, setDraft] = useState<ConsentState>({ analytics: false, chat: false });
|
||||
const [draft, setDraft] = useState<ConsentState>({ chat: false });
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const t = useTranslations("consent");
|
||||
|
||||
@@ -19,7 +19,6 @@ export default function ConsentBanner() {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
essential: t("essential"),
|
||||
analytics: t("analytics"),
|
||||
chat: t("chat"),
|
||||
alwaysOn: t("alwaysOn"),
|
||||
acceptAll: t("acceptAll"),
|
||||
@@ -68,16 +67,6 @@ export default function ConsentBanner() {
|
||||
<div className="text-[11px] text-stone-500">{s.alwaysOn}</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between gap-3 py-1">
|
||||
<span className="text-sm font-semibold text-stone-800">{s.analytics}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.analytics}
|
||||
onChange={(e) => setDraft((p) => ({ ...p, analytics: e.target.checked }))}
|
||||
className="w-4 h-4 accent-stone-900"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-3 py-1">
|
||||
<span className="text-sm font-semibold text-stone-800">{s.chat}</span>
|
||||
<input
|
||||
@@ -91,7 +80,7 @@ export default function ConsentBanner() {
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setConsent({ analytics: true, chat: true })}
|
||||
onClick={() => setConsent({ chat: true })}
|
||||
className="px-4 py-2 rounded-xl bg-stone-900 text-stone-50 font-semibold hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
{s.acceptAll}
|
||||
@@ -103,7 +92,7 @@ export default function ConsentBanner() {
|
||||
{s.acceptSelected}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConsent({ analytics: false, chat: false })}
|
||||
onClick={() => setConsent({ chat: false })}
|
||||
className="px-4 py-2 rounded-xl bg-transparent text-stone-600 font-semibold hover:text-stone-900 transition-colors"
|
||||
>
|
||||
{s.rejectAll}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export type ConsentState = {
|
||||
analytics: boolean;
|
||||
chat: boolean;
|
||||
};
|
||||
|
||||
@@ -20,7 +19,6 @@ function readConsentFromCookie(): ConsentState | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<ConsentState>;
|
||||
return {
|
||||
analytics: !!parsed.analytics,
|
||||
chat: !!parsed.chat,
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import type { JSONContent } from "@tiptap/react";
|
||||
import RichTextClient from "./RichTextClient";
|
||||
import dynamic from "next/dynamic";
|
||||
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
||||
|
||||
const Contact = () => {
|
||||
const { showEmailSent, showEmailError } = useToast();
|
||||
@@ -169,7 +170,7 @@ const Contact = () => {
|
||||
>
|
||||
<div className="max-w-3xl">
|
||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
|
||||
{t("title")}<span className="text-liquid-mint">.</span>
|
||||
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||
</h2>
|
||||
{cmsDoc ? (
|
||||
<RichTextClient doc={cmsDoc} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
|
||||
@@ -192,10 +193,10 @@ const Contact = () => {
|
||||
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
|
||||
<div className="relative z-10">
|
||||
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-12">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Connect</h4>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Connect</p>
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 rounded-full border border-emerald-500/20">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-600 dark:text-emerald-400">Online</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-700 dark:text-emerald-400">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +204,7 @@ const Contact = () => {
|
||||
{/* Email */}
|
||||
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Email</span>
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Email</span>
|
||||
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||
@@ -216,7 +217,7 @@ const Contact = () => {
|
||||
{/* GitHub */}
|
||||
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Code</span>
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Code</span>
|
||||
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||
@@ -229,7 +230,7 @@ const Contact = () => {
|
||||
{/* LinkedIn */}
|
||||
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Professional</span>
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Professional</span>
|
||||
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||
@@ -240,7 +241,7 @@ const Contact = () => {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 sm:mt-8 md:mt-12 pt-4 sm:pt-6 md:pt-8 border-t border-stone-100 dark:border-stone-800 relative z-10">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 mb-2">Location</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-2">Location</p>
|
||||
<div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
|
||||
<MapPin size={14} className="text-liquid-mint" />
|
||||
<span className="font-bold">{tInfo("locationValue")}</span>
|
||||
@@ -264,7 +265,7 @@ const Contact = () => {
|
||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6 md:space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||
{tForm("labels.name")}
|
||||
</label>
|
||||
<input
|
||||
@@ -281,7 +282,7 @@ const Contact = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||
{tForm("labels.email")}
|
||||
</label>
|
||||
<input
|
||||
@@ -299,7 +300,7 @@ const Contact = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||
{tForm("labels.subject")}
|
||||
</label>
|
||||
<input
|
||||
@@ -316,7 +317,7 @@ const Contact = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||
{tForm("labels.message")}
|
||||
</label>
|
||||
<textarea
|
||||
|
||||
@@ -33,14 +33,14 @@ const Footer = () => {
|
||||
{/* Navigation Links */}
|
||||
<div className="md:col-span-4 grid grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Legal</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Legal</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
|
||||
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Social</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Social</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
|
||||
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
|
||||
@@ -52,9 +52,9 @@ const Footer = () => {
|
||||
<div className="md:col-span-4 flex justify-start md:justify-end">
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="group flex flex-col items-center gap-4 text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||
className="group flex flex-col items-center gap-4 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-600 dark:text-stone-400 vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
|
||||
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all shadow-sm">
|
||||
<ArrowUp size={20} />
|
||||
</div>
|
||||
@@ -64,12 +64,12 @@ const Footer = () => {
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">
|
||||
<p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
|
||||
Built with Next.js, Directus & Passion.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">Systems Online</span>
|
||||
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">Systems Online</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const Grain = () => {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 z-[9999] h-full w-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-[-200%] h-[400%] w-[400%] bg-[url('/images/grain.png')] bg-repeat opacity-[0.03] dark:opacity-[0.05]"
|
||||
style={{ transform: 'translate3d(0, 0, 0)' }}
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, -50, 20, -10, 40, -20, 0],
|
||||
y: [0, 20, -30, 10, -20, 30, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
className="absolute inset-[-200%] h-[400%] w-[400%] bg-[url('/images/grain.png')] bg-repeat opacity-[0.03] dark:opacity-[0.05]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Grain;
|
||||
@@ -58,16 +58,16 @@ const Hero = () => {
|
||||
|
||||
<h1 className="text-[2.75rem] sm:text-6xl md:text-8xl lg:text-[9.5rem] font-black tracking-tighter leading-[0.85] text-stone-900 dark:text-stone-50 uppercase">
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
initial={{ x: -50 }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.1 }}
|
||||
className="block"
|
||||
>
|
||||
{getLabel("hero.line1", "Building")}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
initial={{ x: -50 }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-2 sm:pb-4"
|
||||
>
|
||||
@@ -75,14 +75,9 @@ const Hero = () => {
|
||||
</motion.span>
|
||||
</h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1, delay: 0.4 }}
|
||||
className="text-base sm:text-lg md:text-xl lg:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto lg:mx-0 font-light leading-relaxed tracking-tight"
|
||||
>
|
||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto lg:mx-0 font-light leading-relaxed tracking-tight">
|
||||
{t("description")}
|
||||
</motion.p>
|
||||
</p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -115,7 +110,7 @@ const Hero = () => {
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[3rem] sm:rounded-[4rem] lg:rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl" />
|
||||
<div className="relative w-full h-full rounded-[2.5rem] sm:rounded-[3rem] lg:rounded-[4rem] overflow-hidden border-[12px] sm:border-[16px] lg:border-[24px] border-white dark:border-stone-900 shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] sm:shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]">
|
||||
<Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority />
|
||||
<Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority sizes="(max-width: 640px) 208px, (max-width: 768px) 256px, (max-width: 1024px) 320px, 500px" />
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-4 -left-4 sm:-bottom-6 sm:-left-6 bg-white dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-xl sm:rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
|
||||
|
||||
@@ -52,7 +52,7 @@ const Projects = () => {
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 sm:mb-12 md:mb-16 gap-4 sm:gap-6">
|
||||
<div>
|
||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-2 sm:mb-4 uppercase">
|
||||
Selected Work<span className="text-liquid-mint">.</span>
|
||||
Selected Work<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||
</h2>
|
||||
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
|
||||
Projects that pushed my boundaries.
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
// Lazy load providers to avoid webpack module resolution issues
|
||||
const AnalyticsProvider = React.lazy(() =>
|
||||
import("@/components/AnalyticsProvider").then((mod) => ({
|
||||
default: mod.AnalyticsProvider,
|
||||
}))
|
||||
);
|
||||
|
||||
const ToastProvider = React.lazy(() =>
|
||||
import("@/components/Toast").then((mod) => ({
|
||||
default: mod.ToastProvider,
|
||||
@@ -38,14 +31,11 @@ export default function RootProviders({
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<div className="relative z-10">{children}</div>}>
|
||||
<AnalyticsProvider>
|
||||
<ToastProvider>
|
||||
<BackgroundBlobs />
|
||||
<div className="relative z-10">{children}</div>
|
||||
<ChatWidget />
|
||||
</ToastProvider>
|
||||
</AnalyticsProvider>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const ShaderGradientBackground = () => {
|
||||
<ShaderGradient
|
||||
control="props"
|
||||
type="sphere"
|
||||
animate="on"
|
||||
animate="off"
|
||||
brightness={1.3}
|
||||
cAzimuthAngle={180}
|
||||
cDistance={3.6}
|
||||
@@ -57,7 +57,7 @@ const ShaderGradientBackground = () => {
|
||||
<ShaderGradient
|
||||
control="props"
|
||||
type="sphere"
|
||||
animate="on"
|
||||
animate="off"
|
||||
brightness={1.25}
|
||||
cAzimuthAngle={180}
|
||||
cDistance={3.6}
|
||||
@@ -83,7 +83,7 @@ const ShaderGradientBackground = () => {
|
||||
<ShaderGradient
|
||||
control="props"
|
||||
type="sphere"
|
||||
animate="on"
|
||||
animate="off"
|
||||
brightness={1.2}
|
||||
cAzimuthAngle={180}
|
||||
cDistance={3.6}
|
||||
|
||||
@@ -2,19 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Grain Effect */
|
||||
.grain-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.04;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Warm Brown & Off-White Palette */
|
||||
|
||||
@@ -5,7 +5,6 @@ import React from "react";
|
||||
import ClientProviders from "./components/ClientProviders";
|
||||
import { cookies } from "next/headers";
|
||||
import { getBaseUrl } from "@/lib/seo";
|
||||
import ShaderGradientBackground from "./components/ShaderGradientBackground";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -35,7 +34,6 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
||||
<div className="grain-overlay" aria-hidden="true" />
|
||||
<ShaderGradientBackground />
|
||||
<ClientProviders>{children}</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -41,25 +41,6 @@ const ProjectDetail = () => {
|
||||
const loadedProject = data.projects[0];
|
||||
setProject(loadedProject);
|
||||
|
||||
// Track page view
|
||||
try {
|
||||
await fetch('/api/analytics/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'pageview',
|
||||
projectId: loadedProject.id.toString(),
|
||||
page: `/projects/${slug}`
|
||||
})
|
||||
});
|
||||
} catch (trackError) {
|
||||
// Silently fail tracking
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error tracking page view:', trackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useWebVitals } from '@/lib/useWebVitals';
|
||||
import { trackEvent, trackPageLoad } from '@/lib/analytics';
|
||||
import { debounce } from '@/lib/utils';
|
||||
|
||||
interface AnalyticsProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
|
||||
const hasTrackedInitialView = useRef(false);
|
||||
const hasTrackedPerformance = useRef(false);
|
||||
const currentPath = useRef('');
|
||||
|
||||
// Initialize Web Vitals tracking - wrapped to prevent crashes
|
||||
// Hooks must be called unconditionally, but the hook itself handles errors
|
||||
useWebVitals();
|
||||
|
||||
// Track page view - memoized to prevent recreation
|
||||
const trackPageView = useCallback(async () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const path = window.location.pathname;
|
||||
|
||||
// Only track if path has changed (prevents duplicate tracking)
|
||||
if (currentPath.current === path && hasTrackedInitialView.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentPath.current = path;
|
||||
hasTrackedInitialView.current = true;
|
||||
|
||||
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
||||
const projectId = projectMatch ? projectMatch[1] : null;
|
||||
|
||||
// Track to Umami (if available)
|
||||
trackEvent('page-view', {
|
||||
url: path,
|
||||
referrer: document.referrer,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Track to our API - single call
|
||||
try {
|
||||
await fetch('/api/analytics/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'pageview',
|
||||
projectId: projectId,
|
||||
page: path
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error tracking page view:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Wrap entire effect in try-catch to prevent any errors from breaking the app
|
||||
try {
|
||||
// Track page load performance - wrapped in try-catch
|
||||
try {
|
||||
trackPageLoad();
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking page load:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Track initial page view
|
||||
trackPageView();
|
||||
|
||||
// Track performance metrics to our API - only once
|
||||
const trackPerformanceToAPI = async () => {
|
||||
// Prevent duplicate tracking
|
||||
if (hasTrackedPerformance.current) return;
|
||||
hasTrackedPerformance.current = true;
|
||||
|
||||
try {
|
||||
if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current page path to extract project ID if on project page
|
||||
const path = window.location.pathname;
|
||||
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
||||
const projectId = projectMatch ? projectMatch[1] : null;
|
||||
|
||||
// Wait for page to fully load
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
|
||||
const paintEntries = performance.getEntriesByType('paint');
|
||||
const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
|
||||
|
||||
const fcp = paintEntries.find((e: PerformanceEntry) => e.name === 'first-contentful-paint');
|
||||
const lcp = lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : undefined;
|
||||
|
||||
const performanceData = {
|
||||
loadTime: navigation && navigation.loadEventEnd && navigation.fetchStart ? navigation.loadEventEnd - navigation.fetchStart : 0,
|
||||
fcp: fcp ? fcp.startTime : 0,
|
||||
lcp: lcp ? lcp.startTime : 0,
|
||||
ttfb: navigation && navigation.responseStart && navigation.fetchStart ? navigation.responseStart - navigation.fetchStart : 0,
|
||||
cls: 0, // Will be updated by CLS observer
|
||||
fid: 0, // Will be updated by FID observer
|
||||
si: 0 // Speed Index - would need to calculate
|
||||
};
|
||||
|
||||
// Send performance data - single call
|
||||
await fetch('/api/analytics/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'performance',
|
||||
projectId: projectId,
|
||||
page: path,
|
||||
performance: performanceData
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - performance tracking is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error collecting performance data:', error);
|
||||
}
|
||||
}
|
||||
}, 2500); // Wait 2.5 seconds for page to stabilize
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error tracking performance:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track performance after page load
|
||||
if (document.readyState === 'complete') {
|
||||
trackPerformanceToAPI();
|
||||
} else {
|
||||
window.addEventListener('load', trackPerformanceToAPI, { once: true });
|
||||
}
|
||||
|
||||
// Track route changes (for SPA navigation) - debounced
|
||||
const handleRouteChange = debounce(() => {
|
||||
// Track new page view (trackPageView will handle path change detection)
|
||||
trackPageView();
|
||||
trackPageLoad();
|
||||
}, 300);
|
||||
|
||||
// Listen for popstate events (back/forward navigation)
|
||||
window.addEventListener('popstate', handleRouteChange);
|
||||
|
||||
// Track user interactions - debounced to prevent spam
|
||||
const handleClick = debounce((event: unknown) => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const mouseEvent = event as MouseEvent;
|
||||
const target = mouseEvent.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
|
||||
const element = target.tagName ? target.tagName.toLowerCase() : 'unknown';
|
||||
const className = target.className;
|
||||
const id = target.id;
|
||||
|
||||
trackEvent('click', {
|
||||
element,
|
||||
className: (typeof className === 'string' && className) ? className.split(' ')[0] : undefined,
|
||||
id: id || undefined,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - click tracking is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking click:', error);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Track form submissions
|
||||
const handleSubmit = (event: SubmitEvent) => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const form = event.target as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
|
||||
trackEvent('form-submit', {
|
||||
formId: form.id || undefined,
|
||||
formClass: form.className || undefined,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - form tracking is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking form submit:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track scroll depth - debounced
|
||||
let maxScrollDepth = 0;
|
||||
const firedScrollMilestones = new Set<number>();
|
||||
const handleScroll = debounce(() => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const scrollHeight = document.documentElement.scrollHeight;
|
||||
const innerHeight = window.innerHeight;
|
||||
|
||||
if (scrollHeight <= innerHeight) return; // No scrollable content
|
||||
|
||||
const scrollDepth = Math.round(
|
||||
(window.scrollY / (scrollHeight - innerHeight)) * 100
|
||||
);
|
||||
|
||||
if (scrollDepth > maxScrollDepth) maxScrollDepth = scrollDepth;
|
||||
|
||||
// Track each milestone once (avoid spamming events on every scroll tick)
|
||||
const milestones = [25, 50, 75, 90];
|
||||
for (const milestone of milestones) {
|
||||
if (maxScrollDepth >= milestone && !firedScrollMilestones.has(milestone)) {
|
||||
firedScrollMilestones.add(milestone);
|
||||
trackEvent('scroll-depth', { depth: milestone, url: window.location.pathname });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - scroll tracking is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking scroll:', error);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('submit', handleSubmit);
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
// Track errors
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('error', {
|
||||
message: event.message || 'Unknown error',
|
||||
filename: event.filename || undefined,
|
||||
lineno: event.lineno || undefined,
|
||||
colno: event.colno || undefined,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - error tracking should not cause more errors
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking error event:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('unhandled-rejection', {
|
||||
reason: event.reason?.toString() || 'Unknown rejection',
|
||||
url: window.location.pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - error tracking should not cause more errors
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking unhandled rejection:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('error', handleError);
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
try {
|
||||
// Cancel any pending debounced calls to prevent memory leaks
|
||||
handleRouteChange.cancel();
|
||||
handleClick.cancel();
|
||||
handleScroll.cancel();
|
||||
|
||||
// Remove event listeners
|
||||
window.removeEventListener('load', trackPerformanceToAPI);
|
||||
window.removeEventListener('popstate', handleRouteChange);
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('submit', handleSubmit);
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('error', handleError);
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
} catch {
|
||||
// Silently fail during cleanup
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// If anything fails, log but don't break the app
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('AnalyticsProvider initialization error:', error);
|
||||
}
|
||||
// Return empty cleanup function
|
||||
return () => {};
|
||||
}
|
||||
}, [trackPageView]);
|
||||
|
||||
// Always render children, even if analytics fails
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,169 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useMotionValue, useTransform, useSpring } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const BackgroundBlobs = () => {
|
||||
const mouseX = useMotionValue(0);
|
||||
const mouseY = useMotionValue(0);
|
||||
|
||||
const springConfig = { damping: 50, stiffness: 50, mass: 2 };
|
||||
const springX = useSpring(mouseX, springConfig);
|
||||
const springY = useSpring(mouseY, springConfig);
|
||||
|
||||
// Very subtle parallax offsets
|
||||
const x1 = useTransform(springX, (value) => value / 30);
|
||||
const y1 = useTransform(springY, (value) => value / 30);
|
||||
|
||||
const x2 = useTransform(springX, (value) => value / -25);
|
||||
const y2 = useTransform(springY, (value) => value / -25);
|
||||
|
||||
const x3 = useTransform(springX, (value) => value / 20);
|
||||
const y3 = useTransform(springY, (value) => value / 20);
|
||||
|
||||
const x4 = useTransform(springX, (value) => value / -35);
|
||||
const y4 = useTransform(springY, (value) => value / -35);
|
||||
|
||||
const x5 = useTransform(springX, (value) => value / 15);
|
||||
const y5 = useTransform(springY, (value) => value / 15);
|
||||
|
||||
// Prevent hydration mismatch
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const x = e.clientX - window.innerWidth / 2;
|
||||
const y = e.clientY - window.innerHeight / 2;
|
||||
mouseX.set(x);
|
||||
mouseY.set(y);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
return () => window.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [mouseX, mouseY, mounted]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
|
||||
{/* Mint blob - top left */}
|
||||
<motion.div
|
||||
className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[100px] mix-blend-multiply"
|
||||
style={{ x: x1, y: y1 }}
|
||||
animate={{
|
||||
scale: [1, 1.15, 1],
|
||||
rotate: [0, 45, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 40,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[60px] opacity-70"
|
||||
animate={{ scale: [1, 1.15, 1] }}
|
||||
transition={{ duration: 40, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||
/>
|
||||
|
||||
{/* Lavender blob - top right */}
|
||||
<motion.div
|
||||
className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[100px] mix-blend-multiply"
|
||||
style={{ x: x2, y: y2 }}
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
rotate: [0, -30, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 45,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[60px] opacity-70"
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 45, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||
/>
|
||||
|
||||
{/* Rose blob - bottom left */}
|
||||
<motion.div
|
||||
className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[100px] mix-blend-multiply"
|
||||
style={{ x: x3, y: y3 }}
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
rotate: [0, 60, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 50,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[60px] opacity-70"
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 50, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||
/>
|
||||
|
||||
{/* Peach blob - middle right */}
|
||||
<motion.div
|
||||
className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[120px] mix-blend-multiply"
|
||||
style={{ x: x4, y: y4 }}
|
||||
animate={{
|
||||
scale: [1, 1.25, 1],
|
||||
rotate: [0, -45, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 55,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Blue blob - center */}
|
||||
<motion.div
|
||||
className="absolute top-[50%] left-[40%] w-[38vw] h-[38vw] bg-blue-200/30 rounded-full blur-[110px] mix-blend-multiply"
|
||||
style={{ x: x5, y: y5 }}
|
||||
animate={{
|
||||
scale: [1, 1.18, 1],
|
||||
rotate: [0, 90, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 48,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pink blob - bottom right */}
|
||||
<motion.div
|
||||
className="absolute bottom-[10%] right-[-8%] w-[32vw] h-[32vw] bg-pink-200/35 rounded-full blur-[100px] mix-blend-multiply"
|
||||
animate={{
|
||||
scale: [1, 1.12, 1],
|
||||
rotate: [0, -60, 0],
|
||||
x: [0, -20, 0],
|
||||
y: [0, 20, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 43,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Yellow-green blob - top center */}
|
||||
<motion.div
|
||||
className="absolute top-[5%] left-[45%] w-[28vw] h-[28vw] bg-lime-200/30 rounded-full blur-[115px] mix-blend-multiply"
|
||||
animate={{
|
||||
scale: [1, 1.22, 1],
|
||||
rotate: [0, 75, 0],
|
||||
x: [0, 15, 0],
|
||||
y: [0, -15, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 52,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[60px] opacity-70"
|
||||
animate={{ scale: [1, 1.25, 1] }}
|
||||
transition={{ duration: 55, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,32 +1,2 @@
|
||||
// This file configures the initialization of Sentry on the client.
|
||||
// The added config here will be used whenever a users loads a page in their browser.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
// DSN from environment variable with fallback to wizard-provided value
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "https://148e31ea874c60f3d2e0f70fd6701f6b@o4510751135105024.ingest.de.sentry.io/4510751388926032",
|
||||
|
||||
// Add optional integrations for additional features
|
||||
integrations: [Sentry.replayIntegration()],
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Define how likely Replay events are sampled.
|
||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
// in development and sample at a lower rate in production
|
||||
replaysSessionSampleRate: 0.1,
|
||||
|
||||
// Define how likely Replay events are sampled when an error occurs.
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
// Enable sending user PII (Personally Identifiable Information)
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
||||
sendDefaultPii: true,
|
||||
});
|
||||
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
// Sentry client SDK disabled to reduce bundle size (~400KB).
|
||||
// To re-enable, restore the @sentry/nextjs import and withSentryConfig in next.config.ts.
|
||||
|
||||
144
lib/analytics.ts
144
lib/analytics.ts
@@ -1,144 +0,0 @@
|
||||
// Analytics utilities for Umami with Performance Tracking
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (event: string, data?: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface PerformanceMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
url: string;
|
||||
timestamp: number;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface WebVitalsMetric {
|
||||
name: 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB';
|
||||
value: number;
|
||||
delta: number;
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Track custom events to Umami
|
||||
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const trackFn = window.umami?.track;
|
||||
if (typeof trackFn !== "function") return;
|
||||
|
||||
try {
|
||||
trackFn(event, {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
url: window.location.pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - analytics must never break the app
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("Error tracking Umami event:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track performance metrics
|
||||
export const trackPerformance = (metric: PerformanceMetric) => {
|
||||
trackEvent('performance', {
|
||||
metric: metric.name,
|
||||
value: Math.round(metric.value),
|
||||
url: metric.url,
|
||||
userAgent: metric.userAgent,
|
||||
});
|
||||
};
|
||||
|
||||
// Track Web Vitals
|
||||
export const trackWebVitals = (metric: WebVitalsMetric) => {
|
||||
trackEvent('web-vitals', {
|
||||
name: metric.name,
|
||||
value: Math.round(metric.value),
|
||||
delta: Math.round(metric.delta),
|
||||
id: metric.id,
|
||||
url: metric.url,
|
||||
});
|
||||
};
|
||||
|
||||
// Track page load performance
|
||||
export const trackPageLoad = () => {
|
||||
if (typeof window === 'undefined' || typeof performance === 'undefined') return;
|
||||
|
||||
try {
|
||||
const navigationEntries = performance.getEntriesByType('navigation');
|
||||
const navigation = navigationEntries[0] as PerformanceNavigationTiming | undefined;
|
||||
|
||||
if (navigation && navigation.loadEventEnd && navigation.fetchStart) {
|
||||
trackPerformance({
|
||||
name: 'page-load',
|
||||
value: navigation.loadEventEnd - navigation.fetchStart,
|
||||
url: window.location.pathname,
|
||||
timestamp: Date.now(),
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
|
||||
// Track individual timing phases
|
||||
trackEvent('page-timing', {
|
||||
dns: navigation.domainLookupEnd && navigation.domainLookupStart
|
||||
? Math.round(navigation.domainLookupEnd - navigation.domainLookupStart)
|
||||
: 0,
|
||||
tcp: navigation.connectEnd && navigation.connectStart
|
||||
? Math.round(navigation.connectEnd - navigation.connectStart)
|
||||
: 0,
|
||||
request: navigation.responseStart && navigation.requestStart
|
||||
? Math.round(navigation.responseStart - navigation.requestStart)
|
||||
: 0,
|
||||
response: navigation.responseEnd && navigation.responseStart
|
||||
? Math.round(navigation.responseEnd - navigation.responseStart)
|
||||
: 0,
|
||||
dom: navigation.domContentLoadedEventEnd && navigation.responseEnd
|
||||
? Math.round(navigation.domContentLoadedEventEnd - navigation.responseEnd)
|
||||
: 0,
|
||||
load: navigation.loadEventEnd && navigation.domContentLoadedEventEnd
|
||||
? Math.round(navigation.loadEventEnd - navigation.domContentLoadedEventEnd)
|
||||
: 0,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - performance tracking is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking page load:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track API response times
|
||||
export const trackApiCall = (endpoint: string, duration: number, status: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('api-call', {
|
||||
endpoint,
|
||||
duration: Math.round(duration),
|
||||
status,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
// Track user interactions
|
||||
export const trackInteraction = (action: string, element?: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('interaction', {
|
||||
action,
|
||||
element,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
// Track errors
|
||||
export const trackError = (error: string, context?: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('error', {
|
||||
error,
|
||||
context,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
};
|
||||
@@ -196,9 +196,9 @@ if (typeof window === 'undefined') {
|
||||
}, 60000); // Clear every minute
|
||||
}
|
||||
|
||||
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean {
|
||||
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): boolean {
|
||||
const now = Date.now();
|
||||
const key = `admin_${ip}`;
|
||||
const key = `${prefix}_${ip}`;
|
||||
|
||||
const current = rateLimitMap.get(key);
|
||||
|
||||
@@ -215,8 +215,8 @@ export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: n
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000): Record<string, string> {
|
||||
const current = rateLimitMap.get(`admin_${ip}`);
|
||||
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): Record<string, string> {
|
||||
const current = rateLimitMap.get(`${prefix}_${ip}`);
|
||||
const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { trackWebVitals, trackPerformance } from './analytics';
|
||||
|
||||
// Web Vitals types
|
||||
interface Metric {
|
||||
name: string;
|
||||
value: number;
|
||||
delta: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Simple Web Vitals implementation (since we don't want to add external dependencies)
|
||||
const getCLS = (onPerfEntry: (metric: Metric) => void) => {
|
||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
||||
|
||||
try {
|
||||
let clsValue = 0;
|
||||
let sessionValue = 0;
|
||||
let sessionEntries: PerformanceEntry[] = [];
|
||||
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
try {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) {
|
||||
const firstSessionEntry = sessionEntries[0];
|
||||
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
|
||||
|
||||
if (sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000) {
|
||||
sessionValue += (entry as PerformanceEntry & { value?: number }).value || 0;
|
||||
sessionEntries.push(entry);
|
||||
} else {
|
||||
sessionValue = (entry as PerformanceEntry & { value?: number }).value || 0;
|
||||
sessionEntries = [entry];
|
||||
}
|
||||
|
||||
if (sessionValue > clsValue) {
|
||||
clsValue = sessionValue;
|
||||
onPerfEntry({
|
||||
name: 'CLS',
|
||||
value: clsValue,
|
||||
delta: clsValue,
|
||||
id: `cls-${Date.now()}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - CLS tracking is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('CLS tracking error:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ type: 'layout-shift', buffered: true });
|
||||
return observer;
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('CLS observer initialization failed:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getFID = (onPerfEntry: (metric: Metric) => void) => {
|
||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
try {
|
||||
for (const entry of list.getEntries()) {
|
||||
const processingStart = (entry as PerformanceEntry & { processingStart?: number }).processingStart;
|
||||
if (processingStart !== undefined) {
|
||||
onPerfEntry({
|
||||
name: 'FID',
|
||||
value: processingStart - entry.startTime,
|
||||
delta: processingStart - entry.startTime,
|
||||
id: `fid-${Date.now()}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('FID tracking error:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ type: 'first-input', buffered: true });
|
||||
return observer;
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('FID observer initialization failed:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getFCP = (onPerfEntry: (metric: Metric) => void) => {
|
||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
try {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.name === 'first-contentful-paint') {
|
||||
onPerfEntry({
|
||||
name: 'FCP',
|
||||
value: entry.startTime,
|
||||
delta: entry.startTime,
|
||||
id: `fcp-${Date.now()}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('FCP tracking error:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ type: 'paint', buffered: true });
|
||||
return observer;
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('FCP observer initialization failed:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getLCP = (onPerfEntry: (metric: Metric) => void) => {
|
||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
try {
|
||||
const entries = list.getEntries();
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
|
||||
if (lastEntry) {
|
||||
onPerfEntry({
|
||||
name: 'LCP',
|
||||
value: lastEntry.startTime,
|
||||
delta: lastEntry.startTime,
|
||||
id: `lcp-${Date.now()}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('LCP tracking error:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
||||
return observer;
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('LCP observer initialization failed:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTTFB = (onPerfEntry: (metric: Metric) => void) => {
|
||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
try {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === 'navigation') {
|
||||
const navEntry = entry as PerformanceNavigationTiming;
|
||||
if (navEntry.responseStart && navEntry.fetchStart) {
|
||||
onPerfEntry({
|
||||
name: 'TTFB',
|
||||
value: navEntry.responseStart - navEntry.fetchStart,
|
||||
delta: navEntry.responseStart - navEntry.fetchStart,
|
||||
id: `ttfb-${Date.now()}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('TTFB tracking error:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ type: 'navigation', buffered: true });
|
||||
return observer;
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('TTFB observer initialization failed:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Custom hook for Web Vitals tracking
|
||||
export const useWebVitals = () => {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Wrap everything in try-catch to prevent errors from breaking the app
|
||||
try {
|
||||
const safeNow = () => {
|
||||
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
};
|
||||
|
||||
// Store web vitals for batch sending
|
||||
const webVitals: Record<string, number> = {};
|
||||
const path = window.location.pathname;
|
||||
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
||||
const projectId = projectMatch ? projectMatch[1] : null;
|
||||
const observers: PerformanceObserver[] = [];
|
||||
|
||||
const sendWebVitals = async () => {
|
||||
if (Object.keys(webVitals).length >= 3) { // Wait for at least FCP, LCP, CLS
|
||||
try {
|
||||
await fetch('/api/analytics/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'performance',
|
||||
projectId: projectId,
|
||||
page: path,
|
||||
performance: {
|
||||
fcp: webVitals.FCP || 0,
|
||||
lcp: webVitals.LCP || 0,
|
||||
cls: webVitals.CLS || 0,
|
||||
fid: webVitals.FID || 0,
|
||||
ttfb: webVitals.TTFB || 0,
|
||||
loadTime: safeNow()
|
||||
}
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error sending web vitals:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track Core Web Vitals
|
||||
const clsObserver = getCLS((metric) => {
|
||||
webVitals.CLS = metric.value;
|
||||
trackWebVitals({
|
||||
...metric,
|
||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||
url: window.location.pathname,
|
||||
});
|
||||
sendWebVitals();
|
||||
});
|
||||
if (clsObserver) observers.push(clsObserver);
|
||||
|
||||
const fidObserver = getFID((metric) => {
|
||||
webVitals.FID = metric.value;
|
||||
trackWebVitals({
|
||||
...metric,
|
||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||
url: window.location.pathname,
|
||||
});
|
||||
sendWebVitals();
|
||||
});
|
||||
if (fidObserver) observers.push(fidObserver);
|
||||
|
||||
const fcpObserver = getFCP((metric) => {
|
||||
webVitals.FCP = metric.value;
|
||||
trackWebVitals({
|
||||
...metric,
|
||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||
url: window.location.pathname,
|
||||
});
|
||||
sendWebVitals();
|
||||
});
|
||||
if (fcpObserver) observers.push(fcpObserver);
|
||||
|
||||
const lcpObserver = getLCP((metric) => {
|
||||
webVitals.LCP = metric.value;
|
||||
trackWebVitals({
|
||||
...metric,
|
||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||
url: window.location.pathname,
|
||||
});
|
||||
sendWebVitals();
|
||||
});
|
||||
if (lcpObserver) observers.push(lcpObserver);
|
||||
|
||||
const ttfbObserver = getTTFB((metric) => {
|
||||
webVitals.TTFB = metric.value;
|
||||
trackWebVitals({
|
||||
...metric,
|
||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||
url: window.location.pathname,
|
||||
});
|
||||
sendWebVitals();
|
||||
});
|
||||
if (ttfbObserver) observers.push(ttfbObserver);
|
||||
|
||||
// Track page load performance
|
||||
const handleLoad = () => {
|
||||
setTimeout(() => {
|
||||
trackPerformance({
|
||||
name: 'page-load-complete',
|
||||
value: safeNow(),
|
||||
url: window.location.pathname,
|
||||
timestamp: Date.now(),
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
handleLoad();
|
||||
} else {
|
||||
window.addEventListener('load', handleLoad);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup all observers
|
||||
observers.forEach(observer => {
|
||||
try {
|
||||
observer.disconnect();
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
});
|
||||
try {
|
||||
window.removeEventListener('load', handleLoad);
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// If Web Vitals initialization fails, don't break the app
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Web Vitals initialization failed:', error);
|
||||
}
|
||||
// Return empty cleanup function
|
||||
return () => {};
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
@@ -3,8 +3,6 @@ import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import bundleAnalyzer from "@next/bundle-analyzer";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
// Load the .env file from the working directory
|
||||
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
|
||||
|
||||
@@ -46,7 +44,7 @@ const nextConfig: NextConfig = {
|
||||
// Image optimization
|
||||
images: {
|
||||
formats: ["image/webp", "image/avif"],
|
||||
minimumCacheTTL: 60,
|
||||
minimumCacheTTL: 2592000,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
@@ -169,7 +167,35 @@ const nextConfig: NextConfig = {
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/(.*)",
|
||||
// Only prevent caching for real-time/sensitive API routes
|
||||
source: "/api/n8n/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/auth/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/email/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/contacts/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
@@ -200,42 +226,4 @@ const withBundleAnalyzer = bundleAnalyzer({
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
||||
|
||||
// Wrap with Sentry
|
||||
export default withSentryConfig(
|
||||
withBundleAnalyzer(withNextIntl(nextConfig)),
|
||||
{
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||
|
||||
org: "dk0",
|
||||
project: "portfolio",
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
tunnelRoute: "/monitoring",
|
||||
|
||||
// Webpack-specific options
|
||||
webpack: {
|
||||
// Automatically annotate React components to show their full name in breadcrumbs and session replay
|
||||
reactComponentAnnotation: {
|
||||
enabled: true,
|
||||
},
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
treeshake: {
|
||||
removeDebugLogging: true,
|
||||
},
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors
|
||||
automaticVercelMonitors: true,
|
||||
},
|
||||
|
||||
// Source maps configuration
|
||||
sourcemaps: {
|
||||
disable: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
export default withBundleAnalyzer(withNextIntl(nextConfig));
|
||||
|
||||
548
package-lock.json
generated
548
package-lock.json
generated
@@ -10,10 +10,10 @@
|
||||
"dependencies": {
|
||||
"@next/bundle-analyzer": "^15.1.7",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@sentry/nextjs": "^10.36.0",
|
||||
"@shadergradient/react": "^2.4.20",
|
||||
"@swc/helpers": "^0.5.19",
|
||||
"@tiptap/extension-color": "^3.15.3",
|
||||
"@tiptap/extension-highlight": "^3.15.3",
|
||||
"@tiptap/extension-link": "^3.15.3",
|
||||
@@ -22,12 +22,10 @@
|
||||
"@tiptap/html": "^3.15.3",
|
||||
"@tiptap/react": "^3.15.3",
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"@types/three": "^0.182.0",
|
||||
"@vercel/og": "^0.6.5",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"framer-motion": "^12.24.10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "^15.5.7",
|
||||
"next-intl": "^4.7.0",
|
||||
@@ -39,12 +37,10 @@
|
||||
"react-dom": "^19.2.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-responsive-masonry": "^2.7.1",
|
||||
"redis": "^5.8.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"three": "^0.182.0",
|
||||
"zod": "^4.3.5"
|
||||
"three": "^0.183.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -58,8 +54,6 @@
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-responsive-masonry": "^2.6.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.11",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -629,12 +623,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
|
||||
@@ -2435,24 +2423,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.17",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
|
||||
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@monogrid/gainmap-js": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
|
||||
"integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"promise-worker-transferable": "^1.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.159.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@@ -3616,46 +3586,6 @@
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/drei": {
|
||||
"version": "10.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
|
||||
"integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@mediapipe/tasks-vision": "0.10.17",
|
||||
"@monogrid/gainmap-js": "^3.0.6",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"camera-controls": "^3.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"detect-gpu": "^5.0.56",
|
||||
"glsl-noise": "^0.0.0",
|
||||
"hls.js": "^1.5.17",
|
||||
"maath": "^0.10.8",
|
||||
"meshline": "^3.3.1",
|
||||
"stats-gl": "^2.2.8",
|
||||
"stats.js": "^0.17.0",
|
||||
"suspend-react": "^0.1.3",
|
||||
"three-mesh-bvh": "^0.8.3",
|
||||
"three-stdlib": "^2.35.6",
|
||||
"troika-three-text": "^0.52.4",
|
||||
"tunnel-rat": "^0.1.2",
|
||||
"use-sync-external-store": "^1.4.0",
|
||||
"utility-types": "^3.11.0",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-three/fiber": "^9.0.0",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"three": ">=0.159"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/fiber": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz",
|
||||
@@ -4865,9 +4795,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
@@ -5483,12 +5413,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -5570,12 +5494,6 @@
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/draco3d": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
|
||||
"integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -5806,12 +5724,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/offscreencanvas": {
|
||||
"version": "2019.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
||||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.15.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz",
|
||||
@@ -5859,26 +5771,6 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-responsive-masonry": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-responsive-masonry/-/react-responsive-masonry-2.7.0.tgz",
|
||||
"integrity": "sha512-eMOxLcmPo3M8IDcTCmgK/luxjlJiqK1glZr15iM0+DYhL0QFlJvnNEgjhyOBGFlXsjlnLbcz1/M3/Q3fSeU1sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-syntax-highlighter": {
|
||||
"version": "15.5.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
|
||||
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html": {
|
||||
"version": "2.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.0.tgz",
|
||||
@@ -5896,12 +5788,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/tedious": {
|
||||
"version": "4.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
|
||||
@@ -5911,21 +5797,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.182.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz",
|
||||
"integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
@@ -6529,24 +6400,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@use-gesture/core": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
|
||||
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@use-gesture/react": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
|
||||
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@use-gesture/core": "10.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/og": {
|
||||
"version": "0.6.8",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/og/-/og-0.6.8.tgz",
|
||||
@@ -6722,12 +6575,6 @@
|
||||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.69",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
|
||||
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@xtuc/ieee754": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||
@@ -7401,15 +7248,6 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -7620,19 +7458,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camera-controls": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz",
|
||||
"integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.126.1"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001770",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
|
||||
@@ -8026,6 +7851,7 @@
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.1"
|
||||
@@ -8352,15 +8178,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-gpu": {
|
||||
"version": "5.0.70",
|
||||
"resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
|
||||
"integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"webgl-constants": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -8540,12 +8357,6 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/draco3d": {
|
||||
"version": "1.5.7",
|
||||
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
||||
"integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -9282,6 +9093,7 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
@@ -9424,18 +9236,6 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -9549,12 +9349,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -9992,12 +9786,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/glsl-noise": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
|
||||
"integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -10017,43 +9805,6 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/gzip-size": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
|
||||
@@ -10254,12 +10005,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.15",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||
@@ -10416,12 +10161,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -10754,15 +10493,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -10929,12 +10659,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
|
||||
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||
@@ -12399,15 +12123,6 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -12462,15 +12177,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -12619,16 +12325,6 @@
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/maath": {
|
||||
"version": "0.10.8",
|
||||
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
|
||||
"integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/three": ">=0.134.0",
|
||||
"three": ">=0.134.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -12898,21 +12594,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/meshline": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
|
||||
"integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">=0.137"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||
@@ -13697,6 +13378,15 @@
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -14647,12 +14337,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
|
||||
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -14720,16 +14404,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/promise-worker-transferable": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
||||
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"is-promise": "^2.1.0",
|
||||
"lie": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -15125,12 +14799,6 @@
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-responsive-masonry": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-responsive-masonry/-/react-responsive-masonry-2.7.1.tgz",
|
||||
"integrity": "sha512-Q+u+nOH87PzjqGFd2PgTcmLpHPZnCmUPREHYoNBc8dwJv6fi51p9U6hqwG8g/T8MN86HrFjrU+uQU6yvETU7cA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-use-measure": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||
@@ -15302,6 +14970,7 @@
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -15686,19 +15355,6 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -16008,6 +15664,7 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
@@ -16061,32 +15718,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/stats-gl": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
|
||||
"integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/three": "*",
|
||||
"three": "^0.170.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/three": "*",
|
||||
"three": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/stats-gl/node_modules/three": {
|
||||
"version": "0.170.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
|
||||
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stats.js": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz",
|
||||
"integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -16361,15 +15992,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-final-newline": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
|
||||
@@ -16775,41 +16397,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.182.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz",
|
||||
"integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.159.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three-stdlib": {
|
||||
"version": "2.36.1",
|
||||
"resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz",
|
||||
"integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/draco3d": "^1.4.0",
|
||||
"@types/offscreencanvas": "^2019.6.4",
|
||||
"@types/webxr": "^0.5.2",
|
||||
"draco3d": "^1.4.1",
|
||||
"fflate": "^0.6.9",
|
||||
"potpack": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.128.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three-stdlib/node_modules/fflate": {
|
||||
"version": "0.6.10",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
|
||||
"integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
|
||||
"version": "0.183.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.183.1.tgz",
|
||||
"integrity": "sha512-Psv6bbd3d/M/01MT2zZ+VmD0Vj2dbWTNhfe4CuSg7w5TuW96M3NOyCVuh9SZQ05CpGmD7NEcJhZw4GVjhCYxfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
@@ -16902,36 +16492,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-three-text": {
|
||||
"version": "0.52.4",
|
||||
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
|
||||
"integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.0.2",
|
||||
"troika-three-utils": "^0.52.4",
|
||||
"troika-worker-utils": "^0.52.0",
|
||||
"webgl-sdf-generator": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.125.0"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-three-utils": {
|
||||
"version": "0.52.4",
|
||||
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
|
||||
"integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">=0.125.0"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-worker-utils": {
|
||||
"version": "0.52.0",
|
||||
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
|
||||
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/trough": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
|
||||
@@ -17147,43 +16707,6 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-rat": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
|
||||
"integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zustand": "^4.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-rat/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -17599,15 +17122,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utility-types": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
|
||||
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
@@ -17714,17 +17228,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webgl-constants": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
|
||||
"integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
|
||||
},
|
||||
"node_modules/webgl-sdf-generator": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
|
||||
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
@@ -18323,15 +17826,6 @@
|
||||
"integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||
|
||||
24
package.json
24
package.json
@@ -54,10 +54,10 @@
|
||||
"dependencies": {
|
||||
"@next/bundle-analyzer": "^15.1.7",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@sentry/nextjs": "^10.36.0",
|
||||
"@shadergradient/react": "^2.4.20",
|
||||
"@swc/helpers": "^0.5.19",
|
||||
"@tiptap/extension-color": "^3.15.3",
|
||||
"@tiptap/extension-highlight": "^3.15.3",
|
||||
"@tiptap/extension-link": "^3.15.3",
|
||||
@@ -66,12 +66,10 @@
|
||||
"@tiptap/html": "^3.15.3",
|
||||
"@tiptap/react": "^3.15.3",
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"@types/three": "^0.182.0",
|
||||
"@vercel/og": "^0.6.5",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"framer-motion": "^12.24.10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "^15.5.7",
|
||||
"next-intl": "^4.7.0",
|
||||
@@ -83,13 +81,17 @@
|
||||
"react-dom": "^19.2.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-responsive-masonry": "^2.7.1",
|
||||
"redis": "^5.8.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"three": "^0.182.0",
|
||||
"zod": "^4.3.5"
|
||||
"three": "^0.183.1"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Chrome versions",
|
||||
"last 2 Firefox versions",
|
||||
"last 2 Safari versions",
|
||||
"last 2 Edge versions"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.57.0",
|
||||
@@ -102,8 +104,6 @@
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-responsive-masonry": "^2.6.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.11",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -121,5 +121,11 @@
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "5.9.3",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"chrome >= 100",
|
||||
"firefox >= 100",
|
||||
"safari >= 15",
|
||||
"edge >= 100"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
// DSN from environment variable with fallback to wizard-provided value
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "https://148e31ea874c60f3d2e0f70fd6701f6b@o4510751135105024.ingest.de.sentry.io/4510751388926032",
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
// DSN from environment variable with fallback to wizard-provided value
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "https://148e31ea874c60f3d2e0f70fd6701f6b@o4510751135105024.ingest.de.sentry.io/4510751388926032",
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user