Compare commits

..

37 Commits

Author SHA1 Message Date
denshooter
0f7ea8ca4d perf: remove Sentry client SDK and lazy-load TipTap (~830KB saved)
All checks were successful
Gitea CI / test-build (push) Successful in 11m36s
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 13m14s
- Remove withSentryConfig wrapper from next.config.ts (Sentry was disabled anyway)
- Clear instrumentation-client.ts to prevent Sentry client bundle (~400KB)
- Lazy-load RichTextClient via next/dynamic in About.tsx and Contact.tsx
- Defers TipTap/ProseMirror loading until CMS data arrives (~430KB)
- Homepage First Load JS: 1479KB → 646KB (56% reduction)
- Shared JS: 182KB → 102KB (44% reduction)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 16:37:17 +01:00
denshooter
c00fe6b06c perf: optimize Lighthouse scores to 100
All checks were successful
Gitea CI / test-build (push) Successful in 12m5s
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 14m37s
Performance:
- Lazy-load ShaderGradientBackground via dynamic import (reduces initial JS ~250KB)
- Disable ShaderGradient animations (animate=off) to reduce CPU/GPU load
- Remove opacity:0 animations from Hero LCP elements for instant paint
- Add browserslist targeting modern browsers (eliminates ~13KB polyfills)

Accessibility:
- Fix color contrast: text-stone-400 → text-stone-600 dark:text-stone-400 on light backgrounds
- Fix text-liquid-mint → text-emerald-700/600 for readable text/accent dots
- Fix quote text contrast on dark status box (text-stone-700 → text-stone-300)
- Fix Online badge contrast (emerald-600 → emerald-700)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 14:53:32 +01:00
denshooter
dcaa1f8c3c chore: remove accidental files from tracking, gitignore .claude/ and ._*
All checks were successful
Gitea CI / test-build (push) Successful in 11m57s
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 13m51s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 02:22:23 +01:00
denshooter
c49493bb44 perf: disable Sentry, remove grain overlay and shader gradient files
- Disable Sentry in all 3 configs (client/server/edge) - replayIntegration
  was recording every DOM mutation causing overhead in Chrome
- Remove grain-overlay div and its CSS (SVG feTurbulence + mix-blend-mode:overlay
  forces software compositing in Chrome on every frame)
- Remove mix-blend-multiply from BackgroundBlobs (prevents Chrome GPU compositing)
- Delete unused Grain.tsx, ShaderGradientBackground.tsx and its client wrapper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 02:21:44 +01:00
denshooter
c9cd2d734d perf: remove WebGL ShaderGradient and reduce BackgroundBlobs blur
Some checks failed
Gitea CI / test-build (push) Successful in 12m1s
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
ShaderGradientBackground used 3 full-screen Three.js WebGL canvases
with a blur(150px) CSS filter, crashing Lighthouse and causing severe
lag in Chrome. BackgroundBlobs also had 7 elements with blur(100-120px)
and per-frame mouse spring tracking compounding the issue.

- Remove ShaderGradientBackground from layout (WebGL not needed for a blur effect)
- Reduce BackgroundBlobs blur from 100-120px to 60px
- Remove mouse tracking spring animations from BackgroundBlobs
- Reduce to 4 blobs (remove 3 least visible)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 01:54:48 +01:00
denshooter
ef72f5fc58 fix: move ShaderGradientBackground dynamic import into client wrapper
All checks were successful
Gitea CI / test-build (push) Successful in 12m8s
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 14m37s
next/dynamic with ssr:false is not allowed in Server Components.
Follows existing BackgroundBlobsClient pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 01:03:51 +01:00
denshooter
8b440dd60b fix: prefix unused cmsMessages state with _ to satisfy lint rule
Some checks failed
Gitea CI / test-build (push) Failing after 6m4s
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 00:48:59 +01:00
copilot-swe-agent[bot]
9a55dc7f81 perf: fix TBT/LCP/a11y — disable shader animation, cache APIs, fix images
Some checks failed
Gitea CI / test-build (push) Failing after 5m19s
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 6m0s
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>
2026-03-01 22:18:32 +00:00
copilot-swe-agent[bot]
3ac7c7a5b3 perf: lazy-load ShaderGradient and fix image cache TTL
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>
2026-03-01 22:12:27 +00:00
copilot-swe-agent[bot]
96d7ae5747 Initial plan 2026-03-01 22:04:19 +00:00
denshooter
f7b7eaeaff chore: merge dev into production
Some checks failed
Gitea CI / test-build (push) Failing after 5m21s
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m23s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 23:12:57 +01:00
denshooter
32e621df14 fix: namespace rate limit buckets per endpoint, remove custom analytics
Some checks failed
Gitea CI / test-build (push) Failing after 5m21s
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 18m29s
- Add `prefix` param to checkRateLimit/getRateLimitHeaders so each endpoint
  has its own bucket (previously all shared `admin_${ip}`, causing 429s when
  analytics/track incremented past n8n endpoints' lower limits)
- n8n/hardcover/currently-reading → prefix 'n8n-reading'
- n8n/status → prefix 'n8n-status'
- analytics/track → prefix 'analytics-track'
- Remove custom analytics system (AnalyticsProvider, lib/analytics,
  lib/useWebVitals, all /api/analytics/* routes) — was causing 500s in
  production due to missing PostgreSQL PageView table
- Remove analytics consent toggle from ConsentBanner/ConsentProvider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 23:12:50 +01:00
denshooter
6c5297836c fix: randomize quotes, remove CMS idle quote, fix postgres image tag
Some checks failed
Gitea CI / test-build (push) Failing after 5m19s
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 17m49s
- Remove hardcoded Dennis Konkol idle quote from rotation
- Double quote pool (5 → 12 quotes per locale)
- Start at a random quote on page load
- Cycle to a random non-repeating quote every 10s instead of sequential
- Fix dev-deploy.yml: postgres:15-alpine → postgres:16-alpine

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 12:57:04 +01:00
denshooter
9c7e564f6f chore: re-enable production deploy workflow on production branch
All checks were successful
Gitea CI / test-build (push) Successful in 12m4s
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 12:40:58 +01:00
denshooter
4046a3c5b3 chore: add ci.yml to dev branch (Node 22, lint/test/build)
Some checks failed
Gitea CI / test-build (push) Successful in 12m5s
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 12:40:47 +01:00
denshooter
3e83dcfa15 chore: merge dev into production + fix ci.yml Node version
All checks were successful
Gitea CI / test-build (push) Successful in 12m18s
- Merge dev: disable GitHub CI/CD, fix @swc/helpers, clean unused deps
- Fix ci.yml: bump Node from 20 to 22 (required by camera-controls)
- Add dev branch to ci.yml trigger branches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 12:21:57 +01:00
denshooter
b0ec4fd4b7 chore: merge dev into production
- Disable GitHub CI/CD (Gitea only)
- Fix @swc/helpers peer dependency for npm ci on Node v20
- Remove unused dependencies (@react-three/drei, gray-matter, zod, etc.)
- Restore three and @react-three/fiber required by @shadergradient/react

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 12:06:56 +01:00
denshooter
6ee52ffc8e fix: restore three and @react-three/fiber required by @shadergradient/react
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 18m39s
@shadergradient/react imports these at runtime even though they are not
declared as peer dependencies in its package.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 11:44:38 +01:00
denshooter
450fe1b3eb chore: remove unused dependencies
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 6m10s
Remove @react-three/drei, @react-three/fiber, three, @types/three
(replaced by @shadergradient/react), plus gray-matter, zod,
react-responsive-masonry and related @types packages that are
not imported anywhere in the codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 11:36:17 +01:00
denshooter
f1d42818ee fix: disable GitHub CI/CD and resolve @swc/helpers peer dependency
- Delete .github/workflows/ci-cd.yml to stop GitHub Actions (Gitea only)
- Add @swc/helpers@^0.5.19 explicitly to satisfy next-intl's @swc/core
  peer dependency requirement (>=0.5.17), fixing npm ci on Node v20/npm v10

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 10:52:18 +01:00
Dennis Konkol
e0e0551a83 ci: disable broken auto-deploy workflows, keep gitea CI only
Some checks failed
Gitea CI / test-build (push) Failing after 4m47s
2026-02-24 19:49:13 +00:00
Dennis Konkol
97c600df14 ci: disable GitHub workflow and add Gitea Actions workflow
Some checks failed
Gitea CI / test-build (push) Failing after 4m49s
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 8m18s
2026-02-24 18:54:31 +00:00
denshooter
6c47cdbd83 Merge branch 'dev' into production
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m52s
2026-02-23 23:20:22 +01:00
denshooter
bd6007f299 Merge branch 'dev' into production
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 8m0s
2026-02-23 16:03:38 +01:00
denshooter
689cfa18cf Merge branch 'dev' into production
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 14m19s
2026-02-17 14:47:04 +01:00
denshooter
4029cd660d fix: Switch projects to Directus, add security fixes and example projects
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 14m27s
2026-02-09 16:40:08 +01:00
denshooter
b754af20e6 fix: Security vulnerability - block malicious file requests
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 14m30s
2026-02-09 16:02:10 +01:00
denshooter
3f31d6f5bb Use Directus content in production
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 14m21s
2026-02-05 00:23:11 +01:00
denshooter
8eff9106f5 Fix German jogging fallback text
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
2026-02-05 00:22:26 +01:00
denshooter
af30449071 Fix cache permission error in Docker container
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m19s
- Create cache directories AFTER copying standalone files
- Create both fetch-cache and images subdirectories
- Set proper ownership for nextjs user
- Fixes EACCES permission denied errors for prerender cache
2026-02-03 23:37:37 +01:00
denshooter
98c3ebb96c Fix postgres health check in production
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m0s
- Remove init-db.sql volume mount (not available in CI/CD environment)
- Init script not needed as Prisma handles schema migrations
- Postgres will initialize empty database automatically
2026-02-03 23:09:41 +01:00
denshooter
9e2040cefc Fix production deployment: Start database dependencies
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 7m29s
- Remove --no-deps flag which prevented postgres and redis from starting
- Remove --build flag as image is already built in previous step
- This fixes 'Can't reach database server at postgres:5432' error
2026-02-03 22:56:34 +01:00
denshooter
719071345e Update Dockerfile to use Node.js 25
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 13m16s
- Update base image from node:20 to node:25
- Matches Gitea workflow configuration and camera-controls@3.1.2 requirements
2026-02-03 22:38:45 +01:00
denshooter
efafd38b1a Update Node.js version to 25 in Gitea workflows
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 7m46s
- Fix EBADENGINE error for camera-controls@3.1.2 which requires Node.js >=22
- Update production-deploy.yml, dev-deploy.yml, and ci-cd-with-gitea-vars.yml.disabled
- Node.js v25 matches local development environment
2026-02-03 22:29:38 +01:00
denshooter
5c70b26508 Merge dev into production: Add shader gradient background with blur effects and all locale improvements
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 4m46s
2026-02-02 16:19:50 +01:00
denshooter
ede591c89e Fix ActivityFeed hydration error: Move localStorage read to useEffect to prevent server/client mismatch
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m10s
2026-01-10 18:28:25 +01:00
denshooter
2defd7a4a9 Fix ActivityFeed: Remove dynamic import that was causing it to disappear in production
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
2026-01-10 18:16:01 +01:00
45 changed files with 307 additions and 2935 deletions

32
.gitea/workflows/ci.yml Normal file
View 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

View File

@@ -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..."

View File

@@ -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
View File

@@ -1,5 +1,9 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Local tooling
.claude/
._*
# dependencies
/node_modules
/.pnp

View File

@@ -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();

View File

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

View File

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

View File

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

View File

@@ -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(),
});
}

View File

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

View File

@@ -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
@@ -25,31 +27,29 @@ export async function GET(request: NextRequest) {
const locale = searchParams.get('locale') || 'en';
const reviews = await getBookReviews(locale);
if (process.env.NODE_ENV === 'development') {
console.log(`[API] Book Reviews geladen für ${locale}:`, reviews?.length || 0);
}
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) {
console.error('Error loading book reviews:', 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 }
);
}

View File

@@ -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,21 +17,32 @@ export async function GET(request: NextRequest) {
// 1) Try Directus first
const directusPage = await getContentPage(key, locale);
if (directusPage) {
return NextResponse.json({
content: {
title: directusPage.title,
slug: directusPage.slug,
locale: directusPage.locale || locale,
content: directusPage.content,
return NextResponse.json(
{
content: {
title: directusPage.title,
slug: directusPage.slug,
locale: directusPage.locale || locale,
content: directusPage.content,
},
source: "directus",
},
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") {

View File

@@ -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) {
console.error('Error loading hobbies:', 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 }
);
}

View File

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

View File

@@ -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 }

View File

@@ -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 }

View File

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

View File

@@ -3,13 +3,15 @@ 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
*
*
* Loads Tech Stack from Directus with fallback to static data
*
*
* Query params:
* - locale: en or de (default: en)
*/
@@ -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) {
console.error('Error loading tech stack:', 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 }
);
}

View File

@@ -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>

View File

@@ -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" },
]
};
@@ -39,30 +54,20 @@ function getSafeGamingText(details: string | number | undefined, state: string |
return fallback;
}
export default function ActivityFeed({
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">
&ldquo;{allQuotes[quoteIndex].content}&rdquo;
</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
src={data.music.albumArt}
alt="Album Art"
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
<Image
src={data.music.albumArt}
alt="Album Art"
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">

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

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

View File

@@ -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.

View File

@@ -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>
<ToastProvider>
<BackgroundBlobs />
<div className="relative z-10">{children}</div>
<ChatWidget />
</ToastProvider>
</React.Suspense>
);
}

View File

@@ -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}

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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}</>;
};

View File

@@ -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>
);

View File

@@ -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.

View File

@@ -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,
});
};

View File

@@ -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 {

View File

@@ -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 () => {};
}
}, []);
};

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
]
}

View File

@@ -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,
});

View File

@@ -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,
});